Merge branch 'main' into app-1809

This commit is contained in:
vineyardbovines
2026-02-17 08:48:10 -05:00
139 changed files with 4023 additions and 2600 deletions
+3
View File
@@ -46,3 +46,6 @@ GEOLOCATION_DEV_URL=
# live-events web worker URL
LIVE_EVENTS_DEV_URL=
# app-config web worker URL
APP_CONFIG_DEV_URL=
+6 -3
View File
@@ -24,13 +24,15 @@ yarn android # Run on Android
yarn ios # Run on iOS
# Testing & Quality
# IMPORTANT: Always use these yarn scripts, never call the underlying tools directly
yarn test # Run Jest tests
yarn lint # Run ESLint
yarn typecheck # Run TypeScript type checking
# Internationalization
yarn intl:extract # Extract translation strings (you don't typically need to run this manually, we have CI for it)
yarn intl:compile # Compile translations for runtime
# DO NOT run these commands - extraction and compilation are handled by CI
yarn intl:extract # Extract translation strings (nightly CI job)
yarn intl:compile # Compile translations for runtime (nightly CI job)
# Build
yarn build-web # Build web version
@@ -299,8 +301,9 @@ function MyComponent() {
**Commands:**
```bash
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
yarn intl:extract # Extract new strings to locale files
yarn intl:compile # Compile for runtime (required after changes)
yarn intl:compile # Compile translations for runtime
```
## State Management
+24
View File
@@ -437,6 +437,13 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://www.flickr.com/groups/898944@N23/',
'https://www.flickr.com/groups',
'https://maxblansjaar.bandcamp.com/album/false-comforts',
'https://grmnygrmny.bandcamp.com/track/fluid',
'https://sufjanstevens.bandcamp.com/',
'https://sufjanstevens.bandcamp.com',
'https://bandcamp.com/',
'https://bandcamp.com',
]
const outputs = [
@@ -815,6 +822,23 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
{
type: 'bandcamp_album',
source: 'bandcamp',
playerUri:
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fmaxblansjaar.bandcamp.com%2Falbum%2Ffalse-comforts/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
},
{
type: 'bandcamp_track',
source: 'bandcamp',
playerUri:
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fgrmnygrmny.bandcamp.com%2Ftrack%2Ffluid/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
},
undefined,
undefined,
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867Zm0 10.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z"/></svg>

After

Width:  |  Height:  |  Size: 357 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+21 -14
View File
@@ -6,21 +6,26 @@ To build the SPA bundle (`bundle.web.js`), first get a JavaScript development
environment set up. Either follow the top-level README, or something quick
like:
# install nodejs
nvm install
nvm use
npm install --global yarn
```bash
# install nodejs
nvm install
nvm use
npm install --global yarn
# setup tools and deps (in top level of this repo)
yarn install --frozen-lockfile
# setup tools and deps (in top level of this repo)
yarn install --frozen-lockfile
# run yarn web dev server, if you wanted
yarn web
# run yarn web dev server, if you wanted
yarn web
```
Then build and copy over the big 'ol `bundle.web.js` file:
# in the top level of this repo
yarn build-web
```bash
# in the top level of this repo
yarn build-web
```
### Golang Daemon
@@ -28,11 +33,13 @@ Install golang. We generally develop against the current stable release of the l
In this directory (`bskyweb/`):
# re-build and run daemon
go run ./cmd/bskyweb serve
```bash
# re-build and run daemon
go run ./cmd/bskyweb serve
# build and output a binary
go build -o bskyweb ./cmd/bskyweb/
# build and output a binary
go build -o bskyweb ./cmd/bskyweb/
```
The easiest way to configure the daemon is to copy `example.env` to `.env` and
fill in auth values there.
+13 -2
View File
@@ -574,7 +574,10 @@ func (srv *Server) WebPost(c echo.Context) error {
if postView.Embed != nil && !isEmbedHidden {
hasImages := postView.Embed.EmbedImages_View != nil
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
hasVideo := postView.Embed.EmbedVideo_View != nil
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil
hasMediaImages := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
hasMediaVideo := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil
if hasImages {
var thumbUrls []string
@@ -582,12 +585,20 @@ func (srv *Server) WebPost(c echo.Context) error {
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
} else if hasMedia {
} else if hasVideo {
if postView.Embed.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedVideo_View.Thumbnail}
}
} else if hasMediaImages {
var thumbUrls []string
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
} else if hasMediaVideo {
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail}
}
}
}
+4 -4
View File
@@ -148,11 +148,11 @@
</head>
<body>
{%- block body_all %}
<div id="splash">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
</div>
<div id="root">
<div id="splash">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
</div>
</div>
<noscript>
+1 -1
View File
@@ -44,6 +44,6 @@ android {
dependencies {
implementation project(':expo-modules-core')
implementation 'com.google.android.material:material:1.12.0'
implementation 'com.google.android.material:material:1.13.0'
implementation "com.facebook.react:react-native:+"
}
@@ -5,8 +5,12 @@ import android.util.DisplayMetrics
import android.view.View
import android.view.ViewGroup
import android.view.ViewStructure
import android.view.Window
import android.view.accessibility.AccessibilityEvent
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.allViews
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactContext
@@ -15,6 +19,7 @@ import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.EventDispatcher
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.internal.EdgeToEdgeUtils
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
@@ -29,22 +34,26 @@ class BottomSheetView(
private lateinit var dialogRootViewGroup: DialogRootViewGroup
private var eventDispatcher: EventDispatcher? = null
private var isKeyboardVisible: Boolean = false
private val rawScreenHeight =
private val screenHeight =
context.resources.displayMetrics.heightPixels
.toFloat()
private val safeScreenHeight = (rawScreenHeight - getNavigationBarHeight()).toFloat()
private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
private fun getStatusBarHeight(): Int {
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
private val onAttemptDismiss by EventDispatcher()
private val onSnapPointChange by EventDispatcher()
private val onStateChange by EventDispatcher()
// Props
var disableDrag = false
set(value) {
field = value
@@ -56,48 +65,31 @@ class BottomSheetView(
field = value
this.dialog?.setCancelable(!value)
}
var preventExpansion = false
var minHeight = 0f
set(value) {
field =
if (value < 0) {
0f
} else {
dpToPx(value)
}
field = if (value < 0) 0f else dpToPx(value)
}
var maxHeight = this.safeScreenHeight
var maxHeight = this.screenHeight
set(value) {
val px = dpToPx(value)
field =
if (px > this.safeScreenHeight) {
this.safeScreenHeight
} else {
px
}
field = if (px > this.screenHeight) this.screenHeight else px
}
private var isOpen: Boolean = false
set(value) {
field = value
onStateChange(
mapOf(
"state" to if (value) "open" else "closed",
),
)
onStateChange(mapOf("state" to if (value) "open" else "closed"))
}
private var isOpening: Boolean = false
set(value) {
field = value
if (value) {
onStateChange(
mapOf(
"state" to "opening",
),
)
onStateChange(mapOf("state" to "opening"))
}
}
@@ -105,33 +97,21 @@ class BottomSheetView(
set(value) {
field = value
if (value) {
onStateChange(
mapOf(
"state" to "closing",
),
)
onStateChange(mapOf("state" to "closing"))
}
}
private var selectedSnapPoint = 0
set(value) {
if (field == value) return
field = value
onSnapPointChange(
mapOf(
"snapPoint" to value,
),
)
onSnapPointChange(mapOf("snapPoint" to value))
}
// Lifecycle
init {
(appContext.reactContext as? ReactContext)?.let {
it.addLifecycleEventListener(this)
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
this.dialogRootViewGroup = DialogRootViewGroup(context)
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
}
@@ -161,27 +141,55 @@ class BottomSheetView(
private fun getHalfExpandedRatio(contentHeight: Float): Float =
when {
// Full height sheets
contentHeight >= safeScreenHeight -> 0.99f
// Medium height sheets (>50% but <100%)
contentHeight >= safeScreenHeight / 2 ->
this.clampRatio(this.getTargetHeight() / safeScreenHeight)
// Small height sheets (<50%)
else ->
this.clampRatio(this.getTargetHeight() / rawScreenHeight)
contentHeight >= screenHeight -> 0.99f
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
}
private fun present() {
if (this.isOpen || this.isOpening || this.isClosing) return
val contentHeight = this.getContentHeight()
val dialog = BottomSheetDialog(context)
var activityWindow: Window? = null
var currentContext = context
while (currentContext != null) {
if (currentContext is android.app.Activity) {
activityWindow = currentContext.window
break
}
currentContext = (currentContext as? android.content.ContextWrapper)?.baseContext
}
val originalStatusBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars
}
val originalNavBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars
}
val dialog = BottomSheetDialog(context, R.style.EdgeToEdgeBottomSheetDialogTheme)
dialog.setContentView(dialogRootViewGroup)
dialog.setCancelable(!preventDismiss)
dialog.setDismissWithAnimation(true)
dialog.setOnDismissListener {
this.isClosing = true
this.destroy()
}
dialog.setOnShowListener {
dialog.window?.let { window ->
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
if (originalNavBarAppearance != null) {
insetsController.isAppearanceLightNavigationBars = originalNavBarAppearance
}
if (originalStatusBarAppearance != null) {
EdgeToEdgeUtils.setLightStatusBar(window, originalStatusBarAppearance)
}
}
}
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let {
it.setBackgroundColor(0)
@@ -194,7 +202,17 @@ class BottomSheetView(
behavior.isDraggable = true
behavior.isHideable = true
if (contentHeight >= this.safeScreenHeight || this.minHeight >= this.safeScreenHeight) {
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
} else {
behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (shouldBeExpanded) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2
} else {
@@ -209,18 +227,10 @@ class BottomSheetView(
newState: Int,
) {
when (newState) {
BottomSheetBehavior.STATE_EXPANDED -> {
selectedSnapPoint = 2
}
BottomSheetBehavior.STATE_COLLAPSED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HIDDEN -> {
selectedSnapPoint = 0
}
BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
}
}
@@ -231,9 +241,26 @@ class BottomSheetView(
},
)
}
this.isOpening = true
dialog.show()
this.dialog = dialog
ViewCompat.setOnApplyWindowInsetsListener(dialogRootViewGroup) { view, insets ->
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) }
val wasKeyboardVisible = isKeyboardVisible
isKeyboardVisible = imeVisible
if (imeVisible && behavior?.state == BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (!imeVisible && wasKeyboardVisible) {
updateLayout()
}
insets
}
}
fun updateLayout() {
@@ -246,12 +273,24 @@ class BottomSheetView(
val currentState = behavior.state
val oldRatio = behavior.halfExpandedRatio
var newRatio = getHalfExpandedRatio(contentHeight)
val newRatio = getHalfExpandedRatio(contentHeight)
behavior.halfExpandedRatio = newRatio
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (isKeyboardVisible) {
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
} else if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
} else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
@@ -279,25 +318,19 @@ class BottomSheetView(
private fun getTargetHeight(): Float {
val contentHeight = this.getContentHeight()
val height =
if (contentHeight > maxHeight) {
maxHeight
} else if (contentHeight < minHeight) {
minHeight
} else {
contentHeight
}
return height
return when {
contentHeight > maxHeight -> maxHeight
contentHeight < minHeight -> minHeight
else -> contentHeight
}
}
private fun clampRatio(ratio: Float): Float {
if (ratio < 0.01) {
return 0.01f
} else if (ratio > 0.99) {
return 0.99f
private fun clampRatio(ratio: Float): Float =
when {
ratio < 0.01 -> 0.01f
ratio > 0.99 -> 0.99f
else -> ratio
}
return ratio
}
private fun setDraggable(draggable: Boolean) {
val dialog = this.dialog ?: return
@@ -322,9 +355,7 @@ class BottomSheetView(
// View overrides to pass to DialogRootViewGroup instead
override fun dispatchProvideStructure(structure: ViewStructure?) {
if (structure == null) {
return
}
if (structure == null) return
dialogRootViewGroup.dispatchProvideStructure(structure)
}
@@ -363,7 +394,6 @@ class BottomSheetView(
// https://stackoverflow.com/questions/11862391/getheight-px-or-dpi
fun dpToPx(dp: Float): Float {
val displayMetrics = context.resources.displayMetrics
val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
return px
return dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
}
}
@@ -52,6 +52,8 @@ class DialogRootViewGroup(
if (ReactFeatureFlags.dispatchPointerEvents) {
jSPointerDispatcher = JSPointerDispatcher(this)
}
fitsSystemWindows = false
}
override fun onSizeChanged(
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="Theme.Material3.DayNight.BottomSheetDialog">
<!-- Enable edge-to-edge -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowIsFloating">false</item>
<item name="enableEdgeToEdge">true</item>
<!-- Configure bottom sheet to respect system window insets -->
<item name="bottomSheetStyle">@style/EdgeToEdgeBottomSheet</item>
</style>
<style name="EdgeToEdgeBottomSheet" parent="Widget.Material3.BottomSheet">
<item name="paddingBottomSystemWindowInsets">false</item>
<item name="paddingLeftSystemWindowInsets">true</item>
<item name="paddingRightSystemWindowInsets">true</item>
<item name="paddingTopSystemWindowInsets">false</item>
</style>
</resources>
@@ -175,6 +175,7 @@ function BottomSheetNativeComponentInner({
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
overflow: 'hidden',
},
extraStyles,
]}>
@@ -34,12 +34,15 @@ class NotificationPrefs(
is Boolean -> {
putBoolean(key, value)
}
is String -> {
putString(key, value)
}
is Array<*> -> {
putStringSet(key, value.map { it.toString() }.toSet())
}
is Map<*, *> -> {
putStringSet(key, value.map { it.toString() }.toSet())
}
@@ -117,7 +117,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleImageIntents(
uris: List<Uri>,
text: String?
text: String?,
) {
var allParams = ""
@@ -145,7 +145,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleVideoIntents(
uris: List<Uri>,
text: String?
text: String?,
) {
val uri = uris[0]
// If there is no extension for the file, substringAfterLast returns the original string - not
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.116.0",
"version": "1.117.0",
"private": true,
"engines": {
"node": ">=20"
@@ -73,7 +73,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.20",
"@atproto/api": "^0.18.21",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
@@ -202,7 +202,7 @@
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0",
"react-native-keyboard-controller": "1.18.5",
"react-native-keyboard-controller": "^1.20.7",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
@@ -229,7 +229,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.208",
"@atproto/dev-env": "^0.3.209",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
@@ -242,7 +242,6 @@
"@react-native/eslint-config": "^0.81.5",
"@react-native/typescript-config": "^0.81.5",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.2.0",
"@types/jest": "29.5.14",
"@types/lodash.chunk": "^4.2.7",
+5 -3
View File
@@ -1,11 +1,11 @@
diff --git a/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift b/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
index c34ba71..13d576a 100644
index c34ba71..3602856 100644
--- a/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
+++ b/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
@@ -159,13 +159,23 @@ class RNUITextViewShadow: RCTShadowView {
@@ -159,13 +159,25 @@ class RNUITextViewShadow: RCTShadowView {
let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
- var totalLines = self.lineHeight == 0.0 ? 0 : Int(ceil(textSize.height / self.lineHeight))
-
- if self.numberOfLines != 0, totalLines > self.numberOfLines {
@@ -27,6 +27,8 @@ index c34ba71..13d576a 100644
}
- self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight))
+ finalHeight = ceil(finalHeight)
+
+ self.frameSize = CGSize(width: CGFloat(maxWidth), height: finalHeight)
return YGSize(width: Float(self.frameSize.width), height: Float(self.frameSize.height))
}
+40 -33
View File
@@ -3,6 +3,7 @@ import '#/view/icons'
import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
import {
initialWindowMetrics,
SafeAreaProvider,
@@ -14,7 +15,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native'
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query'
import {s} from '#/lib/styles'
@@ -22,6 +22,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {
prefetchAppConfig,
Provider as AppConfigProvider,
} from '#/state/appConfig'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
@@ -103,6 +107,7 @@ if (IS_ANDROID) {
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
prefetchAppConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -228,38 +233,40 @@ function App() {
*/
return (
<Geo.Provider>
<A11yProvider>
<KeyboardControllerProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
<AppConfigProvider>
<A11yProvider>
<KeyboardControllerProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
</AppConfigProvider>
</Geo.Provider>
)
}
+93 -87
View File
@@ -2,7 +2,7 @@ import '#/logger/sentry/setup' // must be near top
import '#/view/icons'
import './style.css'
import React, {useEffect, useState} from 'react'
import {Fragment, useEffect, useState} from 'react'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -13,6 +13,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {
prefetchAppConfig,
Provider as AppConfigProvider,
} from '#/state/appConfig'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
@@ -79,9 +83,10 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
prefetchAppConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
const [isReady, setIsReady] = useState(false)
const {currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
@@ -116,68 +121,67 @@ function InnerApp() {
})
}, [_])
// wait for session to resume
if (!isReady || !hasCheckedReferrer) return <Splash isReady />
return (
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<ContextMenuProvider>
<VideoVolumeProvider>
<ActiveVideoProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</React.Fragment>
</ActiveVideoProvider>
</VideoVolumeProvider>
<Splash isReady={isReady && hasCheckedReferrer}>
<VideoVolumeProvider>
<ActiveVideoProvider>
<Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</Fragment>
</ActiveVideoProvider>
</VideoVolumeProvider>
</Splash>
</ContextMenuProvider>
</ThemeProvider>
</Alf>
@@ -187,14 +191,14 @@ function InnerApp() {
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true),
)
}, [])
if (!isReady) {
return <Splash isReady />
return null
}
/*
@@ -203,31 +207,33 @@ function App() {
*/
return (
<Geo.Provider>
<A11yProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</A11yProvider>
<AppConfigProvider>
<A11yProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</A11yProvider>
</AppConfigProvider>
</Geo.Provider>
)
}
+82 -14
View File
@@ -4,26 +4,94 @@
* the app is ready to go.
*/
import {View} from 'react-native'
import {useEffect, useRef, useState} from 'react'
import Svg, {Path} from 'react-native-svg'
import {atoms as a} from '#/alf'
import {atoms as a, flatten} from '#/alf'
const size = 100
const ratio = 57 / 64
export function Splash() {
export function Splash({
isReady,
children,
}: React.PropsWithChildren<{
isReady: boolean
}>) {
const [isAnimationComplete, setIsAnimationComplete] = useState(false)
const splashRef = useRef<HTMLDivElement>(null)
// hide the static one that's baked into the HTML - gets replaced by our React version below
useEffect(() => {
// double rAF ensures that the React version gets painted first
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const splash = document.getElementById('splash')
if (splash) {
splash.remove()
}
})
})
}, [])
// when ready, we fade/scale out
useEffect(() => {
if (!isReady) return
const reduceMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches
const node = splashRef.current
if (!node || reduceMotion) {
setIsAnimationComplete(true)
return
}
const animation = node.animate(
[
{opacity: 1, transform: 'scale(1)'},
{opacity: 0, transform: 'scale(1.5)'},
],
{
duration: 300,
easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
fill: 'forwards',
},
)
animation.onfinish = () => setIsAnimationComplete(true)
return () => {
animation.cancel()
}
}, [isReady])
return (
<View style={[a.fixed, a.inset_0, a.align_center, a.justify_center]}>
<Svg
fill="none"
viewBox="0 0 64 57"
style={[a.relative, {width: size, height: size * ratio, top: -50}]}>
<Path
fill="#006AFF"
d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"
/>
</Svg>
</View>
<>
{isReady && children}
{!isAnimationComplete && (
<div
ref={splashRef}
style={flatten([
a.fixed,
a.inset_0,
a.flex,
a.align_center,
a.justify_center,
// to compensate for the `top: -50px` below
{transformOrigin: 'center calc(50% - 50px)'},
])}>
<Svg
fill="none"
viewBox="0 0 64 57"
style={[a.relative, {width: size, height: size * ratio, top: -50}]}>
<Path
fill="#006AFF"
d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"
/>
</Svg>
</div>
)}
</>
)
}
+16
View File
@@ -108,6 +108,22 @@ export const atoms = {
animation: `zoomIn ${EXP_CURVE} 0.3s, fadeIn ${EXP_CURVE} 0.3s`,
}),
/**
* Visually hidden but available to screen readers (web).
* Use for live regions or off-screen labels (e.g. "Image 1 of 3").
*/
sr_only: web({
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
borderWidth: 0,
}),
/**
* {@link Layout.SCROLLBAR_OFFSET}
*/
+5
View File
@@ -875,4 +875,9 @@ export type Events = {
'liveEvents:unhideAllFeedBanners': {
context: LiveEventFeedMetricContext
}
'profile:associated:germ:click-to-chat': {}
'profile:associated:germ:click-self-info': {}
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}
}
+1 -1
View File
@@ -4,7 +4,6 @@ import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {isJwtExpired} from '#/lib/jwt'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -19,6 +18,7 @@ import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {useActorStatus} from '#/features/liveNow'
export function AccountList({
onSelectAccount,
+3 -7
View File
@@ -11,6 +11,7 @@ import {
} from 'react-native'
import {
KeyboardAwareScrollView,
type KeyboardAwareScrollViewRef,
useKeyboardHandler,
useReanimatedKeyboardAnimation,
} from 'react-native-keyboard-controller'
@@ -23,7 +24,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
import {ScrollProvider} from '#/lib/ScrollContext'
import {logger} from '#/logger'
import {useA11y} from '#/state/a11y'
@@ -209,10 +209,9 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const insets = useSafeAreaInsets()
useEnableKeyboardController(IS_IOS)
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
// note: iOS-only. keyboard-controller doesn't seem to work inside the sheets on Android
useKeyboardHandler(
{
onEnd: e => {
@@ -231,7 +230,6 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
}
paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
} else {
paddingBottom += keyboardHeight
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom += insets.top
}
@@ -259,7 +257,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
{paddingBottom},
contentContainerStyle,
]}
ref={ref}
ref={ref as React.Ref<KeyboardAwareScrollViewRef>}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
{...props}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
@@ -289,8 +287,6 @@ export const InnerFlatList = React.forwardRef<
const insets = useSafeAreaInsets()
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
useEnableKeyboardController(IS_IOS)
const onScroll = (e: ScrollEvent) => {
'worklet'
if (!IS_ANDROID) {
+8 -1
View File
@@ -6,7 +6,13 @@ import {useResolveLinkQuery} from '#/state/queries/resolve-link'
import {atoms as a, useTheme} from '#/alf'
import {QuoteEmbed} from '#/components/Post/Embed'
export function LazyQuoteEmbed({uri}: {uri: string}) {
export function LazyQuoteEmbed({
uri,
linkDisabled,
}: {
uri: string
linkDisabled?: boolean
}) {
const t = useTheme()
const {data} = useResolveLinkQuery(uri)
@@ -21,6 +27,7 @@ export function LazyQuoteEmbed({uri}: {uri: string}) {
type: 'post',
view,
}}
linkDisabled={linkDisabled}
/>
) : (
<View
@@ -1,11 +1,16 @@
import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Fill} from '#/components/Fill'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -26,28 +31,27 @@ export function GifPresentationControls({
return (
<>
<Pressable
accessibilityRole="button"
<Button
label={isPlaying ? _(msg`Pause GIF`) : _(msg`Play GIF`)}
accessibilityHint={_(msg`Plays or pauses the GIF`)}
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
style={[
a.absolute,
a.align_center,
a.justify_center,
a.inset_0,
a.w_full,
a.h_full,
{zIndex: 2},
]}
onPress={onPress}>
{isLoading ? (
<View style={[a.align_center, a.justify_center]}>
<Loader size="xl" />
<ActivityIndicator size="large" color="white" />
</View>
) : !isPlaying ? (
<PlayButtonIcon />
) : undefined}
</Pressable>
) : (
<></>
)}
</Button>
{!isPlaying && (
<Fill
style={[
@@ -58,7 +58,7 @@ export function VideoEmbedInnerNative({
<BlueskyVideoView
url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage}
beginMuted={isGif || autoplayDisabled ? false : muted}
beginMuted={isGif || (autoplayDisabled ? false : muted)}
style={[a.rounded_sm]}
onActiveChange={e => {
setIsActive(e.nativeEvent.isActive)
@@ -67,7 +67,9 @@ export function VideoEmbedInnerNative({
setIsLoading(e.nativeEvent.isLoading)
}}
onMutedChange={e => {
setMuted(e.nativeEvent.isMuted)
if (!isGif) {
setMuted(e.nativeEvent.isMuted)
}
}}
onStatusChange={e => {
setStatus(e.nativeEvent.status)
@@ -72,19 +72,7 @@ export function VideoEmbedInnerWeb({
loop={loop}
/>
{embed.alt && (
<figcaption
id={figId}
style={{
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
}}>
<figcaption id={figId} style={a.sr_only}>
{embed.alt}
</figcaption>
)}
+32 -24
View File
@@ -6,11 +6,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a} from '#/alf'
import {atoms as a, platform} from '#/alf'
import {Button} from '#/components/Button'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {GifPresentationControls} from './GifPresentationControls'
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
@@ -101,33 +102,40 @@ function InnerWrapper({embed}: Props) {
{
backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here,
// the play button won't show up on the first render on android 🥴😮‍💨
display: showOverlay ? 'flex' : 'none',
},
platform({
android: {display: showOverlay ? 'flex' : 'none'},
ios: {zIndex: showOverlay ? 1 : -1},
}),
]}
cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android
>
{showOverlay && (
<Button
style={[a.flex_1, a.align_center, a.justify_center]}
onPress={() => {
ref.current?.togglePlayback()
}}
label={_(msg`Play video`)}>
{showSpinner ? (
<View
style={[
a.rounded_full,
a.p_xs,
a.align_center,
a.justify_center,
]}>
<ActivityIndicator size="large" color="white" />
</View>
) : (
<PlayButtonIcon />
)}
</Button>
)}
{showOverlay &&
(embed.presentation === 'gif' ? (
<GifPresentationControls
isPlaying={false}
isLoading={showSpinner}
onPress={() => {
ref.current?.togglePlayback()
}}
altText={embed.alt}
/>
) : (
<Button
style={[a.flex_1, a.align_center, a.justify_center]}
onPress={() => {
ref.current?.togglePlayback()
}}
label={_(msg`Play video`)}>
{showSpinner ? (
<View style={[a.align_center, a.justify_center]}>
<ActivityIndicator size="large" color="white" />
</View>
) : (
<PlayButtonIcon />
)}
</Button>
))}
</ImageBackground>
</>
)
+57 -45
View File
@@ -225,11 +225,13 @@ export function QuoteEmbed({
embed,
onOpen,
style,
linkDisabled,
isWithinQuote: parentIsWithinQuote,
allowNestedQuotes: parentAllowNestedQuotes,
}: Omit<CommonProps, 'viewContext'> & {
embed: EmbedType<'post'>
viewContext?: QuoteEmbedViewContext
linkDisabled?: boolean
}) {
const moderationOpts = useModerationOpts()
const quote = useMemo<$Typed<AppBskyFeedDefs.PostView>>(
@@ -280,11 +282,48 @@ export function QuoteEmbed({
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const contents = (
<>
<View pointerEvents="none">
<PostMeta
author={quote.author}
moderation={moderation}
showAvatar
postHref={itemHref}
timestamp={quote.indexedAt}
/>
</View>
{moderation ? (
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
) : null}
{richText ? (
<RichText
value={richText}
style={a.text_md}
numberOfLines={20}
disableLinks
/>
) : null}
{quote.embed && (
<Embed
embed={quote.embed}
moderation={moderation}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
/>
)}
</>
)
return (
<View
style={[a.mt_sm]}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}>
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
@@ -292,56 +331,29 @@ export function QuoteEmbed({
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && (
{!active && !linkDisabled && (
<SubtleHover
native
hover={hover || pressed}
style={[a.rounded_md]}
/>
)}
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
<View pointerEvents="none">
<PostMeta
author={quote.author}
moderation={moderation}
showAvatar
postHref={itemHref}
timestamp={quote.indexedAt}
/>
{linkDisabled ? (
<View style={[!active && a.p_md]} pointerEvents="none">
{contents}
</View>
{moderation ? (
<PostAlerts
modui={moderation.ui('contentView')}
style={[a.py_xs]}
/>
) : null}
{richText ? (
<RichText
value={richText}
style={a.text_md}
numberOfLines={20}
disableLinks
/>
) : null}
{quote.embed && (
<Embed
embed={quote.embed}
moderation={moderation}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
/>
)}
</Link>
) : (
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
{contents}
</Link>
)}
</>
)}
</ContentHider>
@@ -13,7 +13,7 @@ import {
AtUri,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -384,9 +384,8 @@ let PostMenuItems = ({
if (e instanceof MaxHiddenRepliesError) {
Toast.show(
_(
msg({
message: `You can hide a maximum of ${MAX_HIDDEN_REPLIES} replies.`,
context: 'toast',
plural(MAX_HIDDEN_REPLIES, {
other: 'You can hide a maximum of # replies.',
}),
),
)
+1 -1
View File
@@ -14,7 +14,6 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
@@ -47,6 +46,7 @@ import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {type Metrics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
import type * as bsky from '#/types/bsky'
export function Default({
@@ -10,7 +10,6 @@ import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
@@ -34,7 +33,6 @@ import {
shouldShowKnownFollowers,
} from '#/components/KnownFollowers'
import {InlineLinkText, Link} from '#/components/Link'
import {LiveStatus} from '#/components/live/LiveStatusDialog'
import {Loader} from '#/components/Loader'
import * as Pills from '#/components/Pills'
import {Portal} from '#/components/Portal'
@@ -43,6 +41,8 @@ import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {IS_WEB_TOUCH_DEVICE} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {LiveStatus} from '#/features/liveNow/components/LiveStatusDialog'
import {type ProfileHoverCardProps} from './types'
const floatingMiddlewares = [
+79 -63
View File
@@ -1,4 +1,10 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {useState} from 'react'
import {
type LayoutChangeEvent,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -11,7 +17,7 @@ import {
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Person_Stroke2_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
@@ -51,7 +57,7 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
a.flex_col,
a.gap_md,
a.rounded_md,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
a.p_lg,
style,
]}>
@@ -81,8 +87,7 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
a.justify_between,
a.gap_sm,
]
: a.flex_col,
!inlineLayout && a.gap_md,
: [a.flex_col, a.gap_md],
]}>
<StackedAvatars follows={follows?.pages?.[0]?.follows} />
<FollowDialog guide={guide} showArrow={inlineLayout} />
@@ -112,70 +117,81 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) {
const t = useTheme()
const {centerColumnOffset} = useLayoutBreakpoints()
const [containerWidth, setContainerWidth] = useState(0)
// Smaller avatars for narrower viewport
const avatarSize = centerColumnOffset ? 30 : 37
const overlap = centerColumnOffset ? 9 : 11
const iconSize = centerColumnOffset ? 14 : 18
const onLayout = (e: LayoutChangeEvent) => {
setContainerWidth(e.nativeEvent.layout.width)
}
// Overlap ratio (22% of avatar size)
const overlapRatio = 0.22
// Calculate avatar size to fill container width
// Formula: containerWidth = avatarSize * count - overlap * (count - 1)
// Where overlap = avatarSize * overlapRatio
const visiblePortions = TOTAL_AVATARS - overlapRatio * (TOTAL_AVATARS - 1)
const avatarSize = containerWidth > 0 ? containerWidth / visiblePortions : 0
const overlap = avatarSize * overlapRatio
const iconSize = avatarSize * 0.5
// Use actual follows count, not the guide's event counter
const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? []
const remainingSlots = TOTAL_AVATARS - followedAvatars.length
// Total width calculation: first avatar + (remaining * visible portion)
const totalWidth = avatarSize + (TOTAL_AVATARS - 1) * (avatarSize - overlap)
return (
<View style={[a.flex_row, a.self_start, {width: totalWidth}]}>
{/* Show followed user avatars */}
{followedAvatars.map((follow, i) => (
<View
key={follow.did}
style={[
a.rounded_full,
{
marginLeft: i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - i,
borderWidth: 2,
borderColor: t.atoms.bg_contrast_25.backgroundColor,
},
]}>
<UserAvatar
type="user"
size={avatarSize - 4}
avatar={follow.avatar}
/>
</View>
))}
{/* Show placeholder avatars for remaining slots */}
{Array(remainingSlots)
.fill(0)
.map((_, i) => (
<View
key={`placeholder-${i}`}
style={[
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_100,
{
width: avatarSize,
height: avatarSize,
marginLeft:
followedAvatars.length === 0 && i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - followedAvatars.length - i,
borderWidth: 2,
borderColor: t.atoms.bg_contrast_25.backgroundColor,
},
]}>
<PersonIcon
width={iconSize}
height={iconSize}
fill={t.atoms.text_contrast_low.color}
/>
</View>
))}
<View style={[a.flex_row, a.flex_1]} onLayout={onLayout}>
{containerWidth > 0 && (
<>
{/* Show followed user avatars */}
{followedAvatars.map((follow, i) => (
<View
key={follow.did}
style={[
a.rounded_full,
a.border,
t.atoms.border_contrast_low,
{
marginLeft: i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - i,
},
]}>
<UserAvatar
type="user"
size={avatarSize - 2}
avatar={follow.avatar}
noBorder
/>
</View>
))}
{/* Show placeholder avatars for remaining slots */}
{Array(remainingSlots)
.fill(0)
.map((_, i) => (
<View
key={`placeholder-${i}`}
style={[
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_300,
a.border,
t.atoms.border_contrast_low,
{
width: avatarSize,
height: avatarSize,
marginLeft:
followedAvatars.length === 0 && i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - followedAvatars.length - i,
},
]}>
<PersonIcon
width={iconSize}
height={iconSize}
fill={t.atoms.bg_contrast_50.backgroundColor}
/>
</View>
))}
</>
)}
</View>
)
}
+30 -5
View File
@@ -4,8 +4,14 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme, type ViewStyleProp, web} from '#/alf'
import {Button, type ButtonColor, ButtonText} from '#/components/Button'
import {
Button,
type ButtonColor,
ButtonIcon,
ButtonText,
} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography'
import {type BottomSheetViewProps} from '../../modules/bottom-sheet'
@@ -126,7 +132,7 @@ export function Cancel({
<Button
variant="solid"
color="secondary"
size={'large'}
size="large"
label={cta || _(msg`Cancel`)}
onPress={onPress}>
<ButtonText>{cta || _(msg`Cancel`)}</ButtonText>
@@ -138,6 +144,9 @@ export function Action({
onPress,
color = 'primary',
cta,
disabled = false,
icon,
shouldCloseOnPress,
testID,
}: {
/**
@@ -153,25 +162,41 @@ export function Action({
* Optional i18n string. If undefined, it will default to "Confirm".
*/
cta?: string
/**
* If undefined, it will default to false.
*/
disabled?: boolean
icon?: React.ComponentType<SVGIconProps>
/**
* Optionally close dialog automatically on press. If undefined, it will
* default to true.
*/
shouldCloseOnPress?: boolean
testID?: string
}) {
const {_} = useLingui()
const {close} = Dialog.useDialogContext()
const handleOnPress = useCallback(
(e: GestureResponderEvent) => {
close(() => onPress?.(e))
if (shouldCloseOnPress) {
close(() => onPress?.(e))
} else {
onPress?.(e)
}
},
[close, onPress],
[close, onPress, shouldCloseOnPress],
)
return (
<Button
color={color}
size={'large'}
disabled={disabled}
size="large"
label={cta || _(msg`Confirm`)}
onPress={handleOnPress}
testID={testID}>
<ButtonText>{cta || _(msg`Confirm`)}</ButtonText>
{icon && <ButtonIcon icon={icon} />}
</Button>
)
}
@@ -56,6 +56,7 @@ function Inner({style}: ViewStyleProp & {}) {
const {status, lastInitiatedAt} = aa.state
const isBlocked = status === aa.Status.Blocked
const hasInitiated = !!lastInitiatedAt
const hasCompletedFlow = status === aa.Status.Assured
const timeAgo = lastInitiatedAt
? getTimeAgo(lastInitiatedAt, new Date())
: null
@@ -85,8 +86,25 @@ function Inner({style}: ViewStyleProp & {}) {
</View>
</View>
<View style={[a.pb_md, a.gap_xs]}>
<View style={[a.pb_md, a.gap_sm]}>
<Text style={[a.text_sm, a.leading_snug]}>{copy.notice}</Text>
{hasCompletedFlow && (
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
If you are 18 years of age or older and want to try again,
click the button below and use a different verification method
if one is available in your region. If you have questions or
concerns,{' '}
<InlineLinkText
label={_(msg`Contact our support team`)}
{...createStaticClick(() => {
appealControl.open()
})}>
our support team can help.
</InlineLinkText>
</Trans>
</Text>
)}
{IS_NATIVE && (
<>
@@ -59,7 +59,7 @@ export function AgeRestrictedScreen({
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_xl, a.leading_snug, a.font_bold]}>
<Trans>
You must complete age assurance in order to access this screen.
We're sorry, you cannot access this screen at this time.
</Trans>
</Text>
@@ -2,20 +2,29 @@ import {useMemo} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/ageAssurance'
export function useAgeAssuranceCopy() {
const {_} = useLingui()
const aa = useAgeAssurance()
const hasCompletedFlow = aa.state.status === aa.Status.Assured
return useMemo(() => {
return {
notice: _(
msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`,
),
notice: hasCompletedFlow
? _(
msg`You have completed the Age Assurance process, but based on the results, we cannot be sure that you are 18 years of age or older. Due to laws in your region, certain features on Bluesky must remain restricted until you're able to verify you're an adult.`,
)
: _(
msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`,
),
banner: _(
msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`,
msg`The laws in your region require you to verify you're an adult to access certain features. Tap to learn more.`,
),
chatsInfoText: _(
msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`,
),
}
}, [_])
}, [_, hasCompletedFlow])
}
+2 -2
View File
@@ -72,9 +72,9 @@ export function BirthDateSettingsDialog({
) : isUsingAppPassword ? (
<Admonition type="info">
<Trans>
Hmm, it looks like you're logged in with an{' '}
Hmm, it looks like you're signed in with an{' '}
<Span style={[a.italic]}>App Password</Span>. To set your
birthdate, you'll need to log in with your main account
birthdate, you'll need to sign in with your main account
password, or ask whomever controls this account to do so.
</Trans>
</Admonition>
@@ -32,8 +32,11 @@ export function TokenField({
<TextField.Root>
<TextField.Icon icon={Shield} />
<TextField.Input
autoComplete="off"
autoCorrect={false}
isInvalid={isInvalid}
label={_(msg`Confirmation code`)}
maxLength={11}
placeholder="XXXXX-XXXXX"
value={value}
onChangeText={handleOnChangeText}
+9 -14
View File
@@ -9,7 +9,8 @@ import {
externalEmbedLabels,
} from '#/lib/strings/embed-player'
import {useSetExternalEmbedPref} from '#/state/preferences'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
@@ -24,9 +25,7 @@ export function EmbedConsentDialog({
onAccept: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const setExternalEmbedPref = useSetExternalEmbedPref()
const {gtMobile} = useBreakpoints()
const onShowAllPress = useCallback(() => {
for (const key of embedPlayerSources) {
@@ -52,50 +51,46 @@ export function EmbedConsentDialog({
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`External Media`)}
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
style={web({maxWidth: 400})}>
<View style={a.gap_sm}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>External Media</Trans>
</Text>
<View style={[a.mt_sm, a.mb_2xl, a.gap_lg]}>
<Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
This content is hosted by {externalEmbedLabels[source]}. Do you
want to enable external media?
</Trans>
</Text>
<Text style={t.atoms.text_contrast_medium}>
<Admonition type="info">
<Trans>
External media may allow websites to collect information about
you and your device. No information is sent or requested until
you press the "play" button.
</Trans>
</Text>
</Admonition>
</View>
</View>
<View style={a.gap_md}>
<Button
style={gtMobile && a.flex_1}
label={_(msg`Enable external media`)}
onPress={onShowAllPress}
onAccessibilityEscape={control.close}
color="primary"
size="large"
variant="solid">
size="large">
<ButtonText>
<Trans>Enable external media</Trans>
</ButtonText>
</Button>
<Button
style={gtMobile && a.flex_1}
label={_(msg`Enable this source only`)}
onPress={onShowPress}
onAccessibilityEscape={control.close}
color="secondary"
size="large"
variant="solid">
size="large">
<ButtonText>
<Trans>Enable {externalEmbedLabels[source]} only</Trans>
</ButtonText>
+17 -2
View File
@@ -22,12 +22,27 @@ export function LinkWarningDialog() {
webOptions={{alignCenter: true}}
onClose={linkWarningDialogControl.clear}>
<Dialog.Handle />
<InAppBrowserConsentInner link={linkWarningDialogControl.value} />
<LinkWarningDialogInner link={linkWarningDialogControl.value} />
</Dialog.Outer>
)
}
function InAppBrowserConsentInner({
export function CustomLinkWarningDialog({
control,
link,
}: {
control: Dialog.DialogControlProps
link?: {href: string; displayText: string; share?: boolean}
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<LinkWarningDialogInner link={link} />
</Dialog.Outer>
)
}
function LinkWarningDialogInner({
link,
}: {
link?: {href: string; displayText: string; share?: boolean}
+107 -26
View File
@@ -8,6 +8,7 @@ import {logger} from '#/logger'
import {
usePreferencesQuery,
useRemoveMutedWordMutation,
useUpdateMutedWordMutation,
useUpsertMutedWordsMutation,
} from '#/state/queries/preferences'
import {
@@ -29,6 +30,7 @@ import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/P
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
@@ -421,6 +423,7 @@ function MutedWordRow({
const t = useTheme()
const {_} = useLingui()
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
const {mutateAsync: updateMutedWord} = useUpdateMutedWordMutation()
const control = Prompt.usePromptControl()
const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
const isExpired = expiryDate && expiryDate < new Date()
@@ -431,6 +434,15 @@ function MutedWordRow({
removeMutedWord(word)
}, [removeMutedWord, word, control])
const renew = (days?: number) => {
updateMutedWord({
...word,
expiresAt: days
? new Date(Date.now() + days * ONE_DAY).toISOString()
: undefined,
})
}
return (
<>
<Prompt.Basic
@@ -493,35 +505,104 @@ function MutedWordRow({
</View>
{(expiryDate || word.actorTarget === 'exclude-following') && (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Text
style={[
a.flex_1,
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{expiryDate && (
<View style={[a.flex_1, a.flex_row, a.align_center, a.flex_wrap]}>
{expiryDate &&
(isExpired ? (
<>
{isExpired ? (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>Expired</Trans>
) : (
<Trans>
Expires{' '}
{formatDistance(expiryDate, new Date(), {
addSuffix: true,
})}
</Trans>
)}
</Text>
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{' · '}
</Text>
<Menu.Root>
<Menu.Trigger label={_(msg`Renew mute word`)}>
{({props}) => (
<Text
{...props}
style={[
a.text_xs,
a.leading_snug,
a.font_semi_bold,
{color: t.palette.primary_500},
]}>
<Trans>Renew</Trans>
</Text>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.LabelText>
<Trans>Renew duration</Trans>
</Menu.LabelText>
<Menu.Group>
<Menu.Item
label={_(msg`24 hours`)}
onPress={() => renew(1)}>
<Menu.ItemText>
<Trans>24 hours</Trans>
</Menu.ItemText>
</Menu.Item>
<Menu.Item
label={_(msg`7 days`)}
onPress={() => renew(7)}>
<Menu.ItemText>
<Trans>7 days</Trans>
</Menu.ItemText>
</Menu.Item>
<Menu.Item
label={_(msg`30 days`)}
onPress={() => renew(30)}>
<Menu.ItemText>
<Trans>30 days</Trans>
</Menu.ItemText>
</Menu.Item>
<Menu.Item
label={_(msg`Forever`)}
onPress={() => renew()}>
<Menu.ItemText>
<Trans>Forever</Trans>
</Menu.ItemText>
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
</>
)}
{word.actorTarget === 'exclude-following' && (
<>
{' • '}
<Trans>Excludes users you follow</Trans>
</>
)}
</Text>
) : (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Expires{' '}
{formatDistance(expiryDate, new Date(), {
addSuffix: true,
})}
</Trans>
</Text>
))}
{word.actorTarget === 'exclude-following' && (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{expiryDate ? ' · ' : ''}
<Trans>Excludes users you follow</Trans>
</Text>
)}
</View>
)}
</View>
+56 -58
View File
@@ -1,4 +1,4 @@
import {useCallback, useState} from 'react'
import {useCallback} from 'react'
import {View} from 'react-native'
import {
type AppBskyGraphGetStarterPacksWithMembership,
@@ -7,20 +7,18 @@ import {
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type NavigationProp} from '#/lib/routes/types'
import {
invalidateActorStarterPacksWithMembershipQuery,
useActorStarterPacksWithMembershipsQuery,
} from '#/state/queries/actor-starter-packs'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useActorStarterPacksWithMembershipsQuery} from '#/state/queries/actor-starter-packs'
import {
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {useProfileQuery} from '#/state/queries/profile'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -29,6 +27,7 @@ import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/
import {StarterPack} from '#/components/icons/StarterPack'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
@@ -131,6 +130,7 @@ function StarterPackList({
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const {data: subject} = useProfileQuery({did: targetDid})
const {
data,
@@ -155,9 +155,13 @@ function StarterPackList({
const renderItem = useCallback(
({item}: {item: StarterPackWithMembership}) => (
<StarterPackItem starterPackWithMembership={item} targetDid={targetDid} />
<StarterPackItem
starterPackWithMembership={item}
targetDid={targetDid}
subject={subject}
/>
),
[targetDid],
[targetDid, subject],
)
const onClose = useCallback(() => {
@@ -168,9 +172,11 @@ function StarterPackList({
<>
<View
style={[
{justifyContent: 'space-between', flexDirection: 'row'},
IS_WEB ? a.mb_2xl : a.my_lg,
a.justify_between,
a.align_center,
a.flex_row,
a.pb_lg,
native(a.pt_lg),
]}>
<Text style={[a.text_lg, a.font_semi_bold]}>
<Trans>Add to starter packs</Trans>
@@ -181,7 +187,8 @@ function StarterPackList({
variant="ghost"
color="secondary"
size="small"
shape="round">
shape="round"
style={{margin: -8}}>
<ButtonIcon icon={XIcon} />
</Button>
</View>
@@ -232,7 +239,10 @@ function StarterPackList({
onEndReachedThreshold={0.1}
ListHeaderComponent={listHeader}
ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
style={IS_WEB ? [a.px_md, {minHeight: 500}] : [a.px_2xl, a.pt_lg]}
style={platform({
web: [a.px_2xl, {minHeight: 500}],
native: [a.px_2xl, a.pt_lg],
})}
/>
)
}
@@ -240,66 +250,54 @@ function StarterPackList({
function StarterPackItem({
starterPackWithMembership,
targetDid,
subject,
}: {
starterPackWithMembership: StarterPackWithMembership
targetDid: string
subject?: bsky.profile.AnyProfileView
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const queryClient = useQueryClient()
const starterPack = starterPackWithMembership.starterPack
const isInPack = !!starterPackWithMembership.listItem
const [isPendingRefresh, setIsPendingRefresh] = useState(false)
const {mutate: addMembership, isPending: isPendingAdd} =
useListMembershipAddMutation({
subject,
onSuccess: () => {
Toast.show(_(msg`Added to starter pack`))
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to add to starter pack', {safeMessage: err})
}
Toast.show(_(msg`Failed to add to starter pack`), {type: 'error'})
},
})
const {mutate: addMembership} = useListMembershipAddMutation({
onSuccess: () => {
Toast.show(_(msg`Added to starter pack`))
// Use a timeout to wait for the appview to update, matching the pattern
// in list-memberships.ts
setTimeout(() => {
invalidateActorStarterPacksWithMembershipQuery({
queryClient,
did: targetDid,
})
setIsPendingRefresh(false)
}, 1e3)
},
onError: () => {
Toast.show(_(msg`Failed to add to starter pack`), 'xmark')
setIsPendingRefresh(false)
},
})
const {mutate: removeMembership, isPending: isPendingRemove} =
useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(_(msg`Removed from starter pack`))
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to remove from starter pack', {safeMessage: err})
}
Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'})
},
})
const {mutate: removeMembership} = useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(_(msg`Removed from starter pack`))
// Use a timeout to wait for the appview to update, matching the pattern
// in list-memberships.ts
setTimeout(() => {
invalidateActorStarterPacksWithMembershipQuery({
queryClient,
did: targetDid,
})
setIsPendingRefresh(false)
}, 1e3)
},
onError: () => {
Toast.show(_(msg`Failed to remove from starter pack`), 'xmark')
setIsPendingRefresh(false)
},
})
const isPending = isPendingAdd || isPendingRemove
const handleToggleMembership = () => {
if (!starterPack.list?.uri || isPendingRefresh) return
if (!starterPack.list?.uri || isPending) return
const listUri = starterPack.list.uri
const starterPackUri = starterPack.uri
setIsPendingRefresh(true)
if (!isInPack) {
addMembership({
listUri: listUri,
@@ -309,7 +307,6 @@ function StarterPackItem({
} else {
if (!starterPackWithMembership.listItem?.uri) {
console.error('Cannot remove: missing membership URI')
setIsPendingRefresh(false)
return
}
removeMembership({
@@ -344,7 +341,7 @@ function StarterPackItem({
starterPack.listItemsSample.length > 0 && (
<>
<AvatarStack
size={32}
size={24}
profiles={starterPack.listItemsSample
?.slice(0, 4)
.map(p => p.subject)}
@@ -375,8 +372,9 @@ function StarterPackItem({
label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
color={isInPack ? 'secondary' : 'primary_subtle'}
size="tiny"
disabled={isPendingRefresh}
disabled={isPending}
onPress={handleToggleMembership}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText>
{isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText>
+2 -2
View File
@@ -43,10 +43,10 @@ export function SwitchAccountDialog({
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
<Dialog.ScrollableInner label={_(msg`Switch account`)}>
<View style={[a.gap_lg]}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
<Trans>Switch Account</Trans>
<Trans>Switch account</Trans>
</Text>
<AccountList
@@ -95,7 +95,7 @@ export function DraftsAnnouncement() {
]}
alt={_(
msg({
message: `A screenshot of a the post composer with a new button next to the post button that says "Drafts", with a rainbow firework effect. Below, the text in the composer reads "Hey, did you hear the news? Bluesky has drafts now???".`,
message: `A screenshot of the post composer with a new button next to the post button that says "Drafts", with a rainbow firework effect. Below, the text in the composer reads "Hey, did you hear the news? Bluesky has drafts now!!!".`,
comment:
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}),
+4
View File
@@ -36,3 +36,7 @@ export const PersonPlus_Stroke2_Corner2_Rounded = createSinglePathSVG({
export const PersonGroup_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm7.301 9.7c-.836-2.6-2.88-3.503-4.575-3.111a1 1 0 0 1-.451-1.949c2.815-.651 5.81.966 6.93 4.448a2.49 2.49 0 0 1-.506 2.43A2.92 2.92 0 0 1 20 20h-2a1 1 0 1 1 0-2h2a.92.92 0 0 0 .69-.295.49.49 0 0 0 .112-.505ZM8 14c-1.865 0-3.878 1.274-4.681 4.151a.57.57 0 0 0 .132.55c.15.171.4.299.695.299h7.708a.93.93 0 0 0 .695-.299.57.57 0 0 0 .132-.55C11.878 15.274 9.865 14 8 14Zm0-2c2.87 0 5.594 1.98 6.607 5.613.53 1.9-1.09 3.387-2.753 3.387H4.146c-1.663 0-3.283-1.487-2.753-3.387C2.406 13.981 5.129 12 8 12Z',
})
export const Person_Filled_Corner2_Rounded = createSinglePathSVG({
path: 'M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867ZM12.233 12.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z',
})
@@ -28,7 +28,7 @@ export function VerifierDialog({
verificationState: FullVerificationState
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Inner
control={control}
@@ -123,7 +123,6 @@ function Inner({
}),
)}
size="small"
variant="solid"
color="primary"
style={[a.justify_center]}
onPress={() => {
@@ -138,7 +137,6 @@ function Inner({
<Button
label={_(msg`Close dialog`)}
size="small"
variant="solid"
color="secondary"
onPress={() => {
control.close()
+10
View File
@@ -141,3 +141,13 @@ export const LIVE_EVENTS_PROD_URL = `https://live-events.workers.bsky.app`
export const LIVE_EVENTS_URL = IS_DEV
? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL)
: LIVE_EVENTS_PROD_URL
/**
* URLs for the app-config web worker. Can be a
* locally running server, see `env.example` for more.
*/
export const APP_CONFIG_DEV_URL = process.env.APP_CONFIG_DEV_URL
export const APP_CONFIG_PROD_URL = `https://app-config.workers.bsky.app`
export const APP_CONFIG_URL = IS_DEV
? (APP_CONFIG_DEV_URL ?? APP_CONFIG_PROD_URL)
: APP_CONFIG_PROD_URL
@@ -9,6 +9,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInMinutes} from 'date-fns'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {cleanError} from '#/lib/strings/errors'
import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useTickEveryMinute} from '#/state/shell'
@@ -20,13 +21,13 @@ import * as TextField from '#/components/forms/TextField'
import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {LinkPreview} from './LinkPreview'
import {
displayDuration,
useLiveLinkMetaQuery,
useRemoveLiveStatusMutation,
useUpsertLiveStatusMutation,
} from './queries'
import {displayDuration, useDebouncedValue} from './utils'
} from '#/features/liveNow'
import {LinkPreview} from '#/features/liveNow/components/LinkPreview'
export function EditLiveDialog({
control,
@@ -3,28 +3,29 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {cleanError} from '#/lib/strings/errors'
import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell'
import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {
displayDuration,
getLiveServiceNames,
useDebouncedValue,
} from '#/components/live/utils'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Select from '#/components/Select'
import {Text} from '#/components/Typography'
import {
displayDuration,
getLiveServiceNames,
useLiveLinkMetaQuery,
useLiveNowConfig,
useUpsertLiveStatusMutation,
} from '#/features/liveNow'
import type * as bsky from '#/types/bsky'
import {LinkPreview} from './LinkPreview'
import {useLiveLinkMetaQuery, useUpsertLiveStatusMutation} from './queries'
export function GoLiveDialog({
control,
@@ -17,15 +17,15 @@ import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Globe_Stroke2_Corner0_Rounded} from '#/components/icons/Globe'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '#/components/icons/SquareArrowTopRight'
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator'
import type * as bsky from '#/types/bsky'
import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight'
import {LiveIndicator} from './LiveIndicator'
export function LiveStatusDialog({
control,
@@ -1,24 +1,166 @@
import {useMemo} from 'react'
import {
type $Typed,
type AppBskyActorDefs,
type AppBskyActorStatus,
type AppBskyEmbedExternal,
AppBskyEmbedExternal,
AtUri,
ComAtprotoRepoPutRecord,
} from '@atproto/api'
import {retry} from '@atproto/common-web'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {isAfter, parseISO} from 'date-fns'
import {uploadBlob} from '#/lib/api'
import {imageToThumb} from '#/lib/api/resolve'
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useLiveNowConfig} from '#/state/service-config'
import {useAppConfig} from '#/state/appConfig'
import {
updateProfileShadow,
useMaybeProfileShadow,
} from '#/state/cache/profile-shadow'
import {useAgent, useSession} from '#/state/session'
import {useTickEveryMinute} from '#/state/shell'
import * as Toast from '#/view/com/util/Toast'
import {useDialogContext} from '#/components/Dialog'
import {getLiveServiceNames} from '#/components/live/utils'
import {useAnalytics} from '#/analytics'
import {getLiveNowHost, getLiveServiceNames} from '#/features/liveNow/utils'
import type * as bsky from '#/types/bsky'
export * from '#/features/liveNow/utils'
export const DEFAULT_ALLOWED_DOMAINS = [
'twitch.tv',
'stream.place',
'bluecast.app',
]
export type LiveNowConfig = {
canGoLive: boolean
currentAccountAllowedHosts: Set<string>
defaultAllowedHosts: Set<string>
allowedHostsExceptionsByDid: Map<string, Set<string>>
}
export function useLiveNowConfig(): LiveNowConfig {
const ax = useAnalytics()
const {liveNow} = useAppConfig()
const {currentAccount} = useSession()
return useMemo(() => {
const disabled = ax.features.enabled(ax.features.LiveNowBetaDisable)
const defaultAllowedHosts = new Set(
DEFAULT_ALLOWED_DOMAINS.concat(liveNow.allow),
)
const allowedHostsExceptionsByDid = new Map<string, Set<string>>()
for (const ex of liveNow.exceptions) {
allowedHostsExceptionsByDid.set(
ex.did,
new Set(DEFAULT_ALLOWED_DOMAINS.concat(ex.allow)),
)
}
if (!currentAccount?.did || disabled) {
return {
canGoLive: false,
currentAccountAllowedHosts: new Set(),
defaultAllowedHosts,
allowedHostsExceptionsByDid,
}
}
return {
canGoLive: true,
currentAccountAllowedHosts:
allowedHostsExceptionsByDid.get(currentAccount.did) ??
defaultAllowedHosts,
defaultAllowedHosts,
allowedHostsExceptionsByDid,
}
}, [ax, liveNow, currentAccount])
}
export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
const shadowed = useMaybeProfileShadow(actor)
const tick = useTickEveryMinute()
const config = useLiveNowConfig()
return useMemo(() => {
void tick // revalidate every minute
if (shadowed && 'status' in shadowed && shadowed.status) {
const isValid = isStatusValidForViewers(shadowed.status, config)
const isDisabled = shadowed.status.isDisabled || false
const isActive = isStatusStillActive(shadowed.status.expiresAt)
if (isValid && !isDisabled && isActive) {
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled: false,
isActive: true,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
}
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled,
isActive: false,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
} else {
return {
status: '',
isDisabled: false,
isActive: false,
record: {},
} satisfies AppBskyActorDefs.StatusView
}
}, [shadowed, config, tick])
}
export function isStatusStillActive(timeStr: string | undefined) {
if (!timeStr) return false
const now = new Date()
const expiry = parseISO(timeStr)
return isAfter(expiry, now)
}
/**
* Validates whether the live status is valid for display in the app. Does NOT
* validate if the status is valid for the acting user e.g. as they go live.
*/
export function isStatusValidForViewers(
status: AppBskyActorDefs.StatusView,
config: LiveNowConfig,
) {
if (status.status !== 'app.bsky.actor.status#live') return false
if (!status.uri) return false // should not happen, just backwards compat
try {
const {host: liveDid} = new AtUri(status.uri)
if (AppBskyEmbedExternal.isView(status.embed)) {
const host = getLiveNowHost(status.embed.external.uri)
const exception = config.allowedHostsExceptionsByDid.get(liveDid)
const isValidException = exception ? exception.has(host) : false
const isValidForAnyone = config.defaultAllowedHosts.has(host)
return isValidException || isValidForAnyone
} else {
return false
}
} catch {
return false
}
}
export function useLiveLinkMetaQuery(url: string | null) {
const liveNowConfig = useLiveNowConfig()
@@ -30,8 +172,8 @@ export function useLiveLinkMetaQuery(url: string | null) {
queryKey: ['link-meta', url],
queryFn: async () => {
if (!url) return undefined
const urlp = new URL(url)
if (!liveNowConfig.currentAccountAllowedHosts.has(urlp.hostname)) {
const host = getLiveNowHost(url)
if (!liveNowConfig.currentAccountAllowedHosts.has(host)) {
const {formatted} = getLiveServiceNames(
liveNowConfig.currentAccountAllowedHosts,
)
@@ -1,6 +1,6 @@
import {useEffect, useState} from 'react'
import {type I18n} from '@lingui/core'
import {plural} from '@lingui/macro'
import psl from 'psl'
export function displayDuration(i18n: I18n, durationInMinutes: number) {
const roundedDurationInMinutes = Math.round(durationInMinutes)
@@ -24,41 +24,49 @@ export function displayDuration(i18n: I18n, durationInMinutes: number) {
: minutesString
}
// Trailing debounce
export function useDebouncedValue<T>(val: T, delayMs: number): T {
const [prev, setPrev] = useState(val)
useEffect(() => {
const timeout = setTimeout(() => setPrev(val), delayMs)
return () => clearTimeout(timeout)
}, [val, delayMs])
return prev
}
const serviceUrlToNameMap: Record<string, string> = {
'twitch.tv': 'Twitch',
'www.twitch.tv': 'Twitch',
'youtube.com': 'YouTube',
'www.youtube.com': 'YouTube',
'youtu.be': 'YouTube',
'nba.com': 'NBA',
'www.nba.com': 'NBA',
'nba.smart.link': 'nba.smart.link',
'espn.com': 'ESPN',
'www.espn.com': 'ESPN',
'stream.place': 'Streamplace',
'skylight.social': 'Skylight',
'bluecast.app': 'Bluecast',
'www.bluecast.app': 'Bluecast',
}
export function getLiveServiceNames(domains: Set<string>) {
const names = Array.from(
new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)),
new Set(
Array.from(domains.values())
.map(d => sanitizeLiveNowHost(d))
.map(d => serviceUrlToNameMap[d] || d),
),
)
return {
names,
formatted: names.join(', '),
}
}
export function sanitizeLiveNowHost(hostname: string) {
// special case this one
if (hostname === 'nba.smart.link') {
return hostname
}
const parsed = psl.parse(hostname)
if (parsed.error || !parsed.listed || !parsed.domain) {
// fall back to dumb version
return hostname.replace(/^www\./, '')
}
return parsed.domain
}
/**
* Extracts the apex domain from a given URL, for use when matching allowed
* Live Now hosts.
*/
export function getLiveNowHost(url: string) {
const {hostname} = new URL(url)
return sanitizeLiveNowHost(hostname)
}
-92
View File
@@ -1,92 +0,0 @@
import {useMemo} from 'react'
import {
type $Typed,
type AppBskyActorDefs,
AppBskyEmbedExternal,
AtUri,
} from '@atproto/api'
import {isAfter, parseISO} from 'date-fns'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell'
import type * as bsky from '#/types/bsky'
export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
const shadowed = useMaybeProfileShadow(actor)
const tick = useTickEveryMinute()
const config = useLiveNowConfig()
return useMemo(() => {
void tick // revalidate every minute
if (shadowed && 'status' in shadowed && shadowed.status) {
const isValid = isStatusValidForViewers(shadowed.status, config)
const isDisabled = shadowed.status.isDisabled || false
const isActive = isStatusStillActive(shadowed.status.expiresAt)
if (isValid && !isDisabled && isActive) {
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled: false,
isActive: true,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
}
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled,
isActive: false,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
} else {
return {
status: '',
isDisabled: false,
isActive: false,
record: {},
} satisfies AppBskyActorDefs.StatusView
}
}, [shadowed, config, tick])
}
export function isStatusStillActive(timeStr: string | undefined) {
if (!timeStr) return false
const now = new Date()
const expiry = parseISO(timeStr)
return isAfter(expiry, now)
}
/**
* Validates whether the live status is valid for display in the app. Does NOT
* validate if the status is valid for the acting user e.g. as they go live.
*/
export function isStatusValidForViewers(
status: AppBskyActorDefs.StatusView,
config: LiveNowConfig,
) {
if (status.status !== 'app.bsky.actor.status#live') return false
if (!status.uri) return false // should not happen, just backwards compat
try {
const {host: liveDid} = new AtUri(status.uri)
if (AppBskyEmbedExternal.isView(status.embed)) {
const url = new URL(status.embed.external.uri)
const exception = config.allowedHostsExceptionsByDid.get(liveDid)
const isValidException = exception ? exception.has(url.hostname) : false
const isValidForAnyone = config.defaultAllowedHosts.has(url.hostname)
return isValidException || isValidForAnyone
} else {
return false
}
} catch {
return false
}
}
+2 -1
View File
@@ -29,6 +29,7 @@ export async function retry<P>(
export async function networkRetry<P>(
retries: number,
fn: () => Promise<P>,
delay?: number,
): Promise<P> {
return retry(retries, isNetworkError, fn)
return retry(retries, isNetworkError, fn, delay)
}
+2
View File
@@ -62,6 +62,8 @@ export const MAX_DESCRIPTION = 256
export const MAX_GRAPHEME_LENGTH = 300
export const MAX_DRAFT_GRAPHEME_LENGTH = 1000
export const MAX_DM_GRAPHEME_LENGTH = 1000
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
+16
View File
@@ -0,0 +1,16 @@
import {useEffect, useState} from 'react'
/**
* Returns a debounced version of the input value that only updates after the
* specified delay has passed without any changes to the input value.
*/
export function useDebouncedValue<T>(val: T, delayMs: number): T {
const [prev, setPrev] = useState(val)
useEffect(() => {
const timeout = setTimeout(() => setPrev(val), delayMs)
return () => clearTimeout(timeout)
}, [val, delayMs])
return prev
}
@@ -1,107 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react'
import {
KeyboardProvider,
useKeyboardController,
} from 'react-native-keyboard-controller'
import {useFocusEffect} from '@react-navigation/native'
const KeyboardControllerRefCountContext = createContext<{
incrementRefCount: () => void
decrementRefCount: () => void
}>({
incrementRefCount: () => {},
decrementRefCount: () => {},
})
KeyboardControllerRefCountContext.displayName =
'KeyboardControllerRefCountContext'
export function KeyboardControllerProvider({
children,
}: {
children: React.ReactNode
}) {
return (
<KeyboardProvider enabled={false} preload={false}>
<KeyboardControllerProviderInner>
{children}
</KeyboardControllerProviderInner>
</KeyboardProvider>
)
}
function KeyboardControllerProviderInner({
children,
}: {
children: React.ReactNode
}) {
const {setEnabled} = useKeyboardController()
const refCount = useRef(0)
const value = useMemo(
() => ({
incrementRefCount: () => {
refCount.current++
setEnabled(refCount.current > 0)
},
decrementRefCount: () => {
refCount.current--
setEnabled(refCount.current > 0)
if (__DEV__ && refCount.current < 0) {
console.error('KeyboardController ref count < 0')
}
},
}),
[setEnabled],
)
return (
<KeyboardControllerRefCountContext.Provider value={value}>
{children}
</KeyboardControllerRefCountContext.Provider>
)
}
export function useEnableKeyboardController(shouldEnable: boolean) {
const {incrementRefCount, decrementRefCount} = useContext(
KeyboardControllerRefCountContext,
)
useEffect(() => {
if (!shouldEnable) {
return
}
incrementRefCount()
return () => {
decrementRefCount()
}
}, [shouldEnable, incrementRefCount, decrementRefCount])
}
/**
* Like `useEnableKeyboardController`, but using `useFocusEffect`
*/
export function useEnableKeyboardControllerScreen(shouldEnable: boolean) {
const {incrementRefCount, decrementRefCount} = useContext(
KeyboardControllerRefCountContext,
)
useFocusEffect(
useCallback(() => {
if (!shouldEnable) {
return
}
incrementRefCount()
return () => {
decrementRefCount()
}
}, [shouldEnable, incrementRefCount, decrementRefCount]),
)
}
+33
View File
@@ -24,6 +24,7 @@ export const embedPlayerSources = [
'giphy',
'tenor',
'flickr',
'bandcamp',
] as const
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
@@ -44,6 +45,8 @@ export type EmbedPlayerType =
| 'giphy_gif'
| 'tenor_gif'
| 'flickr_album'
| 'bandcamp_album'
| 'bandcamp_track'
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
youtube: 'YouTube',
@@ -56,6 +59,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
appleMusic: 'Apple Music',
soundcloud: 'SoundCloud',
flickr: 'Flickr',
bandcamp: 'Bandcamp',
}
export interface EmbedPlayerParams {
@@ -459,6 +463,32 @@ export function parseEmbedPlayerFromUrl(
return undefined
}
}
const bandcampRegex = /^[a-z\d][a-z\d-]{2,}[a-z\d]\.bandcamp\.com$/i
if (bandcampRegex.test(urlp.hostname)) {
const pathComponents = urlp.pathname.split('/')
switch (pathComponents[1]) {
case 'album':
return {
type: 'bandcamp_album',
source: 'bandcamp',
playerUri: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(
urlp.href,
)}/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/`,
}
case 'track':
return {
type: 'bandcamp_track',
source: 'bandcamp',
playerUri: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(
urlp.href,
)}/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/`,
}
default:
return undefined
}
}
}
export function getPlayerAspect({
@@ -498,6 +528,9 @@ export function getPlayerAspect({
return {height: 165}
case 'apple_music_song':
return {height: 150}
case 'bandcamp_album':
case 'bandcamp_track':
return {aspectRatio: 1}
default:
return {aspectRatio: 16 / 9}
}
+13
View File
@@ -30,6 +30,18 @@ export function cleanError(str: any): string {
if (str.includes('Bad token scope') || str.includes('Bad token method')) {
return t`This feature is not available while using an App Password. Please sign in with your main password.`
}
if (str.includes('Account has been suspended')) {
return t`Account has been suspended`
}
if (str.includes('Account is deactivated')) {
return t`Account is deactivated`
}
if (str.includes('Profile not found')) {
return t`Profile not found`
}
if (str.includes('Unable to resolve handle')) {
return t`Unable to resolve handle`
}
if (str.startsWith('Error: ')) {
return str.slice('Error: '.length)
}
@@ -42,6 +54,7 @@ const NETWORK_ERRORS = [
'Failed to fetch',
'Load failed',
'Upstream service unreachable',
'NetworkError when attempting to fetch resource',
]
export function isNetworkError(e: unknown) {
File diff suppressed because it is too large Load Diff
-3
View File
@@ -4,7 +4,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {usePreventRemove} from '@react-navigation/native'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
@@ -37,8 +36,6 @@ export function FindContactsFlowScreen({navigation}: Props) {
})
})
useEnableKeyboardControllerScreen(true)
const setMinimalShellMode = useSetMinimalShellMode()
const effect = useCallback(() => {
setMinimalShellMode(true)
+23 -18
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {useCallback, useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -12,6 +12,7 @@ import {AccountList} from '#/components/AccountList'
import {Button, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer'
export const ChooseAccountForm = ({
@@ -21,14 +22,14 @@ export const ChooseAccountForm = ({
onSelectAccount: (account?: SessionAccount) => void
onPressBack: () => void
}) => {
const [pendingDid, setPendingDid] = React.useState<string | null>(null)
const [pendingDid, setPendingDid] = useState<string | null>(null)
const {_} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const {setShowLoggedOut} = useLoggedOutViewControls()
const onSelect = React.useCallback(
const onSelect = useCallback(
async (account: SessionAccount) => {
if (pendingDid) {
// The session API isn't resilient to race conditions so let's just ignore this.
@@ -54,7 +55,7 @@ export const ChooseAccountForm = ({
Toast.show(_(msg`Signed in as @${account.handle}`))
} catch (e: any) {
logger.error('choose account: initSession failed', {
message: e.message,
message: e instanceof Error ? e.message : 'Unknown error',
})
// Move to login form.
onSelectAccount(account)
@@ -69,6 +70,7 @@ export const ChooseAccountForm = ({
onSelectAccount,
setShowLoggedOut,
_,
ax,
],
)
@@ -78,26 +80,29 @@ export const ChooseAccountForm = ({
titleText={<Trans>Select account</Trans>}
style={web([a.py_2xl])}>
<View>
<TextField.LabelText>
<Trans>Sign in as...</Trans>
</TextField.LabelText>
{IS_WEB && (
<TextField.LabelText>
<Trans>Sign in as...</Trans>
</TextField.LabelText>
)}
<AccountList
onSelectAccount={onSelect}
onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid}
/>
</View>
<View style={[a.flex_row]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>{_(msg`Back`)}</ButtonText>
</Button>
<View style={[a.flex_1]} />
</View>
{IS_WEB && (
<View style={[a.flex_row]}>
<Button
label={_(msg`Back`)}
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>{_(msg`Back`)}</ButtonText>
</Button>
<View style={[a.flex_1]} />
</View>
)}
</FormContainer>
)
}
+36 -28
View File
@@ -1,21 +1,22 @@
import React, {useState} from 'react'
import {ActivityIndicator, Keyboard, View} from 'react-native'
import {Keyboard, View} from 'react-native'
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as EmailValidator from 'email-validator'
import {isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {Agent} from '#/state/session/agent'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -118,43 +119,50 @@ export const ForgotPasswordForm = ({
<FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{!serviceDescription || isProcessing ? (
<ActivityIndicator />
<View style={[web([a.flex_row, a.align_center]), a.pt_md]}>
{IS_WEB && (
<>
<Button
label={_(msg`Back`)}
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
</>
)}
{!serviceDescription ? (
<Button
label={_(msg`Connecting to service...`)}
size="large"
color="secondary"
disabled>
<ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText>
</Button>
) : (
<Button
label={_(msg`Next`)}
variant="solid"
color={'primary'}
accessibilityHint={_(msg`Navigates to the next screen`)}
color="primary"
size="large"
onPress={onPressNext}>
onPress={onPressNext}
disabled={isProcessing}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
)}
{!serviceDescription || isProcessing ? (
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Processing...</Trans>
</Text>
) : undefined}
</View>
<View
style={[
t.atoms.border_contrast_medium,
a.border_t,
a.pt_2xl,
a.pt_xl,
a.mt_md,
a.flex_row,
a.justify_center,
+5 -6
View File
@@ -1,7 +1,7 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import type React from 'react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useGutters} from '#/alf'
import {Text} from '#/components/Typography'
export function FormContainer({
@@ -16,15 +16,14 @@ export function FormContainer({
style?: StyleProp<ViewStyle>
}) {
const {gtMobile} = useBreakpoints()
const t = useTheme()
const gutter = useGutters([0, 'wide'])
return (
<View
testID={testID}
style={[a.gap_md, a.flex_1, !gtMobile && [a.px_lg, a.py_md], style]}>
style={[a.gap_md, a.flex_1, !gtMobile && gutter, style]}>
{titleText && !gtMobile && (
<Text style={[a.text_xl, a.font_semi_bold, t.atoms.text_contrast_high]}>
{titleText}
</Text>
<Text style={[a.text_3xl, a.font_bold]}>{titleText}</Text>
)}
{children}
</View>
+47 -45
View File
@@ -1,11 +1,5 @@
import React, {useRef, useState} from 'react'
import {
ActivityIndicator,
Keyboard,
LayoutAnimation,
type TextInput,
View,
} from 'react-native'
import {useCallback, useRef, useState} from 'react'
import {Keyboard, type TextInput, View} from 'react-native'
import {
ComAtprotoServerCreateSession,
type ComAtprotoServerDescribeServer,
@@ -14,14 +8,13 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {atoms as a, ios, useTheme} from '#/alf'
import {atoms as a, ios, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider'
@@ -31,7 +24,7 @@ import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env'
import {IS_IOS, IS_WEB} from '#/env'
import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -62,9 +55,11 @@ export const LoginForm = ({
onAttemptFailed: () => void
}) => {
const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
useState<boolean>(false)
const [isProcessing, setIsProcessing] = useState(false)
const [errorField, setErrorField] = useState<
'none' | 'identifier' | 'password' | '2fa'
>('none')
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
const identifierValueRef = useRef<string>(initialHandle || '')
const passwordValueRef = useRef<string>('')
const [authFactorToken, setAuthFactorToken] = useState('')
@@ -77,26 +72,28 @@ export const LoginForm = ({
const {setShowLoggedOut} = useLoggedOutViewControls()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const onPressSelectService = React.useCallback(() => {
const onPressSelectService = useCallback(() => {
Keyboard.dismiss()
}, [])
const onPressNext = async () => {
if (isProcessing) return
Keyboard.dismiss()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setError('')
setErrorField('none')
const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current
if (!identifier) {
setError(_(msg`Please enter your username`))
setErrorField('identifier')
return
}
if (!password) {
setError(_(msg`Please enter your password`))
setErrorField('password')
return
}
@@ -141,7 +138,6 @@ export const LoginForm = ({
requestNotificationsPermission('Login')
} catch (e: any) {
const errMsg = e.toString()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setIsProcessing(false)
if (
e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
@@ -154,6 +150,7 @@ export const LoginForm = ({
error: errMsg,
})
setError(_(msg`Invalid 2FA confirmation code.`))
setErrorField('2fa')
} else if (
errMsg.includes('Authentication Required') ||
errMsg.includes('Invalid identifier or password')
@@ -194,7 +191,7 @@ export const LoginForm = ({
<Trans>Account</Trans>
</TextField.LabelText>
<View style={[a.gap_sm]}>
<TextField.Root>
<TextField.Root isInvalid={errorField === 'identifier'}>
<TextField.Icon icon={At} />
<TextField.Input
testID="loginUsernameInput"
@@ -209,6 +206,7 @@ export const LoginForm = ({
defaultValue={initialHandle || ''}
onChangeText={v => {
identifierValueRef.current = v
if (errorField) setErrorField('none')
}}
onSubmitEditing={() => {
passwordRef.current?.focus()
@@ -221,7 +219,7 @@ export const LoginForm = ({
/>
</TextField.Root>
<TextField.Root>
<TextField.Root isInvalid={errorField === 'password'}>
<TextField.Icon icon={Lock} />
<TextField.Input
testID="loginPasswordInput"
@@ -236,6 +234,7 @@ export const LoginForm = ({
clearButtonMode="while-editing"
onChangeText={v => {
passwordValueRef.current = v
if (errorField) setErrorField('none')
}}
onSubmitEditing={onPressNext}
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
@@ -277,7 +276,7 @@ export const LoginForm = ({
<TextField.LabelText>
<Trans>2FA Confirmation</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Root isInvalid={errorField === '2fa'}>
<TextField.Icon icon={Ticket} />
<TextField.Input
testID="loginAuthFactorTokenInput"
@@ -288,8 +287,11 @@ export const LoginForm = ({
autoComplete="one-time-code"
returnKeyType="done"
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
onChangeText={setAuthFactorToken}
value={authFactorToken} // controlled input due to uncontrolled input not receiving pasted values properly
onChangeText={text => {
setAuthFactorToken(text)
if (errorField) setErrorField('none')
}}
onSubmitEditing={onPressNext}
editable={!isProcessing}
accessibilityHint={_(
@@ -308,25 +310,24 @@ export const LoginForm = ({
</View>
)}
<FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
<View style={[a.pt_md, web([a.justify_between, a.flex_row])]}>
{IS_WEB && (
<Button
label={_(msg`Back`)}
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
)}
{!serviceDescription && error ? (
<Button
testID="loginRetryButton"
label={_(msg`Retry`)}
accessibilityHint={_(msg`Retries signing in`)}
variant="solid"
color="secondary"
color="primary_subtle"
size="large"
onPress={onPressRetryConnect}>
<ButtonText>
@@ -334,23 +335,24 @@ export const LoginForm = ({
</ButtonText>
</Button>
) : !serviceDescription ? (
<>
<ActivityIndicator />
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Connecting...</Trans>
</Text>
</>
<Button
label={_(msg`Connecting to service...`)}
size="large"
color="secondary"
disabled>
<ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText>
</Button>
) : (
<Button
testID="loginNextButton"
label={_(msg`Next`)}
label={_(msg`Sign in`)}
accessibilityHint={_(msg`Navigates to the next screen`)}
variant="solid"
color="primary"
size="large"
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
<Trans>Sign in</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
+3 -4
View File
@@ -2,7 +2,7 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints} from '#/alf'
import {atoms as a, useBreakpoints, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import {FormContainer} from './FormContainer'
@@ -19,18 +19,17 @@ export const PasswordUpdatedForm = ({
<FormContainer
testID="passwordUpdatedForm"
style={[a.gap_2xl, !gtMobile && a.mt_5xl]}>
<Text style={[a.text_3xl, a.font_semi_bold, a.text_center]}>
<Text style={[a.text_3xl, a.font_bold, a.text_center]}>
<Trans>Password updated!</Trans>
</Text>
<Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}>
<Trans>You can now sign in with your new password.</Trans>
</Text>
<View style={[a.flex_row, a.justify_center]}>
<View style={web([a.flex_row, a.justify_center])}>
<Button
onPress={onPressNext}
label={_(msg`Close alert`)}
accessibilityHint={_(msg`Closes password update alert`)}
variant="solid"
color="primary"
size="large">
<ButtonText>
+32 -34
View File
@@ -1,5 +1,5 @@
import {useState} from 'react'
import {ActivityIndicator, View} from 'react-native'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -7,14 +7,16 @@ import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {checkAndFormatResetCode} from '#/lib/strings/password'
import {logger} from '#/logger'
import {Agent} from '#/state/session/agent'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {atoms as a, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import * as TextField from '#/components/forms/TextField'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer'
export const SetNewPasswordForm = ({
@@ -31,7 +33,6 @@ export const SetNewPasswordForm = ({
onPasswordSet: () => void
}) => {
const {_} = useLingui()
const t = useTheme()
const ax = useAnalytics()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
@@ -163,37 +164,34 @@ export const SetNewPasswordForm = ({
<FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_lg]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{isProcessing ? (
<ActivityIndicator />
) : (
<Button
label={_(msg`Next`)}
variant="solid"
color="primary"
size="large"
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
</Button>
<View style={[web([a.flex_row, a.align_center]), a.pt_lg]}>
{IS_WEB && (
<>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
</>
)}
{isProcessing ? (
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Updating...</Trans>
</Text>
) : undefined}
<Button
label={_(msg`Next`)}
color="primary"
size="large"
onPress={onPressNext}
disabled={isProcessing}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
</View>
</FormContainer>
)
@@ -0,0 +1,92 @@
import {useContext} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants'
import {Logomark} from '#/view/icons/Logomark'
import {atoms as a, platform, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import {
BUTTON_VISUAL_ALIGNMENT_OFFSET,
Header,
HEADER_SLOT_SIZE,
} from '#/components/Layout'
import {IS_WEB} from '#/env'
import {AuthLayoutNavigationContext} from '../context'
/**
* This is a simplified version of `Layout.Header` for the auth screens.
*/
export const Slot = Header.Slot
export function Outer({children}: {children: React.ReactNode}) {
const t = useTheme()
const gutters = useGutters([0, 'wide'])
return (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_sm,
gutters,
platform({
native: [a.pb_xs, {minHeight: 48}],
web: [a.py_xs, {minHeight: 52}],
}),
t.atoms.border_contrast_low,
]}>
{children}
</View>
)
}
export function Content({children}: {children?: React.ReactNode}) {
return (
<View style={[a.flex_1, a.justify_center, {minHeight: HEADER_SLOT_SIZE}]}>
{IS_WEB ? children : <Logo />}
</View>
)
}
export function Logo() {
const t = useTheme()
return <Logomark fill={t.palette.primary_500} style={[a.mx_auto]} />
}
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui()
const navigation = useContext(AuthLayoutNavigationContext)
const onPressBack = (evt: GestureResponderEvent) => {
onPress?.(evt)
if (evt.defaultPrevented) return
navigation?.goBack()
}
return (
<Slot>
<Button
label={_(msg`Go back`)}
onPress={onPressBack}
size="small"
variant="ghost"
color="secondary"
shape="round"
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
</Slot>
)
}
@@ -0,0 +1,23 @@
import {type ButtonProps} from '#/components/Button'
import {Header} from '#/components/Layout'
// TEMP: Web needs a much more comprehensive layout change to support the new design
// so to do this incrementally, we'll do native only and migrate to web later
export const Slot = Header.Slot
export function Outer({}: {children: React.ReactNode}) {
return null
}
export function Content({}: {children?: React.ReactNode}) {
return null
}
export function Logo() {
return null
}
export function BackButton({}: Partial<ButtonProps>) {
return null
}
@@ -0,0 +1,5 @@
import {createContext} from 'react'
export const AuthLayoutNavigationContext = createContext<{
goBack: () => void
} | null>(null)
@@ -0,0 +1 @@
export * as Header from './Header'
+41 -23
View File
@@ -18,6 +18,8 @@ import {atoms as a, native} from '#/alf'
import {ScreenTransition} from '#/components/ScreenTransition'
import {useAnalytics} from '#/analytics'
import {ChooseAccountForm} from './ChooseAccountForm'
import * as AuthLayout from './components/AuthLayout'
import {AuthLayoutNavigationContext} from './components/AuthLayout/context'
enum Forms {
Login,
@@ -131,11 +133,14 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
let content = null
let title = ''
let description = ''
let goBack = null
switch (currentForm) {
case Forms.Login:
title = _(msg`Sign in`)
description = _(msg`Enter your username and password`)
goBack = () =>
accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack()
content = (
<LoginForm
error={error}
@@ -146,9 +151,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
onAttemptFailed={onAttemptFailed}
onAttemptSuccess={onAttemptSuccess}
setServiceUrl={setServiceUrl}
onPressBack={() =>
accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack()
}
onPressBack={goBack}
onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={refetchService}
/>
@@ -157,16 +160,18 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
case Forms.ChooseAccount:
title = _(msg`Sign in`)
description = _(msg`Select from an existing account`)
goBack = handlePressBack
content = (
<ChooseAccountForm
onSelectAccount={onSelectAccount}
onPressBack={handlePressBack}
onPressBack={goBack}
/>
)
break
case Forms.ForgotPassword:
title = _(msg`Forgot Password`)
description = _(msg`Let's get your password reset!`)
goBack = () => gotoForm(Forms.Login)
content = (
<ForgotPasswordForm
error={error}
@@ -174,7 +179,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
serviceDescription={serviceDescription}
setError={setError}
setServiceUrl={setServiceUrl}
onPressBack={() => gotoForm(Forms.Login)}
onPressBack={goBack}
onEmailSent={() => gotoForm(Forms.SetNewPassword)}
/>
)
@@ -182,12 +187,13 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
case Forms.SetNewPassword:
title = _(msg`Forgot Password`)
description = _(msg`Let's get your password reset!`)
goBack = () => gotoForm(Forms.ForgotPassword)
content = (
<SetNewPasswordForm
error={error}
serviceUrl={serviceUrl}
setError={setError}
onPressBack={() => gotoForm(Forms.ForgotPassword)}
onPressBack={goBack}
onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}
/>
)
@@ -201,23 +207,35 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
break
}
const navigation = goBack ? {goBack} : null
return (
<Animated.View style={a.flex_1} entering={native(FadeIn.duration(90))}>
<KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}>
<LoggedOutLayout
leadin=""
title={title}
description={description}
scrollable>
<LayoutAnimationConfig skipEntering>
<ScreenTransition
key={currentForm}
direction={screenTransitionDirection}>
{content}
</ScreenTransition>
</LayoutAnimationConfig>
</LoggedOutLayout>
</KeyboardAvoidingView>
</Animated.View>
<AuthLayoutNavigationContext value={navigation}>
<Animated.View style={a.flex_1} entering={native(FadeIn.duration(90))}>
<KeyboardAvoidingView
testID="signIn"
behavior="padding"
style={a.flex_1}>
<AuthLayout.Header.Outer>
<AuthLayout.Header.BackButton />
<AuthLayout.Header.Content />
<AuthLayout.Header.Slot />
</AuthLayout.Header.Outer>
<LoggedOutLayout
leadin=""
title={title}
description={description}
scrollable>
<LayoutAnimationConfig skipEntering>
<ScreenTransition
key={currentForm}
direction={screenTransitionDirection}>
{content}
</ScreenTransition>
</LayoutAnimationConfig>
</LoggedOutLayout>
</KeyboardAvoidingView>
</Animated.View>
</AuthLayoutNavigationContext>
)
}
-3
View File
@@ -15,7 +15,6 @@ import {
} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {
type CommonNavigatorParams,
@@ -68,8 +67,6 @@ export function MessagesConversationScreenInner({route}: Props) {
const convoId = route.params.conversation
const {setCurrentConvoId} = useCurrentConvoId()
useEnableKeyboardControllerScreen(true)
useFocusEffect(
useCallback(() => {
setCurrentConvoId(convoId)
+4 -2
View File
@@ -103,10 +103,12 @@ export function MessagesSettingsScreenInner({}: Props) {
</Toggle.Item>
<Toggle.Item
name="none"
label={_(msg`No one`)}
label={_(
msg({context: 'allow messages from', message: `No one`}),
)}
style={[a.justify_between, a.py_sm]}>
<Toggle.LabelText>
<Trans>No one</Trans>
<Trans context="allow messages from">No one</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
@@ -96,10 +96,13 @@ export function StepFinished() {
const {selectedInterests} = interestsStepResults
await Promise.all([
bulkWriteFollows(agent, [
BSKY_APP_ACCOUNT_DID,
...(listItems?.map(i => i.subject.did) ?? []),
]),
bulkWriteFollows(
agent,
[BSKY_APP_ACCOUNT_DID, ...(listItems?.map(i => i.subject.did) ?? [])],
starterPack
? {uri: starterPack.uri, cid: starterPack.cid}
: undefined,
),
(async () => {
// Interests need to get saved first, then we can write the feeds to prefs
await agent.setInterestsPref({tags: selectedInterests})
@@ -72,7 +72,10 @@ export function StarterPackCard({
let followUris: Map<string, string>
try {
followUris = await bulkWriteFollows(agent, dids)
followUris = await bulkWriteFollows(agent, dids, {
uri: view.uri,
cid: view.cid,
})
} catch (e) {
setIsProcessing(false)
Toast.show(_(msg`An error occurred while trying to follow all`), {
-3
View File
@@ -2,7 +2,6 @@ import {useMemo, useReducer} from 'react'
import {View} from 'react-native'
import * as bcp47Match from 'bcp-47-match'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {useLanguagePrefs} from '#/state/preferences'
import {
Layout,
@@ -60,8 +59,6 @@ export function Onboarding() {
)
const [contactsFlowState, contactsFlowDispatch] = useFindContactsFlowState()
useEnableKeyboardControllerScreen(true)
return (
<Portal>
<View style={[a.absolute, a.inset_0, t.atoms.bg]}>
+7 -1
View File
@@ -4,13 +4,18 @@ import {
type AppBskyGraphGetFollows,
type BskyAgent,
type ComAtprotoRepoApplyWrites,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
import {TID} from '@atproto/common-web'
import chunk from 'lodash.chunk'
import {until} from '#/lib/async/until'
export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
export async function bulkWriteFollows(
agent: BskyAgent,
dids: string[],
via?: ComAtprotoRepoStrongRef.Main,
) {
const session = agent.session
if (!session) {
@@ -22,6 +27,7 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
$type: 'app.bsky.graph.follow',
subject: did,
createdAt: new Date().toISOString(),
via,
}
})
@@ -10,7 +10,6 @@ import {
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {makeProfileLink} from '#/lib/routes/links'
@@ -60,6 +59,7 @@ import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {WhoCanReply} from '#/components/WhoCanReply'
import {useAnalytics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
import * as bsky from '#/types/bsky'
export function ThreadItemAnchor({
@@ -452,14 +452,16 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text
testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
<Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
</Trans>
</Text>
</Link>
) : null}
@@ -470,14 +472,16 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text
testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
<Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
</Trans>
</Text>
</Link>
) : null}
@@ -486,10 +490,12 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.likeCount)}
</Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.likeCount)}
</Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
</Trans>
</Text>
</Link>
) : null}
@@ -497,10 +503,16 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text
testID="bookmarkCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.bookmarkCount)}
</Text>{' '}
<Plural value={post.bookmarkCount} one="save" other="saves" />
<Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.bookmarkCount)}
</Text>{' '}
<Plural
value={post.bookmarkCount}
one="save"
other="saves"
/>
</Trans>
</Text>
) : null}
</View>
@@ -8,7 +8,6 @@ import {
} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useActorStatus} from '#/lib/actor-status'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {makeProfileLink} from '#/lib/routes/links'
@@ -44,6 +43,7 @@ import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import {useActorStatus} from '#/features/liveNow'
export type ThreadItemPostProps = {
item: Extract<ThreadItem, {type: 'threadPost'}>
@@ -10,7 +10,6 @@ import {
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {useHaptics} from '#/lib/haptics'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -39,6 +38,8 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {IS_IOS} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {GermButton} from '../components/GermButton'
import {EditProfileDialog} from './EditProfileDialog'
import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics'
@@ -156,6 +157,7 @@ let ProfileHeaderStandard = ({
testID="profileHeaderDescription"
style={[a.text_md]}
numberOfLines={15}
selectable
value={descriptionRT}
enableTags
authorHandle={profile.handle}
@@ -163,6 +165,10 @@ let ProfileHeaderStandard = ({
</View>
) : undefined}
{profile.associated?.germ && (
<GermButton germ={profile.associated.germ} profile={profile} />
)}
{!isMe &&
!isBlockedUser &&
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
+4 -4
View File
@@ -14,7 +14,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {BACK_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {type NavigationProp} from '#/lib/routes/types'
@@ -28,13 +27,14 @@ import {atoms as a, platform, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {EditLiveDialog} from '#/components/live/EditLiveDialog'
import {LiveIndicator} from '#/components/live/LiveIndicator'
import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog'
import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator'
import {LiveStatusDialog} from '#/features/liveNow/components/LiveStatusDialog'
import {GrowableAvatar} from './GrowableAvatar'
import {GrowableBanner} from './GrowableBanner'
import {StatusBarShadow} from './StatusBarShadow'
@@ -0,0 +1,334 @@
import {Platform, View} from 'react-native'
import {Image} from 'expo-image'
import {
type AppBskyActorDefs,
type AppBskyActorGetProfile,
type AtpAgent,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {until} from '#/lib/async/until'
import {isNetworkError} from '#/lib/strings/errors'
import {RQKEY} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {CustomLinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
import {Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
export function GermButton({
germ,
profile,
}: {
germ: AppBskyActorDefs.ProfileAssociatedGerm
profile: bsky.profile.AnyProfileView
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {currentAccount} = useSession()
const linkWarningControl = Dialog.useDialogControl()
// exclude `none` and all unknown values
if (
!(germ.showButtonTo === 'everyone' || germ.showButtonTo === 'usersIFollow')
) {
return null
}
if (currentAccount?.did === profile.did) {
return <GermSelfButton did={currentAccount.did} />
}
if (germ.showButtonTo === 'usersIFollow' && !profile.viewer?.followedBy) {
return null
}
const url = constructGermUrl(germ, profile, currentAccount?.did)
if (!url) {
return null
}
return (
<>
<Link
to={url}
onPress={evt => {
ax.metric('profile:associated:germ:click-to-chat', {})
if (isCustomGermDomain(url)) {
evt.preventDefault()
linkWarningControl.open()
return false
}
}}
label={_(msg`Open Germ DM`)}
overridePresentation={false}
shouldProxy={false}
style={[
t.atoms.bg_contrast_50,
a.rounded_full,
a.self_start,
{padding: 6},
]}>
<GermLogo size="small" />
<Text style={[a.text_sm, a.font_medium, a.ml_xs]}>
<Trans>Germ DM</Trans>
</Text>
<ArrowTopRightIcon style={[t.atoms.text, a.mx_2xs]} width={14} />
</Link>
<CustomLinkWarningDialog
control={linkWarningControl}
link={{
href: url,
displayText: '',
share: false,
}}
/>
</>
)
}
function GermLogo({size}: {size: 'small' | 'large'}) {
return (
<Image
source={require('../../../../assets/images/germ_logo.webp')}
accessibilityIgnoresInvertColors={false}
contentFit="cover"
style={[
a.rounded_full,
size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16},
]}
/>
)
}
function GermSelfButton({did}: {did: string}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const selfExplanationDialogControl = Dialog.useDialogControl()
const agent = useAgent()
const queryClient = useQueryClient()
const {mutate: deleteDeclaration, isPending} = useMutation({
mutationFn: async () => {
const previousRecord = await agent.com.germnetwork.declaration
.get({
repo: did,
rkey: 'self',
})
.then(res => res.value)
.catch(() => null)
await agent.com.germnetwork.declaration.delete({
repo: did,
rkey: 'self',
})
await whenAppViewReady(agent, did, res => !res.data.associated?.germ)
return previousRecord
},
onSuccess: previousRecord => {
ax.metric('profile:associated:germ:self-disconnect', {})
async function undo() {
if (!previousRecord) return
try {
await agent.com.germnetwork.declaration.put(
{
repo: did,
rkey: 'self',
},
previousRecord,
)
await whenAppViewReady(agent, did, res => !!res.data.associated?.germ)
await queryClient.refetchQueries({queryKey: RQKEY(did)})
Toast.show(_(msg`Germ DM reconnected`))
ax.metric('profile:associated:germ:self-reconnect', {})
} catch (e: any) {
Toast.show(
_(msg`Failed to reconnect Germ DM. Error: ${e?.message}`),
{
type: 'error',
},
)
if (!isNetworkError(e)) {
ax.logger.error('Failed to reconnect Germ DM link', {
safeMessage: e,
})
}
}
}
selfExplanationDialogControl.close(() => {
void queryClient.refetchQueries({queryKey: RQKEY(did)})
Toast.show(
<Toast.Outer>
<Toast.Icon />
<Toast.Text>
<Trans>Germ DM disconnected</Trans>
</Toast.Text>
{previousRecord && (
<Toast.Action label={_(msg`Undo`)} onPress={() => void undo()}>
<Trans>Undo</Trans>
</Toast.Action>
)}
</Toast.Outer>,
)
})
},
onError: error => {
Toast.show(
_(msg`Failed to disconnect Germ DM. Error: ${error?.message}`),
{
type: 'error',
},
)
if (!isNetworkError(error)) {
ax.logger.error('Failed to disconnect Germ DM link', {
safeMessage: error,
})
}
},
})
return (
<>
<Button
label={_(msg`Learn more about your Germ DM link`)}
onPress={() => {
ax.metric('profile:associated:germ:click-self-info', {})
selfExplanationDialogControl.open()
}}
style={[
t.atoms.bg_contrast_50,
a.rounded_full,
a.self_start,
{padding: 6, paddingRight: 10},
]}>
<GermLogo size="small" />
<Text style={[a.text_sm, a.font_medium, a.ml_xs]}>
<Trans>Germ DM</Trans>
</Text>
</Button>
<Dialog.Outer
control={selfExplanationDialogControl}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Germ DM Link`)}
style={web([{maxWidth: 400, borderRadius: 36}])}>
<View style={[a.flex_row, a.align_center, {gap: 6}]}>
<GermLogo size="large" />
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Germ DM Link</Trans>
</Text>
</View>
<Text style={[a.text_md, a.leading_snug, a.mt_sm]}>
<Trans>
This button lets others open the Germ DM app to send you a
message. You can manage its visibility from the Germ DM app, or
you can disconnect your Bluesky account from Germ DM altogether by
clicking the button below.
</Trans>
</Text>
<View style={[a.mt_2xl, a.gap_md]}>
<Button
label={_(msg`Got it`)}
size="large"
color="primary"
onPress={() => selfExplanationDialogControl.close()}>
<ButtonText>
<Trans>Got it</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Disconnect Germ DM`)}
size="large"
color="secondary"
onPress={() => deleteDeclaration()}
disabled={isPending}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText>
<Trans>Disconnect Germ DM</Trans>
</ButtonText>
</Button>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
function constructGermUrl(
declaration: AppBskyActorDefs.ProfileAssociatedGerm,
profile: bsky.profile.AnyProfileView,
viewerDid?: string,
) {
try {
const urlp = new URL(declaration.messageMeUrl)
if (urlp.pathname.endsWith('/')) {
urlp.pathname = urlp.pathname.slice(0, -1)
}
urlp.pathname += `/${platform()}`
if (viewerDid) {
urlp.hash = `#${profile.did}+${viewerDid}`
} else {
urlp.hash = `#${profile.did}`
}
return urlp.toString()
} catch {
return null
}
}
function isCustomGermDomain(url: string) {
try {
const urlp = new URL(url)
return urlp.hostname !== 'landing.ger.mx'
} catch {
return false
}
}
function platform() {
switch (Platform.OS) {
case 'ios':
return 'iOS'
case 'android':
return 'android'
default:
return 'web'
}
}
async function whenAppViewReady(
agent: AtpAgent,
actor: string,
fn: (res: AppBskyActorGetProfile.Response) => boolean,
) {
await until(
5, // 5 tries
1e3, // 1s delay between tries
fn,
() => agent.app.bsky.actor.getProfile({actor}),
)
}
+24 -9
View File
@@ -1,4 +1,4 @@
import {useCallback, useMemo, useRef} from 'react'
import {useCallback, useMemo, useRef, useState} from 'react'
import {View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated'
import {
@@ -35,12 +35,13 @@ import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {FAB} from '#/view/com/util/fab/FAB'
import {type ListRef} from '#/view/com/util/List'
import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen'
import {atoms as a, platform} from '#/alf'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Hider from '#/components/moderation/Hider'
import {IS_WEB} from '#/env'
import {AboutSection} from './AboutSection'
import {ErrorScreen} from './components/ErrorScreen'
import {Header} from './components/Header'
@@ -149,6 +150,7 @@ function ProfileListScreenLoaded({
moderationOpts: ModerationOpts
preferences: UsePreferencesQueryResponse
}) {
const t = useTheme()
const {_} = useLingui()
const queryClient = useQueryClient()
const {openComposer} = useOpenComposer()
@@ -164,6 +166,8 @@ function ProfileListScreenLoaded({
const scrollElRef = useAnimatedRef()
const addUserDialogControl = useDialogControl()
const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)]
// modlist only
const [headerHeight, setHeaderHeight] = useState<number | null>(null)
const moderation = useMemo(() => {
return moderateUserList(list, moderationOpts)
@@ -263,13 +267,24 @@ function ProfileListScreenLoaded({
</Hider.Mask>
<Hider.Content>
<View style={[a.util_screen_outer]}>
<Layout.Center>{renderHeader()}</Layout.Center>
<AboutSection
list={list}
scrollElRef={scrollElRef as ListRef}
onPressAddUser={addUserDialogControl.open}
headerHeight={0}
/>
<Layout.Center
onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}
style={[
native([a.absolute, a.z_10, t.atoms.bg]),
a.border_b,
t.atoms.border_contrast_low,
]}>
{renderHeader()}
</Layout.Center>
{headerHeight !== null && (
<AboutSection
list={list}
scrollElRef={scrollElRef as ListRef}
onPressAddUser={addUserDialogControl.open}
headerHeight={IS_WEB ? 0 : headerHeight}
/>
)}
<FAB
testID="composeFAB"
onPress={() => openComposer({logContext: 'Fab'})}
@@ -1,7 +1,7 @@
import {useMemo} from 'react'
import {Pressable, View} from 'react-native'
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api'
import {msg, plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -9,7 +9,6 @@ import {useTrendingSettings} from '#/state/preferences/trending'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {formatCount} from '#/view/com/util/numeric/format'
import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {type Props as SVGIconProps} from '#/components/icons/common'
@@ -65,7 +64,7 @@ export function TrendRow({
onPress?: () => void
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const {_} = useLingui()
const gutters = useGutters([0, 'base'])
const category = useCategoryDisplayName(trend?.category || 'other')
@@ -74,13 +73,6 @@ export function TrendRow({
(1000 * 60 * 60),
)
const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age
const postCount = trend.postCount
? _(
plural(trend.postCount, {
other: `${formatCount(i18n, trend.postCount)} posts`,
}),
)
: null
const actors = useModerateTrendingActors(trend.actors)
@@ -132,8 +124,6 @@ export function TrendRow({
web(a.leading_snug),
]}
numberOfLines={1}>
{postCount}
{postCount && category && <> &middot; </>}
{category}
</Text>
</View>
+7 -3
View File
@@ -3,7 +3,6 @@ import {useLingui} from '@lingui/react'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, useTheme} from '#/alf'
@@ -27,6 +26,7 @@ import * as Layout from '#/components/Layout'
import {ChangeHandleDialog} from './components/ChangeHandleDialog'
import {ChangePasswordDialog} from './components/ChangePasswordDialog'
import {DeactivateAccountDialog} from './components/DeactivateAccountDialog'
import {DeleteAccountDialog} from './components/DeleteAccountDialog'
import {ExportCarDialog} from './components/ExportCarDialog'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'>
@@ -34,13 +34,13 @@ export function AccountSettingsScreen({}: Props) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const {openModal} = useModalControls()
const emailDialogControl = useEmailDialogControl()
const birthdayControl = useDialogControl()
const changeHandleControl = useDialogControl()
const changePasswordControl = useDialogControl()
const exportCarControl = useDialogControl()
const deactivateAccountControl = useDialogControl()
const deleteAccountControl = useDialogControl()
return (
<Layout.Screen>
@@ -169,7 +169,7 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={_(msg`Delete account`)}
onPress={() => openModal({name: 'delete-account'})}
onPress={() => deleteAccountControl.open()}
destructive>
<SettingsList.ItemIcon icon={Trash_Stroke2_Corner2_Rounded} />
<SettingsList.ItemText>
@@ -185,6 +185,10 @@ export function AccountSettingsScreen({}: Props) {
<ChangePasswordDialog control={changePasswordControl} />
<ExportCarDialog control={exportCarControl} />
<DeactivateAccountDialog control={deactivateAccountControl} />
<DeleteAccountDialog
control={deleteAccountControl}
deactivateDialogControl={deactivateAccountControl}
/>
</Layout.Screen>
)
}
@@ -126,12 +126,12 @@ export function Inner({
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
label={_(msg`No one`)}
label={_(msg({context: 'enable for', message: `No one`}))}
name="none"
style={[a.flex_row, a.py_xs, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[t.atoms.text, a.font_normal, a.text_md]}>
<Trans>No one</Trans>
<Trans context="enable for">No one</Trans>
</Toggle.LabelText>
</Toggle.Item>
</View>
@@ -156,7 +156,7 @@ function NotificationDeclaration({
case 'mutuals':
return <Trans>Only followers who I follow</Trans>
case 'none':
return <Trans>No one</Trans>
return <Trans context="enable for">No one</Trans>
case 'followers':
default:
return <Trans>Anyone who follows me</Trans>
+1 -1
View File
@@ -7,7 +7,6 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useActorStatus} from '#/lib/actor-status'
import {HELP_DESK_URL} from '#/lib/constants'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates'
@@ -70,6 +69,7 @@ import {
} from '#/components/verification/VerificationCheckButton'
import {useAnalytics} from '#/analytics'
import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {device, useStorage} from '#/storage'
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
@@ -0,0 +1,364 @@
import {useCallback, useRef, useState} from 'react'
import {type TextInput, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useAgent, useSession, useSessionApi} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {type DialogOuterProps} from '#/components/Dialog'
import {
isValidCode,
TokenField,
} from '#/components/dialogs/EmailDialog/components/TokenField'
import * as TextField from '#/components/forms/TextField'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import * as toast from '#/components/Toast'
import {Span, Text} from '#/components/Typography'
import {resetToTab} from '#/Navigation'
const WHITESPACE_RE = /\s/gu
const PASSWORD_MIN_LENGTH = 8
enum Step {
SEND_CODE,
VERIFY_CODE,
CONFIRM_DELETION,
}
enum EmailState {
DEFAULT,
PENDING,
}
function isPasswordValid(password: string) {
return password.length >= PASSWORD_MIN_LENGTH
}
export function DeleteAccountDialog({
control,
deactivateDialogControl,
}: {
control: DialogOuterProps['control']
deactivateDialogControl: DialogOuterProps['control']
}) {
return (
<Prompt.Outer control={control}>
<DeleteAccountDialogInner
control={control}
deactivateDialogControl={deactivateDialogControl}
/>
</Prompt.Outer>
)
}
function DeleteAccountDialogInner({
control,
deactivateDialogControl,
}: {
control: DialogOuterProps['control']
deactivateDialogControl: DialogOuterProps['control']
}) {
const passwordRef = useRef<TextInput | null>(null)
const t = useTheme()
const {_} = useLingui()
const cleanError = useCleanError()
const agent = useAgent()
const {currentAccount} = useSession()
const {removeAccount} = useSessionApi()
const [emailState, setEmailState] = useState(EmailState.DEFAULT)
const [emailSentCount, setEmailSentCount] = useState(0)
const [step, setStep] = useState(Step.SEND_CODE)
const [confirmCode, setConfirmCode] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const sendEmail = useCallback(async () => {
if (emailState === EmailState.PENDING) {
return
}
try {
setEmailState(EmailState.PENDING)
await agent.com.atproto.server.requestAccountDelete()
setError('')
setEmailSentCount(prevCount => prevCount + 1)
setStep(Step.VERIFY_CODE)
} catch (e: any) {
const {clean, raw} = cleanError(e)
const error = clean || raw || e
setError(error)
logger.error(raw || e, {
message: 'Failed to send account deletion verification email',
})
} finally {
setEmailState(EmailState.DEFAULT)
}
}, [agent, cleanError, emailState, setEmailState])
const confirmDeletion = useCallback(async () => {
try {
setError('')
if (!currentAccount?.did) {
throw new Error('Invalid did')
}
const token = confirmCode.replace(WHITESPACE_RE, '')
// Inform chat service of intent to delete account.
const {success} = await agent.api.chat.bsky.actor.deleteAccount(
undefined,
{
headers: DM_SERVICE_HEADERS,
},
)
if (!success) {
throw new Error('Failed to inform chat service of account deletion')
}
await agent.com.atproto.server.deleteAccount({
did: currentAccount.did,
password,
token,
})
control.close(() => {
toast.show(_(msg`Your account has been deleted, see ya! ✌️`))
resetToTab('HomeTab')
removeAccount(currentAccount)
})
} catch (e: any) {
const {clean, raw} = cleanError(e)
const error = clean || raw || e
setError(error)
logger.error(raw || e, {
message: 'Failed to delete account',
})
setConfirmCode('')
setPassword('')
setStep(Step.VERIFY_CODE)
}
}, [
_,
agent,
cleanError,
confirmCode,
control,
currentAccount,
password,
removeAccount,
])
const handleDeactivate = useCallback(() => {
control.close(() => deactivateDialogControl.open())
}, [control, deactivateDialogControl])
const handleSendEmail = useCallback(() => {
void sendEmail()
}, [sendEmail])
const handleSubmitConfirmCode = useCallback(() => {
passwordRef.current?.focus()
}, [])
const handleDeleteAccount = useCallback(() => {
setStep(Step.CONFIRM_DELETION)
}, [setStep])
const handleConfirmDeletion = useCallback(() => {
void confirmDeletion()
}, [confirmDeletion])
const currentHandle = sanitizeHandle(currentAccount?.handle ?? '', '@')
const currentEmail = currentAccount?.email ?? '(no email)'
switch (step) {
case Step.SEND_CODE:
return (
<>
<Prompt.Content>
<Prompt.TitleText>
{_(msg`Delete account “${currentHandle}”`)}
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
For security reasons, we’ll need to send a confirmation code to
your email address{' '}
<Span style={[a.font_semi_bold, t.atoms.text]}>
{currentEmail}
</Span>
.
</Trans>
</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
icon={emailState === EmailState.PENDING ? Loader : Envelope}
cta={_(msg`Send email`)}
shouldCloseOnPress={false}
onPress={handleSendEmail}
/>
<Prompt.Cancel />
</Prompt.Actions>
{error && (
<Admonition style={[a.mt_lg]} type="error">
<Text style={[a.flex_1, a.leading_snug]}>{error}</Text>
</Admonition>
)}
<Admonition style={[a.mt_lg]} type="tip">
<Trans>
You can also{' '}
<Span
style={[{color: t.palette.primary_500}, web(a.underline)]}
onPress={handleDeactivate}>
temporarily deactivate
</Span>{' '}
your account instead. Your profile, posts, feeds, and lists will
no longer be visible to other Bluesky users. You can reactivate
your account at any time by logging in.
</Trans>
</Admonition>
</>
)
case Step.VERIFY_CODE:
return (
<>
<Prompt.Content>
<Prompt.TitleText>
{_(msg`Delete account “${currentHandle}”`)}
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
Check{' '}
<Span style={[a.font_semi_bold, t.atoms.text]}>
{currentEmail}
</Span>{' '}
for an email with the confirmation code to enter below:
</Trans>
</Prompt.DescriptionText>
</Prompt.Content>
<View style={[a.mb_xs]}>
<TextField.LabelText>
<Trans>Confirmation code</Trans>
</TextField.LabelText>
<TokenField
value={confirmCode}
onChangeText={setConfirmCode}
onSubmitEditing={handleSubmitConfirmCode}
/>
</View>
<Text
style={[
a.text_sm,
a.leading_snug,
a.mb_lg,
t.atoms.text_contrast_medium,
]}>
{emailSentCount > 1 ? (
<Trans>
Email sent!{' '}
<InlineLinkText
label={_(msg`Resend`)}
{...createStaticClick(() => {
void handleSendEmail()
})}>
Click here to resend.
</InlineLinkText>
</Trans>
) : (
<Trans>
Don’t see a code?{' '}
<InlineLinkText
label={_(msg`Resend`)}
{...createStaticClick(() => {
void handleSendEmail()
})}>
Click here to resend.
</InlineLinkText>
</Trans>
)}{' '}
<Span style={{top: 1}}>
{emailState === EmailState.PENDING ? <Loader size="xs" /> : null}
</Span>
</Text>
<View style={[a.mb_xl]}>
<TextField.LabelText>
<Trans>Password</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Lock} />
<TextField.Input
inputRef={passwordRef}
testID="newPasswordInput"
label={_(msg`Enter your password`)}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
secureTextEntry={true}
autoComplete="off"
clearButtonMode="while-editing"
passwordRules={`minlength: ${PASSWORD_MIN_LENGTH}};`}
value={password}
onChangeText={setPassword}
onSubmitEditing={handleDeleteAccount}
/>
</TextField.Root>
</View>
<Prompt.Actions>
<Prompt.Action
color="negative"
disabled={!isValidCode(confirmCode) || !isPasswordValid(password)}
cta={_(msg`Delete my account`)}
shouldCloseOnPress={false}
onPress={handleDeleteAccount}
/>
<Prompt.Cancel />
</Prompt.Actions>
{error && (
<Admonition style={[a.mt_lg]} type="error">
<Text style={[a.flex_1, a.leading_snug]}>{error}</Text>
</Admonition>
)}
</>
)
case Step.CONFIRM_DELETION:
return (
<>
<Prompt.Content>
<Prompt.TitleText>
{_(msg`Are you really, really sure?`)}
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
This will irreversibly delete your Bluesky account{' '}
<Span style={[a.font_semi_bold, t.atoms.text]}>
{currentHandle}
</Span>{' '}
and all associated data. Note that this will affect any other{' '}
<InlineLinkText
label={_(msg`Learn more about the AT Protocol.`)}
style={[a.text_md]}
to="https://bsky.social/about/faq">
AT Protocol
</InlineLinkText>{' '}
services you use with this account.
</Trans>
</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
color="negative"
cta={_(msg`Yes, delete my account`)}
shouldCloseOnPress={false}
onPress={handleConfirmDeletion}
/>
<Prompt.Cancel />
</Prompt.Actions>
</>
)
}
}
+9 -9
View File
@@ -1,6 +1,6 @@
import React, {useRef} from 'react'
import {type TextInput, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as EmailValidator from 'email-validator'
import type tldts from 'tldts'
@@ -312,15 +312,15 @@ export function StepInfo({
<Admonition.Content>
<Admonition.Text>
{!isOverAppMinAccessAge ? (
<Trans>
You must be {MIN_ACCESS_AGE} years of age or older
to create an account.
</Trans>
<Plural
value={MIN_ACCESS_AGE}
other="You must be # years of age or older to create an account."
/>
) : (
<Trans>
You must be {aaRegionConfig.minAccessAge} years of
age or older to create an account in your region.
</Trans>
<Plural
value={aaRegionConfig.minAccessAge}
other="You must be # years of age or older to create an account in your region."
/>
)}
</Admonition.Text>
{IS_NATIVE &&
@@ -372,7 +372,10 @@ function Header({
let followUris: Map<string, string>
try {
followUris = await bulkWriteFollows(agent, dids)
followUris = await bulkWriteFollows(agent, dids, {
uri: starterPack.uri,
cid: starterPack.cid,
})
} catch (e) {
setIsProcessing(false)
Toast.show(_(msg`An error occurred while trying to follow all`), 'xmark')
@@ -498,7 +501,7 @@ function Header({
value={starterPack.joinedAllTimeCount || 0}
other="# people have"
/>{' '}
used this starter pack!
joined Bluesky via this starter pack!
</Trans>
</Text>
</View>
-3
View File
@@ -16,7 +16,6 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {STARTER_PACK_MAX_SIZE} from '#/lib/constants'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {
type CommonNavigatorParams,
@@ -185,8 +184,6 @@ function WizardInner({
})
}, [navigation])
useEnableKeyboardControllerScreen(true)
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(true)
-3
View File
@@ -12,7 +12,6 @@ import {
BLUESKY_MOD_SERVICE_HEADERS,
MAX_REPORT_REASON_GRAPHEME_LENGTH,
} from '#/lib/constants'
import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
import {cleanError} from '#/lib/strings/errors'
import {useAgent, useSession, useSessionApi} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
@@ -121,8 +120,6 @@ export function Takendown() {
const webLayout = IS_WEB && gtMobile
useEnableKeyboardController(true)
return (
<View style={[a.util_screen_outer, a.flex_1]}>
<KeyboardAwareScrollView style={[a.flex_1, t.atoms.bg]} centerContent>
+87
View File
@@ -0,0 +1,87 @@
import {createContext, useContext} from 'react'
import {QueryClient, useQuery} from '@tanstack/react-query'
import {APP_CONFIG_URL} from '#/env'
const qc = new QueryClient()
const appConfigQueryKey = ['app-config']
/**
* Matches the types defined in our `app-config` worker
*/
type AppConfigResponse = {
liveNow: {
allow: string[]
exceptions: {
did: string
allow: string[]
}[]
}
}
export const DEFAULT_APP_CONFIG_RESPONSE: AppConfigResponse = {
liveNow: {
allow: [],
exceptions: [],
},
}
let fetchAppConfigPromise: Promise<AppConfigResponse> | undefined
async function fetchAppConfig(): Promise<AppConfigResponse | null> {
try {
if (!fetchAppConfigPromise) {
fetchAppConfigPromise = (async () => {
const r = await fetch(`${APP_CONFIG_URL}/config`)
if (!r.ok) throw new Error(await r.text())
const data = await r.json()
return data
})()
}
return await fetchAppConfigPromise
} catch (e) {
fetchAppConfigPromise = undefined
throw e
}
}
const Context = createContext<AppConfigResponse>(DEFAULT_APP_CONFIG_RESPONSE)
export function Provider({children}: React.PropsWithChildren<{}>) {
const {data} = useQuery<AppConfigResponse | null>(
{
staleTime: Infinity,
queryKey: appConfigQueryKey,
refetchInterval: query => {
// refetch regularly if fetch failed, otherwise never refetch
return query.state.status === 'error' ? 60e3 : Infinity
},
async queryFn() {
return fetchAppConfig()
},
},
qc,
)
return (
<Context.Provider value={data ?? DEFAULT_APP_CONFIG_RESPONSE}>
{children}
</Context.Provider>
)
}
export async function prefetchAppConfig() {
try {
const data = await fetchAppConfig()
if (data) {
qc.setQueryData(appConfigQueryKey, data)
}
} catch {}
}
export function useAppConfig() {
const ctx = useContext(Context)
if (!ctx) {
throw new Error('useAppConfig must be used within a Provider')
}
return ctx
}

Some files were not shown because too many files have changed in this diff Show More