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 web worker URL
LIVE_EVENTS_DEV_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 yarn ios # Run on iOS
# Testing & Quality # Testing & Quality
# IMPORTANT: Always use these yarn scripts, never call the underlying tools directly
yarn test # Run Jest tests yarn test # Run Jest tests
yarn lint # Run ESLint yarn lint # Run ESLint
yarn typecheck # Run TypeScript type checking yarn typecheck # Run TypeScript type checking
# Internationalization # Internationalization
yarn intl:extract # Extract translation strings (you don't typically need to run this manually, we have CI for it) # DO NOT run these commands - extraction and compilation are handled by CI
yarn intl:compile # Compile translations for runtime yarn intl:extract # Extract translation strings (nightly CI job)
yarn intl:compile # Compile translations for runtime (nightly CI job)
# Build # Build
yarn build-web # Build web version yarn build-web # Build web version
@@ -299,8 +301,9 @@ function MyComponent() {
**Commands:** **Commands:**
```bash ```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: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 ## State Management
+24
View File
@@ -437,6 +437,13 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://www.flickr.com/groups/898944@N23/', 'https://www.flickr.com/groups/898944@N23/',
'https://www.flickr.com/groups', '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 = [ const outputs = [
@@ -815,6 +822,23 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined, undefined,
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', () => { 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 environment set up. Either follow the top-level README, or something quick
like: like:
# install nodejs ```bash
nvm install # install nodejs
nvm use nvm install
npm install --global yarn nvm use
npm install --global yarn
# setup tools and deps (in top level of this repo) # setup tools and deps (in top level of this repo)
yarn install --frozen-lockfile yarn install --frozen-lockfile
# run yarn web dev server, if you wanted # run yarn web dev server, if you wanted
yarn web yarn web
```
Then build and copy over the big 'ol `bundle.web.js` file: 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 ### Golang Daemon
@@ -28,11 +33,13 @@ Install golang. We generally develop against the current stable release of the l
In this directory (`bskyweb/`): In this directory (`bskyweb/`):
# re-build and run daemon ```bash
go run ./cmd/bskyweb serve # re-build and run daemon
go run ./cmd/bskyweb serve
# build and output a binary # build and output a binary
go build -o bskyweb ./cmd/bskyweb/ go build -o bskyweb ./cmd/bskyweb/
```
The easiest way to configure the daemon is to copy `example.env` to `.env` and The easiest way to configure the daemon is to copy `example.env` to `.env` and
fill in auth values there. 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 { if postView.Embed != nil && !isEmbedHidden {
hasImages := postView.Embed.EmbedImages_View != nil 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 { if hasImages {
var thumbUrls []string 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) thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
} }
data["imgThumbUrls"] = thumbUrls 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 var thumbUrls []string
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images { for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb) thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
} }
data["imgThumbUrls"] = thumbUrls 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> </head>
<body> <body>
{%- block body_all %} {%- 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="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> </div>
<noscript> <noscript>
+1 -1
View File
@@ -44,6 +44,6 @@ android {
dependencies { dependencies {
implementation project(':expo-modules-core') 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:+" implementation "com.facebook.react:react-native:+"
} }
@@ -5,8 +5,12 @@ import android.util.DisplayMetrics
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewStructure import android.view.ViewStructure
import android.view.Window
import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityEvent
import android.widget.FrameLayout 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 androidx.core.view.allViews
import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactContext 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.facebook.react.uimanager.events.EventDispatcher
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.internal.EdgeToEdgeUtils
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView import expo.modules.kotlin.views.ExpoView
@@ -29,22 +34,26 @@ class BottomSheetView(
private lateinit var dialogRootViewGroup: DialogRootViewGroup private lateinit var dialogRootViewGroup: DialogRootViewGroup
private var eventDispatcher: EventDispatcher? = null private var eventDispatcher: EventDispatcher? = null
private var isKeyboardVisible: Boolean = false
private val rawScreenHeight = private val screenHeight =
context.resources.displayMetrics.heightPixels context.resources.displayMetrics.heightPixels
.toFloat() .toFloat()
private val safeScreenHeight = (rawScreenHeight - getNavigationBarHeight()).toFloat()
private fun getNavigationBarHeight(): Int { private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0 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 onAttemptDismiss by EventDispatcher()
private val onSnapPointChange by EventDispatcher() private val onSnapPointChange by EventDispatcher()
private val onStateChange by EventDispatcher() private val onStateChange by EventDispatcher()
// Props
var disableDrag = false var disableDrag = false
set(value) { set(value) {
field = value field = value
@@ -56,48 +65,31 @@ class BottomSheetView(
field = value field = value
this.dialog?.setCancelable(!value) this.dialog?.setCancelable(!value)
} }
var preventExpansion = false var preventExpansion = false
var minHeight = 0f var minHeight = 0f
set(value) { set(value) {
field = field = if (value < 0) 0f else dpToPx(value)
if (value < 0) {
0f
} else {
dpToPx(value)
}
} }
var maxHeight = this.safeScreenHeight var maxHeight = this.screenHeight
set(value) { set(value) {
val px = dpToPx(value) val px = dpToPx(value)
field = field = if (px > this.screenHeight) this.screenHeight else px
if (px > this.safeScreenHeight) {
this.safeScreenHeight
} else {
px
}
} }
private var isOpen: Boolean = false private var isOpen: Boolean = false
set(value) { set(value) {
field = value field = value
onStateChange( onStateChange(mapOf("state" to if (value) "open" else "closed"))
mapOf(
"state" to if (value) "open" else "closed",
),
)
} }
private var isOpening: Boolean = false private var isOpening: Boolean = false
set(value) { set(value) {
field = value field = value
if (value) { if (value) {
onStateChange( onStateChange(mapOf("state" to "opening"))
mapOf(
"state" to "opening",
),
)
} }
} }
@@ -105,33 +97,21 @@ class BottomSheetView(
set(value) { set(value) {
field = value field = value
if (value) { if (value) {
onStateChange( onStateChange(mapOf("state" to "closing"))
mapOf(
"state" to "closing",
),
)
} }
} }
private var selectedSnapPoint = 0 private var selectedSnapPoint = 0
set(value) { set(value) {
if (field == value) return if (field == value) return
field = value field = value
onSnapPointChange( onSnapPointChange(mapOf("snapPoint" to value))
mapOf(
"snapPoint" to value,
),
)
} }
// Lifecycle
init { init {
(appContext.reactContext as? ReactContext)?.let { (appContext.reactContext as? ReactContext)?.let {
it.addLifecycleEventListener(this) it.addLifecycleEventListener(this)
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id) this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
this.dialogRootViewGroup = DialogRootViewGroup(context) this.dialogRootViewGroup = DialogRootViewGroup(context)
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
} }
@@ -161,27 +141,55 @@ class BottomSheetView(
private fun getHalfExpandedRatio(contentHeight: Float): Float = private fun getHalfExpandedRatio(contentHeight: Float): Float =
when { when {
// Full height sheets // Full height sheets
contentHeight >= safeScreenHeight -> 0.99f contentHeight >= screenHeight -> 0.99f
// Medium height sheets (>50% but <100%) else -> this.clampRatio(this.getTargetHeight() / screenHeight)
contentHeight >= safeScreenHeight / 2 ->
this.clampRatio(this.getTargetHeight() / safeScreenHeight)
// Small height sheets (<50%)
else ->
this.clampRatio(this.getTargetHeight() / rawScreenHeight)
} }
private fun present() { private fun present() {
if (this.isOpen || this.isOpening || this.isClosing) return if (this.isOpen || this.isOpening || this.isClosing) return
val contentHeight = this.getContentHeight() 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.setContentView(dialogRootViewGroup)
dialog.setCancelable(!preventDismiss) dialog.setCancelable(!preventDismiss)
dialog.setDismissWithAnimation(true)
dialog.setOnDismissListener { dialog.setOnDismissListener {
this.isClosing = true this.isClosing = true
this.destroy() 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) val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let { bottomSheet?.let {
it.setBackgroundColor(0) it.setBackgroundColor(0)
@@ -194,7 +202,17 @@ class BottomSheetView(
behavior.isDraggable = true behavior.isDraggable = true
behavior.isHideable = 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 behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2 this.selectedSnapPoint = 2
} else { } else {
@@ -209,18 +227,10 @@ class BottomSheetView(
newState: Int, newState: Int,
) { ) {
when (newState) { when (newState) {
BottomSheetBehavior.STATE_EXPANDED -> { BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
selectedSnapPoint = 2 BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
} BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_COLLAPSED -> { BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HIDDEN -> {
selectedSnapPoint = 0
}
} }
} }
@@ -231,9 +241,26 @@ class BottomSheetView(
}, },
) )
} }
this.isOpening = true this.isOpening = true
dialog.show() dialog.show()
this.dialog = dialog 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() { fun updateLayout() {
@@ -246,12 +273,24 @@ class BottomSheetView(
val currentState = behavior.state val currentState = behavior.state
val oldRatio = behavior.halfExpandedRatio val oldRatio = behavior.halfExpandedRatio
var newRatio = getHalfExpandedRatio(contentHeight) val newRatio = getHalfExpandedRatio(contentHeight)
behavior.halfExpandedRatio = newRatio 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 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 behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) { } else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
@@ -279,25 +318,19 @@ class BottomSheetView(
private fun getTargetHeight(): Float { private fun getTargetHeight(): Float {
val contentHeight = this.getContentHeight() val contentHeight = this.getContentHeight()
val height = return when {
if (contentHeight > maxHeight) { contentHeight > maxHeight -> maxHeight
maxHeight contentHeight < minHeight -> minHeight
} else if (contentHeight < minHeight) { else -> contentHeight
minHeight }
} else {
contentHeight
}
return height
} }
private fun clampRatio(ratio: Float): Float { private fun clampRatio(ratio: Float): Float =
if (ratio < 0.01) { when {
return 0.01f ratio < 0.01 -> 0.01f
} else if (ratio > 0.99) { ratio > 0.99 -> 0.99f
return 0.99f else -> ratio
} }
return ratio
}
private fun setDraggable(draggable: Boolean) { private fun setDraggable(draggable: Boolean) {
val dialog = this.dialog ?: return val dialog = this.dialog ?: return
@@ -322,9 +355,7 @@ class BottomSheetView(
// View overrides to pass to DialogRootViewGroup instead // View overrides to pass to DialogRootViewGroup instead
override fun dispatchProvideStructure(structure: ViewStructure?) { override fun dispatchProvideStructure(structure: ViewStructure?) {
if (structure == null) { if (structure == null) return
return
}
dialogRootViewGroup.dispatchProvideStructure(structure) dialogRootViewGroup.dispatchProvideStructure(structure)
} }
@@ -363,7 +394,6 @@ class BottomSheetView(
// https://stackoverflow.com/questions/11862391/getheight-px-or-dpi // https://stackoverflow.com/questions/11862391/getheight-px-or-dpi
fun dpToPx(dp: Float): Float { fun dpToPx(dp: Float): Float {
val displayMetrics = context.resources.displayMetrics val displayMetrics = context.resources.displayMetrics
val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT) return dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
return px
} }
} }
@@ -52,6 +52,8 @@ class DialogRootViewGroup(
if (ReactFeatureFlags.dispatchPointerEvents) { if (ReactFeatureFlags.dispatchPointerEvents) {
jSPointerDispatcher = JSPointerDispatcher(this) jSPointerDispatcher = JSPointerDispatcher(this)
} }
fitsSystemWindows = false
} }
override fun onSizeChanged( 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' && { Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius, borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius, borderTopRightRadius: cornerRadius,
overflow: 'hidden',
}, },
extraStyles, extraStyles,
]}> ]}>
@@ -34,12 +34,15 @@ class NotificationPrefs(
is Boolean -> { is Boolean -> {
putBoolean(key, value) putBoolean(key, value)
} }
is String -> { is String -> {
putString(key, value) putString(key, value)
} }
is Array<*> -> { is Array<*> -> {
putStringSet(key, value.map { it.toString() }.toSet()) putStringSet(key, value.map { it.toString() }.toSet())
} }
is Map<*, *> -> { is Map<*, *> -> {
putStringSet(key, value.map { it.toString() }.toSet()) putStringSet(key, value.map { it.toString() }.toSet())
} }
@@ -117,7 +117,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleImageIntents( private fun handleImageIntents(
uris: List<Uri>, uris: List<Uri>,
text: String? text: String?,
) { ) {
var allParams = "" var allParams = ""
@@ -145,7 +145,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleVideoIntents( private fun handleVideoIntents(
uris: List<Uri>, uris: List<Uri>,
text: String? text: String?,
) { ) {
val uri = uris[0] val uri = uris[0]
// If there is no extension for the file, substringAfterLast returns the original string - not // 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", "name": "bsky.app",
"version": "1.116.0", "version": "1.117.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -73,7 +73,7 @@
"icons:optimize": "svgo -f ./assets/icons" "icons:optimize": "svgo -f ./assets/icons"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.18.20", "@atproto/api": "^0.18.21",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6", "@bsky.app/alf": "^0.1.6",
@@ -202,7 +202,7 @@
"react-native-edge-to-edge": "^1.6.0", "react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.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-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress", "react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3", "react-native-qrcode-styled": "^0.3.3",
@@ -229,7 +229,7 @@
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@atproto/dev-env": "^0.3.208", "@atproto/dev-env": "^0.3.209",
"@babel/core": "^7.26.0", "@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0", "@babel/runtime": "^7.26.0",
@@ -242,7 +242,6 @@
"@react-native/eslint-config": "^0.81.5", "@react-native/eslint-config": "^0.81.5",
"@react-native/typescript-config": "^0.81.5", "@react-native/typescript-config": "^0.81.5",
"@sentry/webpack-plugin": "^3.2.2", "@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.2.0", "@testing-library/react-native": "^13.2.0",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/lodash.chunk": "^4.2.7", "@types/lodash.chunk": "^4.2.7",
+4 -2
View File
@@ -1,8 +1,8 @@
diff --git a/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift b/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift 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 --- a/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
+++ b/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 maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil) let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
@@ -27,6 +27,8 @@ index c34ba71..13d576a 100644
} }
- self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight)) - self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight))
+ finalHeight = ceil(finalHeight)
+
+ self.frameSize = CGSize(width: CGFloat(maxWidth), height: finalHeight) + self.frameSize = CGSize(width: CGFloat(maxWidth), height: finalHeight)
return YGSize(width: Float(self.frameSize.width), height: Float(self.frameSize.height)) 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 React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler' import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
import { import {
initialWindowMetrics, initialWindowMetrics,
SafeAreaProvider, SafeAreaProvider,
@@ -14,7 +15,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native' import * as Sentry from '@sentry/react-native'
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query' import {QueryProvider} from '#/lib/react-query'
import {s} from '#/lib/styles' import {s} from '#/lib/styles'
@@ -22,6 +22,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider' import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y' 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 MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs' import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification' import {Provider as EmailVerificationProvider} from '#/state/email-verification'
@@ -103,6 +107,7 @@ if (IS_ANDROID) {
Geo.resolve() Geo.resolve()
prefetchAgeAssuranceConfig() prefetchAgeAssuranceConfig()
prefetchLiveEvents() prefetchLiveEvents()
prefetchAppConfig()
function InnerApp() { function InnerApp() {
const [isReady, setIsReady] = React.useState(false) const [isReady, setIsReady] = React.useState(false)
@@ -228,38 +233,40 @@ function App() {
*/ */
return ( return (
<Geo.Provider> <Geo.Provider>
<A11yProvider> <AppConfigProvider>
<KeyboardControllerProvider> <A11yProvider>
<OnboardingProvider> <KeyboardControllerProvider>
<AnalyticsContext> <OnboardingProvider>
<SessionProvider> <AnalyticsContext>
<PrefsStateProvider> <SessionProvider>
<I18nProvider> <PrefsStateProvider>
<ShellStateProvider> <I18nProvider>
<ModalStateProvider> <ShellStateProvider>
<DialogStateProvider> <ModalStateProvider>
<LightboxStateProvider> <DialogStateProvider>
<PortalProvider> <LightboxStateProvider>
<BottomSheetProvider> <PortalProvider>
<StarterPackProvider> <BottomSheetProvider>
<SafeAreaProvider <StarterPackProvider>
initialMetrics={initialWindowMetrics}> <SafeAreaProvider
<InnerApp /> initialMetrics={initialWindowMetrics}>
</SafeAreaProvider> <InnerApp />
</StarterPackProvider> </SafeAreaProvider>
</BottomSheetProvider> </StarterPackProvider>
</PortalProvider> </BottomSheetProvider>
</LightboxStateProvider> </PortalProvider>
</DialogStateProvider> </LightboxStateProvider>
</ModalStateProvider> </DialogStateProvider>
</ShellStateProvider> </ModalStateProvider>
</I18nProvider> </ShellStateProvider>
</PrefsStateProvider> </I18nProvider>
</SessionProvider> </PrefsStateProvider>
</AnalyticsContext> </SessionProvider>
</OnboardingProvider> </AnalyticsContext>
</KeyboardControllerProvider> </OnboardingProvider>
</A11yProvider> </KeyboardControllerProvider>
</A11yProvider>
</AppConfigProvider>
</Geo.Provider> </Geo.Provider>
) )
} }
+93 -87
View File
@@ -2,7 +2,7 @@ import '#/logger/sentry/setup' // must be near top
import '#/view/icons' import '#/view/icons'
import './style.css' import './style.css'
import React, {useEffect, useState} from 'react' import {Fragment, useEffect, useState} from 'react'
import {SafeAreaProvider} from 'react-native-safe-area-context' import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -13,6 +13,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider' import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y' 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 MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs' import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification' import {Provider as EmailVerificationProvider} from '#/state/email-verification'
@@ -79,9 +83,10 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
Geo.resolve() Geo.resolve()
prefetchAgeAssuranceConfig() prefetchAgeAssuranceConfig()
prefetchLiveEvents() prefetchLiveEvents()
prefetchAppConfig()
function InnerApp() { function InnerApp() {
const [isReady, setIsReady] = React.useState(false) const [isReady, setIsReady] = useState(false)
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
const theme = useColorModeTheme() const theme = useColorModeTheme()
@@ -116,68 +121,67 @@ function InnerApp() {
}) })
}, [_]) }, [_])
// wait for session to resume
if (!isReady || !hasCheckedReferrer) return <Splash isReady />
return ( return (
<Alf theme={theme}> <Alf theme={theme}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<ContextMenuProvider> <ContextMenuProvider>
<VideoVolumeProvider> <Splash isReady={isReady && hasCheckedReferrer}>
<ActiveVideoProvider> <VideoVolumeProvider>
<React.Fragment <ActiveVideoProvider>
// Resets the entire tree below when it changes: <Fragment
key={currentAccount?.did}> // Resets the entire tree below when it changes:
<AnalyticsFeaturesContext> key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}> <AnalyticsFeaturesContext>
<PolicyUpdateOverlayProvider> <QueryProvider currentDid={currentAccount?.did}>
<LiveEventsProvider> <PolicyUpdateOverlayProvider>
<AgeAssuranceV2Provider> <LiveEventsProvider>
<ComposerProvider> <AgeAssuranceV2Provider>
<MessagesProvider> <ComposerProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <MessagesProvider>
<LabelDefsProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<ModerationOptsProvider> <LabelDefsProvider>
<LoggedOutViewProvider> <ModerationOptsProvider>
<SelectedFeedProvider> <LoggedOutViewProvider>
<HiddenRepliesProvider> <SelectedFeedProvider>
<HomeBadgeProvider> <HiddenRepliesProvider>
<UnreadNotifsProvider> <HomeBadgeProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<SafeAreaProvider> <MutedThreadsProvider>
<ProgressGuideProvider> <SafeAreaProvider>
<ServiceConfigProvider> <ProgressGuideProvider>
<EmailVerificationProvider> <ServiceConfigProvider>
<HideBottomBarBorderProvider> <EmailVerificationProvider>
<IntentDialogProvider> <HideBottomBarBorderProvider>
<Shell /> <IntentDialogProvider>
<ToastOutlet /> <Shell />
</IntentDialogProvider> <ToastOutlet />
</HideBottomBarBorderProvider> </IntentDialogProvider>
</EmailVerificationProvider> </HideBottomBarBorderProvider>
</ServiceConfigProvider> </EmailVerificationProvider>
</ProgressGuideProvider> </ServiceConfigProvider>
</SafeAreaProvider> </ProgressGuideProvider>
</MutedThreadsProvider> </SafeAreaProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</HomeBadgeProvider> </UnreadNotifsProvider>
</HiddenRepliesProvider> </HomeBadgeProvider>
</SelectedFeedProvider> </HiddenRepliesProvider>
</LoggedOutViewProvider> </SelectedFeedProvider>
</ModerationOptsProvider> </LoggedOutViewProvider>
</LabelDefsProvider> </ModerationOptsProvider>
</MessagesProvider> </LabelDefsProvider>
</ComposerProvider> </MessagesProvider>
</AgeAssuranceV2Provider> </ComposerProvider>
</LiveEventsProvider> </AgeAssuranceV2Provider>
</PolicyUpdateOverlayProvider> </LiveEventsProvider>
</QueryProvider> </PolicyUpdateOverlayProvider>
</AnalyticsFeaturesContext> </QueryProvider>
</React.Fragment> </AnalyticsFeaturesContext>
</ActiveVideoProvider> </Fragment>
</VideoVolumeProvider> </ActiveVideoProvider>
</VideoVolumeProvider>
</Splash>
</ContextMenuProvider> </ContextMenuProvider>
</ThemeProvider> </ThemeProvider>
</Alf> </Alf>
@@ -187,14 +191,14 @@ function InnerApp() {
function App() { function App() {
const [isReady, setReady] = useState(false) const [isReady, setReady] = useState(false)
React.useEffect(() => { useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() => Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true), setReady(true),
) )
}, []) }, [])
if (!isReady) { if (!isReady) {
return <Splash isReady /> return null
} }
/* /*
@@ -203,31 +207,33 @@ function App() {
*/ */
return ( return (
<Geo.Provider> <Geo.Provider>
<A11yProvider> <AppConfigProvider>
<OnboardingProvider> <A11yProvider>
<AnalyticsContext> <OnboardingProvider>
<SessionProvider> <AnalyticsContext>
<PrefsStateProvider> <SessionProvider>
<I18nProvider> <PrefsStateProvider>
<ShellStateProvider> <I18nProvider>
<ModalStateProvider> <ShellStateProvider>
<DialogStateProvider> <ModalStateProvider>
<LightboxStateProvider> <DialogStateProvider>
<PortalProvider> <LightboxStateProvider>
<StarterPackProvider> <PortalProvider>
<InnerApp /> <StarterPackProvider>
</StarterPackProvider> <InnerApp />
</PortalProvider> </StarterPackProvider>
</LightboxStateProvider> </PortalProvider>
</DialogStateProvider> </LightboxStateProvider>
</ModalStateProvider> </DialogStateProvider>
</ShellStateProvider> </ModalStateProvider>
</I18nProvider> </ShellStateProvider>
</PrefsStateProvider> </I18nProvider>
</SessionProvider> </PrefsStateProvider>
</AnalyticsContext> </SessionProvider>
</OnboardingProvider> </AnalyticsContext>
</A11yProvider> </OnboardingProvider>
</A11yProvider>
</AppConfigProvider>
</Geo.Provider> </Geo.Provider>
) )
} }
+82 -14
View File
@@ -4,26 +4,94 @@
* the app is ready to go. * 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 Svg, {Path} from 'react-native-svg'
import {atoms as a} from '#/alf' import {atoms as a, flatten} from '#/alf'
const size = 100 const size = 100
const ratio = 57 / 64 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 ( return (
<View style={[a.fixed, a.inset_0, a.align_center, a.justify_center]}> <>
<Svg {isReady && children}
fill="none"
viewBox="0 0 64 57" {!isAnimationComplete && (
style={[a.relative, {width: size, height: size * ratio, top: -50}]}> <div
<Path ref={splashRef}
fill="#006AFF" style={flatten([
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" a.fixed,
/> a.inset_0,
</Svg> a.flex,
</View> 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`, 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} * {@link Layout.SCROLLBAR_OFFSET}
*/ */
+5
View File
@@ -875,4 +875,9 @@ export type Events = {
'liveEvents:unhideAllFeedBanners': { 'liveEvents:unhideAllFeedBanners': {
context: LiveEventFeedMetricContext 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 {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {isJwtExpired} from '#/lib/jwt' import {isJwtExpired} from '#/lib/jwt'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' 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 {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification' import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck' import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {useActorStatus} from '#/features/liveNow'
export function AccountList({ export function AccountList({
onSelectAccount, onSelectAccount,
+3 -7
View File
@@ -11,6 +11,7 @@ import {
} from 'react-native' } from 'react-native'
import { import {
KeyboardAwareScrollView, KeyboardAwareScrollView,
type KeyboardAwareScrollViewRef,
useKeyboardHandler, useKeyboardHandler,
useReanimatedKeyboardAnimation, useReanimatedKeyboardAnimation,
} from 'react-native-keyboard-controller' } from 'react-native-keyboard-controller'
@@ -23,7 +24,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
import {ScrollProvider} from '#/lib/ScrollContext' import {ScrollProvider} from '#/lib/ScrollContext'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useA11y} from '#/state/a11y' import {useA11y} from '#/state/a11y'
@@ -209,10 +209,9 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
useEnableKeyboardController(IS_IOS)
const [keyboardHeight, setKeyboardHeight] = React.useState(0) const [keyboardHeight, setKeyboardHeight] = React.useState(0)
// note: iOS-only. keyboard-controller doesn't seem to work inside the sheets on Android
useKeyboardHandler( useKeyboardHandler(
{ {
onEnd: e => { onEnd: e => {
@@ -231,7 +230,6 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
} }
paddingBottom = Math.max(paddingBottom, tokens.space._2xl) paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
} else { } else {
paddingBottom += keyboardHeight
if (nativeSnapPoint === BottomSheetSnapPoint.Full) { if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom += insets.top paddingBottom += insets.top
} }
@@ -259,7 +257,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
{paddingBottom}, {paddingBottom},
contentContainerStyle, contentContainerStyle,
]} ]}
ref={ref} ref={ref as React.Ref<KeyboardAwareScrollViewRef>}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined} showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
{...props} {...props}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full} bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
@@ -289,8 +287,6 @@ export const InnerFlatList = React.forwardRef<
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
useEnableKeyboardController(IS_IOS)
const onScroll = (e: ScrollEvent) => { const onScroll = (e: ScrollEvent) => {
'worklet' 'worklet'
if (!IS_ANDROID) { 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 {atoms as a, useTheme} from '#/alf'
import {QuoteEmbed} from '#/components/Post/Embed' 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 t = useTheme()
const {data} = useResolveLinkQuery(uri) const {data} = useResolveLinkQuery(uri)
@@ -21,6 +27,7 @@ export function LazyQuoteEmbed({uri}: {uri: string}) {
type: 'post', type: 'post',
view, view,
}} }}
linkDisabled={linkDisabled}
/> />
) : ( ) : (
<View <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 {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants' import {HITSLOP_20} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Fill} from '#/components/Fill' import {Fill} from '#/components/Fill'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -26,28 +31,27 @@ export function GifPresentationControls({
return ( return (
<> <>
<Pressable <Button
accessibilityRole="button" label={isPlaying ? _(msg`Pause GIF`) : _(msg`Play GIF`)}
accessibilityHint={_(msg`Plays or pauses the GIF`)} accessibilityHint={_(msg`Plays or pauses the GIF`)}
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
style={[ style={[
a.absolute, a.absolute,
a.align_center, a.align_center,
a.justify_center, a.justify_center,
a.inset_0, a.inset_0,
a.w_full,
a.h_full,
{zIndex: 2}, {zIndex: 2},
]} ]}
onPress={onPress}> onPress={onPress}>
{isLoading ? ( {isLoading ? (
<View style={[a.align_center, a.justify_center]}> <View style={[a.align_center, a.justify_center]}>
<Loader size="xl" /> <ActivityIndicator size="large" color="white" />
</View> </View>
) : !isPlaying ? ( ) : !isPlaying ? (
<PlayButtonIcon /> <PlayButtonIcon />
) : undefined} ) : (
</Pressable> <></>
)}
</Button>
{!isPlaying && ( {!isPlaying && (
<Fill <Fill
style={[ style={[
@@ -58,7 +58,7 @@ export function VideoEmbedInnerNative({
<BlueskyVideoView <BlueskyVideoView
url={embed.playlist} url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage} autoplay={!autoplayDisabled && !isWithinMessage}
beginMuted={isGif || autoplayDisabled ? false : muted} beginMuted={isGif || (autoplayDisabled ? false : muted)}
style={[a.rounded_sm]} style={[a.rounded_sm]}
onActiveChange={e => { onActiveChange={e => {
setIsActive(e.nativeEvent.isActive) setIsActive(e.nativeEvent.isActive)
@@ -67,7 +67,9 @@ export function VideoEmbedInnerNative({
setIsLoading(e.nativeEvent.isLoading) setIsLoading(e.nativeEvent.isLoading)
}} }}
onMutedChange={e => { onMutedChange={e => {
setMuted(e.nativeEvent.isMuted) if (!isGif) {
setMuted(e.nativeEvent.isMuted)
}
}} }}
onStatusChange={e => { onStatusChange={e => {
setStatus(e.nativeEvent.status) setStatus(e.nativeEvent.status)
@@ -72,19 +72,7 @@ export function VideoEmbedInnerWeb({
loop={loop} loop={loop}
/> />
{embed.alt && ( {embed.alt && (
<figcaption <figcaption id={figId} style={a.sr_only}>
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,
}}>
{embed.alt} {embed.alt}
</figcaption> </figcaption>
)} )}
+32 -24
View File
@@ -6,11 +6,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 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 {Button} from '#/components/Button'
import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ConstrainedImage} from '#/components/images/AutoSizedImage' import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {GifPresentationControls} from './GifPresentationControls'
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative' import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
import * as VideoFallback from './VideoEmbedInner/VideoFallback' 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, 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 🥴😮‍💨 // 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 cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android
> >
{showOverlay && ( {showOverlay &&
<Button (embed.presentation === 'gif' ? (
style={[a.flex_1, a.align_center, a.justify_center]} <GifPresentationControls
onPress={() => { isPlaying={false}
ref.current?.togglePlayback() isLoading={showSpinner}
}} onPress={() => {
label={_(msg`Play video`)}> ref.current?.togglePlayback()
{showSpinner ? ( }}
<View altText={embed.alt}
style={[ />
a.rounded_full, ) : (
a.p_xs, <Button
a.align_center, style={[a.flex_1, a.align_center, a.justify_center]}
a.justify_center, onPress={() => {
]}> ref.current?.togglePlayback()
<ActivityIndicator size="large" color="white" /> }}
</View> label={_(msg`Play video`)}>
) : ( {showSpinner ? (
<PlayButtonIcon /> <View style={[a.align_center, a.justify_center]}>
)} <ActivityIndicator size="large" color="white" />
</Button> </View>
)} ) : (
<PlayButtonIcon />
)}
</Button>
))}
</ImageBackground> </ImageBackground>
</> </>
) )
+57 -45
View File
@@ -225,11 +225,13 @@ export function QuoteEmbed({
embed, embed,
onOpen, onOpen,
style, style,
linkDisabled,
isWithinQuote: parentIsWithinQuote, isWithinQuote: parentIsWithinQuote,
allowNestedQuotes: parentAllowNestedQuotes, allowNestedQuotes: parentAllowNestedQuotes,
}: Omit<CommonProps, 'viewContext'> & { }: Omit<CommonProps, 'viewContext'> & {
embed: EmbedType<'post'> embed: EmbedType<'post'>
viewContext?: QuoteEmbedViewContext viewContext?: QuoteEmbedViewContext
linkDisabled?: boolean
}) { }) {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const quote = useMemo<$Typed<AppBskyFeedDefs.PostView>>( const quote = useMemo<$Typed<AppBskyFeedDefs.PostView>>(
@@ -280,11 +282,48 @@ export function QuoteEmbed({
onIn: onPressIn, onIn: onPressIn,
onOut: onPressOut, onOut: onPressOut,
} = useInteractionState() } = 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 ( return (
<View <View
style={[a.mt_sm]} style={[a.mt_sm]}
onPointerEnter={onPointerEnter} onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={onPointerLeave}> onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider <ContentHider
modui={moderation?.ui('contentList')} modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]} style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
@@ -292,56 +331,29 @@ export function QuoteEmbed({
childContainerStyle={[a.pt_sm]}> childContainerStyle={[a.pt_sm]}>
{({active}) => ( {({active}) => (
<> <>
{!active && ( {!active && !linkDisabled && (
<SubtleHover <SubtleHover
native native
hover={hover || pressed} hover={hover || pressed}
style={[a.rounded_md]} style={[a.rounded_md]}
/> />
)} )}
<Link {linkDisabled ? (
style={[!active && a.p_md]} <View style={[!active && a.p_md]} pointerEvents="none">
hoverStyle={t.atoms.border_contrast_high} {contents}
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}
/>
</View> </View>
{moderation ? ( ) : (
<PostAlerts <Link
modui={moderation.ui('contentView')} style={[!active && a.p_md]}
style={[a.py_xs]} hoverStyle={t.atoms.border_contrast_high}
/> href={itemHref}
) : null} title={itemTitle}
{richText ? ( onBeforePress={onBeforePress}
<RichText onPressIn={onPressIn}
value={richText} onPressOut={onPressOut}>
style={a.text_md} {contents}
numberOfLines={20} </Link>
disableLinks )}
/>
) : null}
{quote.embed && (
<Embed
embed={quote.embed}
moderation={moderation}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
/>
)}
</Link>
</> </>
)} )}
</ContentHider> </ContentHider>
@@ -13,7 +13,7 @@ import {
AtUri, AtUri,
type RichText as RichTextAPI, type RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/macro' import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
@@ -384,9 +384,8 @@ let PostMenuItems = ({
if (e instanceof MaxHiddenRepliesError) { if (e instanceof MaxHiddenRepliesError) {
Toast.show( Toast.show(
_( _(
msg({ plural(MAX_HIDDEN_REPLIES, {
message: `You can hide a maximum of ${MAX_HIDDEN_REPLIES} replies.`, other: 'You can hide a maximum of # replies.',
context: 'toast',
}), }),
), ),
) )
+1 -1
View File
@@ -14,7 +14,6 @@ import {
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation' import {getModerationCauseKey} from '#/lib/moderation'
import {forceLTR} from '#/lib/strings/bidi' import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants' import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
@@ -47,6 +46,7 @@ import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification' import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck' import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {type Metrics} from '#/analytics' import {type Metrics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export function Default({ export function Default({
@@ -10,7 +10,6 @@ import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation' import {getModerationCauseKey} from '#/lib/moderation'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
@@ -34,7 +33,6 @@ import {
shouldShowKnownFollowers, shouldShowKnownFollowers,
} from '#/components/KnownFollowers' } from '#/components/KnownFollowers'
import {InlineLinkText, Link} from '#/components/Link' import {InlineLinkText, Link} from '#/components/Link'
import {LiveStatus} from '#/components/live/LiveStatusDialog'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Pills from '#/components/Pills' import * as Pills from '#/components/Pills'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
@@ -43,6 +41,8 @@ import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification' import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck' import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {IS_WEB_TOUCH_DEVICE} from '#/env' import {IS_WEB_TOUCH_DEVICE} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {LiveStatus} from '#/features/liveNow/components/LiveStatusDialog'
import {type ProfileHoverCardProps} from './types' import {type ProfileHoverCardProps} from './types'
const floatingMiddlewares = [ 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 {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -11,7 +17,7 @@ import {
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' 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 {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
@@ -51,7 +57,7 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
a.flex_col, a.flex_col,
a.gap_md, a.gap_md,
a.rounded_md, a.rounded_md,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
a.p_lg, a.p_lg,
style, style,
]}> ]}>
@@ -81,8 +87,7 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
a.justify_between, a.justify_between,
a.gap_sm, a.gap_sm,
] ]
: a.flex_col, : [a.flex_col, a.gap_md],
!inlineLayout && a.gap_md,
]}> ]}>
<StackedAvatars follows={follows?.pages?.[0]?.follows} /> <StackedAvatars follows={follows?.pages?.[0]?.follows} />
<FollowDialog guide={guide} showArrow={inlineLayout} /> <FollowDialog guide={guide} showArrow={inlineLayout} />
@@ -112,70 +117,81 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) { function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) {
const t = useTheme() const t = useTheme()
const {centerColumnOffset} = useLayoutBreakpoints() const [containerWidth, setContainerWidth] = useState(0)
// Smaller avatars for narrower viewport const onLayout = (e: LayoutChangeEvent) => {
const avatarSize = centerColumnOffset ? 30 : 37 setContainerWidth(e.nativeEvent.layout.width)
const overlap = centerColumnOffset ? 9 : 11 }
const iconSize = centerColumnOffset ? 14 : 18
// 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 followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? []
const remainingSlots = TOTAL_AVATARS - followedAvatars.length const remainingSlots = TOTAL_AVATARS - followedAvatars.length
// Total width calculation: first avatar + (remaining * visible portion)
const totalWidth = avatarSize + (TOTAL_AVATARS - 1) * (avatarSize - overlap)
return ( return (
<View style={[a.flex_row, a.self_start, {width: totalWidth}]}> <View style={[a.flex_row, a.flex_1]} onLayout={onLayout}>
{/* Show followed user avatars */} {containerWidth > 0 && (
{followedAvatars.map((follow, i) => ( <>
<View {/* Show followed user avatars */}
key={follow.did} {followedAvatars.map((follow, i) => (
style={[ <View
a.rounded_full, key={follow.did}
{ style={[
marginLeft: i === 0 ? 0 : -overlap, a.rounded_full,
zIndex: TOTAL_AVATARS - i, a.border,
borderWidth: 2, t.atoms.border_contrast_low,
borderColor: t.atoms.bg_contrast_25.backgroundColor, {
}, marginLeft: i === 0 ? 0 : -overlap,
]}> zIndex: TOTAL_AVATARS - i,
<UserAvatar },
type="user" ]}>
size={avatarSize - 4} <UserAvatar
avatar={follow.avatar} type="user"
/> size={avatarSize - 2}
</View> avatar={follow.avatar}
))} noBorder
{/* Show placeholder avatars for remaining slots */} />
{Array(remainingSlots) </View>
.fill(0) ))}
.map((_, i) => ( {/* Show placeholder avatars for remaining slots */}
<View {Array(remainingSlots)
key={`placeholder-${i}`} .fill(0)
style={[ .map((_, i) => (
a.align_center, <View
a.justify_center, key={`placeholder-${i}`}
a.rounded_full, style={[
t.atoms.bg_contrast_100, a.align_center,
{ a.justify_center,
width: avatarSize, a.rounded_full,
height: avatarSize, t.atoms.bg_contrast_300,
marginLeft: a.border,
followedAvatars.length === 0 && i === 0 ? 0 : -overlap, t.atoms.border_contrast_low,
zIndex: TOTAL_AVATARS - followedAvatars.length - i, {
borderWidth: 2, width: avatarSize,
borderColor: t.atoms.bg_contrast_25.backgroundColor, height: avatarSize,
}, marginLeft:
]}> followedAvatars.length === 0 && i === 0 ? 0 : -overlap,
<PersonIcon zIndex: TOTAL_AVATARS - followedAvatars.length - i,
width={iconSize} },
height={iconSize} ]}>
fill={t.atoms.text_contrast_low.color} <PersonIcon
/> width={iconSize}
</View> height={iconSize}
))} fill={t.atoms.bg_contrast_50.backgroundColor}
/>
</View>
))}
</>
)}
</View> </View>
) )
} }
+30 -5
View File
@@ -4,8 +4,14 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {atoms as a, useTheme, type ViewStyleProp, web} from '#/alf' 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 * as Dialog from '#/components/Dialog'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {type BottomSheetViewProps} from '../../modules/bottom-sheet' import {type BottomSheetViewProps} from '../../modules/bottom-sheet'
@@ -126,7 +132,7 @@ export function Cancel({
<Button <Button
variant="solid" variant="solid"
color="secondary" color="secondary"
size={'large'} size="large"
label={cta || _(msg`Cancel`)} label={cta || _(msg`Cancel`)}
onPress={onPress}> onPress={onPress}>
<ButtonText>{cta || _(msg`Cancel`)}</ButtonText> <ButtonText>{cta || _(msg`Cancel`)}</ButtonText>
@@ -138,6 +144,9 @@ export function Action({
onPress, onPress,
color = 'primary', color = 'primary',
cta, cta,
disabled = false,
icon,
shouldCloseOnPress,
testID, testID,
}: { }: {
/** /**
@@ -153,25 +162,41 @@ export function Action({
* Optional i18n string. If undefined, it will default to "Confirm". * Optional i18n string. If undefined, it will default to "Confirm".
*/ */
cta?: string 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 testID?: string
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const {close} = Dialog.useDialogContext() const {close} = Dialog.useDialogContext()
const handleOnPress = useCallback( const handleOnPress = useCallback(
(e: GestureResponderEvent) => { (e: GestureResponderEvent) => {
close(() => onPress?.(e)) if (shouldCloseOnPress) {
close(() => onPress?.(e))
} else {
onPress?.(e)
}
}, },
[close, onPress], [close, onPress, shouldCloseOnPress],
) )
return ( return (
<Button <Button
color={color} color={color}
size={'large'} disabled={disabled}
size="large"
label={cta || _(msg`Confirm`)} label={cta || _(msg`Confirm`)}
onPress={handleOnPress} onPress={handleOnPress}
testID={testID}> testID={testID}>
<ButtonText>{cta || _(msg`Confirm`)}</ButtonText> <ButtonText>{cta || _(msg`Confirm`)}</ButtonText>
{icon && <ButtonIcon icon={icon} />}
</Button> </Button>
) )
} }
@@ -56,6 +56,7 @@ function Inner({style}: ViewStyleProp & {}) {
const {status, lastInitiatedAt} = aa.state const {status, lastInitiatedAt} = aa.state
const isBlocked = status === aa.Status.Blocked const isBlocked = status === aa.Status.Blocked
const hasInitiated = !!lastInitiatedAt const hasInitiated = !!lastInitiatedAt
const hasCompletedFlow = status === aa.Status.Assured
const timeAgo = lastInitiatedAt const timeAgo = lastInitiatedAt
? getTimeAgo(lastInitiatedAt, new Date()) ? getTimeAgo(lastInitiatedAt, new Date())
: null : null
@@ -85,8 +86,25 @@ function Inner({style}: ViewStyleProp & {}) {
</View> </View>
</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> <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 && ( {IS_NATIVE && (
<> <>
@@ -59,7 +59,7 @@ export function AgeRestrictedScreen({
<View style={[a.gap_sm, a.pb_lg]}> <View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_xl, a.leading_snug, a.font_bold]}> <Text style={[a.text_xl, a.leading_snug, a.font_bold]}>
<Trans> <Trans>
You must complete age assurance in order to access this screen. We're sorry, you cannot access this screen at this time.
</Trans> </Trans>
</Text> </Text>
@@ -2,20 +2,29 @@ import {useMemo} from 'react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/ageAssurance'
export function useAgeAssuranceCopy() { export function useAgeAssuranceCopy() {
const {_} = useLingui() const {_} = useLingui()
const aa = useAgeAssurance()
const hasCompletedFlow = aa.state.status === aa.Status.Assured
return useMemo(() => { return useMemo(() => {
return { return {
notice: _( notice: hasCompletedFlow
msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`, ? _(
), 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: _( 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: _( chatsInfoText: _(
msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`, 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 ? ( ) : isUsingAppPassword ? (
<Admonition type="info"> <Admonition type="info">
<Trans> <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 <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. password, or ask whomever controls this account to do so.
</Trans> </Trans>
</Admonition> </Admonition>
@@ -32,8 +32,11 @@ export function TokenField({
<TextField.Root> <TextField.Root>
<TextField.Icon icon={Shield} /> <TextField.Icon icon={Shield} />
<TextField.Input <TextField.Input
autoComplete="off"
autoCorrect={false}
isInvalid={isInvalid} isInvalid={isInvalid}
label={_(msg`Confirmation code`)} label={_(msg`Confirmation code`)}
maxLength={11}
placeholder="XXXXX-XXXXX" placeholder="XXXXX-XXXXX"
value={value} value={value}
onChangeText={handleOnChangeText} onChangeText={handleOnChangeText}
+9 -14
View File
@@ -9,7 +9,8 @@ import {
externalEmbedLabels, externalEmbedLabels,
} from '#/lib/strings/embed-player' } from '#/lib/strings/embed-player'
import {useSetExternalEmbedPref} from '#/state/preferences' 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 {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -24,9 +25,7 @@ export function EmbedConsentDialog({
onAccept: () => void onAccept: () => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme()
const setExternalEmbedPref = useSetExternalEmbedPref() const setExternalEmbedPref = useSetExternalEmbedPref()
const {gtMobile} = useBreakpoints()
const onShowAllPress = useCallback(() => { const onShowAllPress = useCallback(() => {
for (const key of embedPlayerSources) { for (const key of embedPlayerSources) {
@@ -52,50 +51,46 @@ export function EmbedConsentDialog({
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={_(msg`External Media`)} label={_(msg`External Media`)}
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}> style={web({maxWidth: 400})}>
<View style={a.gap_sm}> <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> <Trans>External Media</Trans>
</Text> </Text>
<View style={[a.mt_sm, a.mb_2xl, a.gap_lg]}> <View style={[a.mt_sm, a.mb_2xl, a.gap_lg]}>
<Text> <Text style={[a.text_md, a.leading_snug]}>
<Trans> <Trans>
This content is hosted by {externalEmbedLabels[source]}. Do you This content is hosted by {externalEmbedLabels[source]}. Do you
want to enable external media? want to enable external media?
</Trans> </Trans>
</Text> </Text>
<Text style={t.atoms.text_contrast_medium}> <Admonition type="info">
<Trans> <Trans>
External media may allow websites to collect information about External media may allow websites to collect information about
you and your device. No information is sent or requested until you and your device. No information is sent or requested until
you press the "play" button. you press the "play" button.
</Trans> </Trans>
</Text> </Admonition>
</View> </View>
</View> </View>
<View style={a.gap_md}> <View style={a.gap_md}>
<Button <Button
style={gtMobile && a.flex_1}
label={_(msg`Enable external media`)} label={_(msg`Enable external media`)}
onPress={onShowAllPress} onPress={onShowAllPress}
onAccessibilityEscape={control.close} onAccessibilityEscape={control.close}
color="primary" color="primary"
size="large" size="large">
variant="solid">
<ButtonText> <ButtonText>
<Trans>Enable external media</Trans> <Trans>Enable external media</Trans>
</ButtonText> </ButtonText>
</Button> </Button>
<Button <Button
style={gtMobile && a.flex_1}
label={_(msg`Enable this source only`)} label={_(msg`Enable this source only`)}
onPress={onShowPress} onPress={onShowPress}
onAccessibilityEscape={control.close} onAccessibilityEscape={control.close}
color="secondary" color="secondary"
size="large" size="large">
variant="solid">
<ButtonText> <ButtonText>
<Trans>Enable {externalEmbedLabels[source]} only</Trans> <Trans>Enable {externalEmbedLabels[source]} only</Trans>
</ButtonText> </ButtonText>
+17 -2
View File
@@ -22,12 +22,27 @@ export function LinkWarningDialog() {
webOptions={{alignCenter: true}} webOptions={{alignCenter: true}}
onClose={linkWarningDialogControl.clear}> onClose={linkWarningDialogControl.clear}>
<Dialog.Handle /> <Dialog.Handle />
<InAppBrowserConsentInner link={linkWarningDialogControl.value} /> <LinkWarningDialogInner link={linkWarningDialogControl.value} />
</Dialog.Outer> </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,
}: { }: {
link?: {href: string; displayText: string; share?: boolean} link?: {href: string; displayText: string; share?: boolean}
+107 -26
View File
@@ -8,6 +8,7 @@ import {logger} from '#/logger'
import { import {
usePreferencesQuery, usePreferencesQuery,
useRemoveMutedWordMutation, useRemoveMutedWordMutation,
useUpdateMutedWordMutation,
useUpsertMutedWordsMutation, useUpsertMutedWordsMutation,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import { 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 {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
@@ -421,6 +423,7 @@ function MutedWordRow({
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
const {mutateAsync: updateMutedWord} = useUpdateMutedWordMutation()
const control = Prompt.usePromptControl() const control = Prompt.usePromptControl()
const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
const isExpired = expiryDate && expiryDate < new Date() const isExpired = expiryDate && expiryDate < new Date()
@@ -431,6 +434,15 @@ function MutedWordRow({
removeMutedWord(word) removeMutedWord(word)
}, [removeMutedWord, word, control]) }, [removeMutedWord, word, control])
const renew = (days?: number) => {
updateMutedWord({
...word,
expiresAt: days
? new Date(Date.now() + days * ONE_DAY).toISOString()
: undefined,
})
}
return ( return (
<> <>
<Prompt.Basic <Prompt.Basic
@@ -493,35 +505,104 @@ function MutedWordRow({
</View> </View>
{(expiryDate || word.actorTarget === 'exclude-following') && ( {(expiryDate || word.actorTarget === 'exclude-following') && (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> <View style={[a.flex_1, a.flex_row, a.align_center, a.flex_wrap]}>
<Text {expiryDate &&
style={[ (isExpired ? (
a.flex_1,
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{expiryDate && (
<> <>
{isExpired ? ( <Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>Expired</Trans> <Trans>Expired</Trans>
) : ( </Text>
<Trans> <Text
Expires{' '} style={[
{formatDistance(expiryDate, new Date(), { a.text_xs,
addSuffix: true, a.leading_snug,
})} t.atoms.text_contrast_medium,
</Trans> ]}>
)} {' · '}
</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' && ( <Text
<> style={[
{' • '} a.text_xs,
<Trans>Excludes users you follow</Trans> a.leading_snug,
</> t.atoms.text_contrast_medium,
)} ]}>
</Text> <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>
)} )}
</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 {View} from 'react-native'
import { import {
type AppBskyGraphGetStarterPacksWithMembership, type AppBskyGraphGetStarterPacksWithMembership,
@@ -7,20 +7,18 @@ import {
import {msg, Plural, Trans} from '@lingui/macro' import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import { import {isNetworkError} from '#/lib/strings/errors'
invalidateActorStarterPacksWithMembershipQuery, import {logger} from '#/logger'
useActorStarterPacksWithMembershipsQuery, import {useActorStarterPacksWithMembershipsQuery} from '#/state/queries/actor-starter-packs'
} from '#/state/queries/actor-starter-packs'
import { import {
useListMembershipAddMutation, useListMembershipAddMutation,
useListMembershipRemoveMutation, useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships' } from '#/state/queries/list-memberships'
import * as Toast from '#/view/com/util/Toast' import {useProfileQuery} from '#/state/queries/profile'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, native, platform, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack' import {AvatarStack} from '#/components/AvatarStack'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' 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 {StarterPack} from '#/components/icons/StarterPack'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
@@ -131,6 +130,7 @@ function StarterPackList({
}) { }) {
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const {_} = useLingui() const {_} = useLingui()
const {data: subject} = useProfileQuery({did: targetDid})
const { const {
data, data,
@@ -155,9 +155,13 @@ function StarterPackList({
const renderItem = useCallback( const renderItem = useCallback(
({item}: {item: StarterPackWithMembership}) => ( ({item}: {item: StarterPackWithMembership}) => (
<StarterPackItem starterPackWithMembership={item} targetDid={targetDid} /> <StarterPackItem
starterPackWithMembership={item}
targetDid={targetDid}
subject={subject}
/>
), ),
[targetDid], [targetDid, subject],
) )
const onClose = useCallback(() => { const onClose = useCallback(() => {
@@ -168,9 +172,11 @@ function StarterPackList({
<> <>
<View <View
style={[ style={[
{justifyContent: 'space-between', flexDirection: 'row'}, a.justify_between,
IS_WEB ? a.mb_2xl : a.my_lg,
a.align_center, a.align_center,
a.flex_row,
a.pb_lg,
native(a.pt_lg),
]}> ]}>
<Text style={[a.text_lg, a.font_semi_bold]}> <Text style={[a.text_lg, a.font_semi_bold]}>
<Trans>Add to starter packs</Trans> <Trans>Add to starter packs</Trans>
@@ -181,7 +187,8 @@ function StarterPackList({
variant="ghost" variant="ghost"
color="secondary" color="secondary"
size="small" size="small"
shape="round"> shape="round"
style={{margin: -8}}>
<ButtonIcon icon={XIcon} /> <ButtonIcon icon={XIcon} />
</Button> </Button>
</View> </View>
@@ -232,7 +239,10 @@ function StarterPackList({
onEndReachedThreshold={0.1} onEndReachedThreshold={0.1}
ListHeaderComponent={listHeader} ListHeaderComponent={listHeader}
ListEmptyComponent={<Empty onStartWizard={onStartWizard} />} 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({ function StarterPackItem({
starterPackWithMembership, starterPackWithMembership,
targetDid, targetDid,
subject,
}: { }: {
starterPackWithMembership: StarterPackWithMembership starterPackWithMembership: StarterPackWithMembership
targetDid: string targetDid: string
subject?: bsky.profile.AnyProfileView
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const queryClient = useQueryClient()
const starterPack = starterPackWithMembership.starterPack const starterPack = starterPackWithMembership.starterPack
const isInPack = !!starterPackWithMembership.listItem 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({ const {mutate: removeMembership, isPending: isPendingRemove} =
onSuccess: () => { useListMembershipRemoveMutation({
Toast.show(_(msg`Added to starter pack`)) onSuccess: () => {
// Use a timeout to wait for the appview to update, matching the pattern Toast.show(_(msg`Removed from starter pack`))
// in list-memberships.ts },
setTimeout(() => { onError: err => {
invalidateActorStarterPacksWithMembershipQuery({ if (!isNetworkError(err)) {
queryClient, logger.error('Failed to remove from starter pack', {safeMessage: err})
did: targetDid, }
}) Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'})
setIsPendingRefresh(false) },
}, 1e3) })
},
onError: () => {
Toast.show(_(msg`Failed to add to starter pack`), 'xmark')
setIsPendingRefresh(false)
},
})
const {mutate: removeMembership} = useListMembershipRemoveMutation({ const isPending = isPendingAdd || isPendingRemove
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 handleToggleMembership = () => { const handleToggleMembership = () => {
if (!starterPack.list?.uri || isPendingRefresh) return if (!starterPack.list?.uri || isPending) return
const listUri = starterPack.list.uri const listUri = starterPack.list.uri
const starterPackUri = starterPack.uri const starterPackUri = starterPack.uri
setIsPendingRefresh(true)
if (!isInPack) { if (!isInPack) {
addMembership({ addMembership({
listUri: listUri, listUri: listUri,
@@ -309,7 +307,6 @@ function StarterPackItem({
} else { } else {
if (!starterPackWithMembership.listItem?.uri) { if (!starterPackWithMembership.listItem?.uri) {
console.error('Cannot remove: missing membership URI') console.error('Cannot remove: missing membership URI')
setIsPendingRefresh(false)
return return
} }
removeMembership({ removeMembership({
@@ -344,7 +341,7 @@ function StarterPackItem({
starterPack.listItemsSample.length > 0 && ( starterPack.listItemsSample.length > 0 && (
<> <>
<AvatarStack <AvatarStack
size={32} size={24}
profiles={starterPack.listItemsSample profiles={starterPack.listItemsSample
?.slice(0, 4) ?.slice(0, 4)
.map(p => p.subject)} .map(p => p.subject)}
@@ -375,8 +372,9 @@ function StarterPackItem({
label={isInPack ? _(msg`Remove`) : _(msg`Add`)} label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
color={isInPack ? 'secondary' : 'primary_subtle'} color={isInPack ? 'secondary' : 'primary_subtle'}
size="tiny" size="tiny"
disabled={isPendingRefresh} disabled={isPending}
onPress={handleToggleMembership}> onPress={handleToggleMembership}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText> <ButtonText>
{isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>} {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText> </ButtonText>
+2 -2
View File
@@ -43,10 +43,10 @@ export function SwitchAccountDialog({
return ( return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}> <Dialog.ScrollableInner label={_(msg`Switch account`)}>
<View style={[a.gap_lg]}> <View style={[a.gap_lg]}>
<Text style={[a.text_2xl, a.font_semi_bold]}> <Text style={[a.text_2xl, a.font_semi_bold]}>
<Trans>Switch Account</Trans> <Trans>Switch account</Trans>
</Text> </Text>
<AccountList <AccountList
@@ -95,7 +95,7 @@ export function DraftsAnnouncement() {
]} ]}
alt={_( alt={_(
msg({ 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: 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.', '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({ 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', 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 verificationState: FullVerificationState
}) { }) {
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle /> <Dialog.Handle />
<Inner <Inner
control={control} control={control}
@@ -123,7 +123,6 @@ function Inner({
}), }),
)} )}
size="small" size="small"
variant="solid"
color="primary" color="primary"
style={[a.justify_center]} style={[a.justify_center]}
onPress={() => { onPress={() => {
@@ -138,7 +137,6 @@ function Inner({
<Button <Button
label={_(msg`Close dialog`)} label={_(msg`Close dialog`)}
size="small" size="small"
variant="solid"
color="secondary" color="secondary"
onPress={() => { onPress={() => {
control.close() 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 export const LIVE_EVENTS_URL = IS_DEV
? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL) ? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_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 {useLingui} from '@lingui/react'
import {differenceInMinutes} from 'date-fns' import {differenceInMinutes} from 'date-fns'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {definitelyUrl} from '#/lib/strings/url-helpers' import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useTickEveryMinute} from '#/state/shell' 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 {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {LinkPreview} from './LinkPreview'
import { import {
displayDuration,
useLiveLinkMetaQuery, useLiveLinkMetaQuery,
useRemoveLiveStatusMutation, useRemoveLiveStatusMutation,
useUpsertLiveStatusMutation, useUpsertLiveStatusMutation,
} from './queries' } from '#/features/liveNow'
import {displayDuration, useDebouncedValue} from './utils' import {LinkPreview} from '#/features/liveNow/components/LinkPreview'
export function EditLiveDialog({ export function EditLiveDialog({
control, control,
@@ -3,28 +3,29 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {definitelyUrl} from '#/lib/strings/url-helpers' import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell' import {useTickEveryMinute} from '#/state/shell'
import {atoms as a, ios, native, platform, useTheme, web} from '#/alf' import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {
displayDuration,
getLiveServiceNames,
useDebouncedValue,
} from '#/components/live/utils'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import * as Select from '#/components/Select' import * as Select from '#/components/Select'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {
displayDuration,
getLiveServiceNames,
useLiveLinkMetaQuery,
useLiveNowConfig,
useUpsertLiveStatusMutation,
} from '#/features/liveNow'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {LinkPreview} from './LinkPreview' import {LinkPreview} from './LinkPreview'
import {useLiveLinkMetaQuery, useUpsertLiveStatusMutation} from './queries'
export function GoLiveDialog({ export function GoLiveDialog({
control, control,
@@ -17,15 +17,15 @@ import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 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 {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator'
import type * as bsky from '#/types/bsky' 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({ export function LiveStatusDialog({
control, control,
@@ -1,24 +1,166 @@
import {useMemo} from 'react'
import { import {
type $Typed, type $Typed,
type AppBskyActorDefs,
type AppBskyActorStatus, type AppBskyActorStatus,
type AppBskyEmbedExternal, AppBskyEmbedExternal,
AtUri,
ComAtprotoRepoPutRecord, ComAtprotoRepoPutRecord,
} from '@atproto/api' } from '@atproto/api'
import {retry} from '@atproto/common-web' import {retry} from '@atproto/common-web'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {isAfter, parseISO} from 'date-fns'
import {uploadBlob} from '#/lib/api' import {uploadBlob} from '#/lib/api'
import {imageToThumb} from '#/lib/api/resolve' import {imageToThumb} from '#/lib/api/resolve'
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {updateProfileShadow} from '#/state/cache/profile-shadow' import {useAppConfig} from '#/state/appConfig'
import {useLiveNowConfig} from '#/state/service-config' import {
updateProfileShadow,
useMaybeProfileShadow,
} from '#/state/cache/profile-shadow'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {useTickEveryMinute} from '#/state/shell'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {useDialogContext} from '#/components/Dialog' import {useDialogContext} from '#/components/Dialog'
import {getLiveServiceNames} from '#/components/live/utils'
import {useAnalytics} from '#/analytics' 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) { export function useLiveLinkMetaQuery(url: string | null) {
const liveNowConfig = useLiveNowConfig() const liveNowConfig = useLiveNowConfig()
@@ -30,8 +172,8 @@ export function useLiveLinkMetaQuery(url: string | null) {
queryKey: ['link-meta', url], queryKey: ['link-meta', url],
queryFn: async () => { queryFn: async () => {
if (!url) return undefined if (!url) return undefined
const urlp = new URL(url) const host = getLiveNowHost(url)
if (!liveNowConfig.currentAccountAllowedHosts.has(urlp.hostname)) { if (!liveNowConfig.currentAccountAllowedHosts.has(host)) {
const {formatted} = getLiveServiceNames( const {formatted} = getLiveServiceNames(
liveNowConfig.currentAccountAllowedHosts, liveNowConfig.currentAccountAllowedHosts,
) )
@@ -1,6 +1,6 @@
import {useEffect, useState} from 'react'
import {type I18n} from '@lingui/core' import {type I18n} from '@lingui/core'
import {plural} from '@lingui/macro' import {plural} from '@lingui/macro'
import psl from 'psl'
export function displayDuration(i18n: I18n, durationInMinutes: number) { export function displayDuration(i18n: I18n, durationInMinutes: number) {
const roundedDurationInMinutes = Math.round(durationInMinutes) const roundedDurationInMinutes = Math.round(durationInMinutes)
@@ -24,41 +24,49 @@ export function displayDuration(i18n: I18n, durationInMinutes: number) {
: minutesString : 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> = { const serviceUrlToNameMap: Record<string, string> = {
'twitch.tv': 'Twitch', 'twitch.tv': 'Twitch',
'www.twitch.tv': 'Twitch',
'youtube.com': 'YouTube', 'youtube.com': 'YouTube',
'www.youtube.com': 'YouTube',
'youtu.be': 'YouTube',
'nba.com': 'NBA', 'nba.com': 'NBA',
'www.nba.com': 'NBA',
'nba.smart.link': 'nba.smart.link', 'nba.smart.link': 'nba.smart.link',
'espn.com': 'ESPN', 'espn.com': 'ESPN',
'www.espn.com': 'ESPN',
'stream.place': 'Streamplace', 'stream.place': 'Streamplace',
'skylight.social': 'Skylight', 'skylight.social': 'Skylight',
'bluecast.app': 'Bluecast', 'bluecast.app': 'Bluecast',
'www.bluecast.app': 'Bluecast',
} }
export function getLiveServiceNames(domains: Set<string>) { export function getLiveServiceNames(domains: Set<string>) {
const names = Array.from( 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 { return {
names, names,
formatted: names.join(', '), 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>( export async function networkRetry<P>(
retries: number, retries: number,
fn: () => Promise<P>, fn: () => Promise<P>,
delay?: number,
): Promise<P> { ): 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_GRAPHEME_LENGTH = 300
export const MAX_DRAFT_GRAPHEME_LENGTH = 1000
export const MAX_DM_GRAPHEME_LENGTH = 1000 export const MAX_DM_GRAPHEME_LENGTH = 1000
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html // 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', 'giphy',
'tenor', 'tenor',
'flickr', 'flickr',
'bandcamp',
] as const ] as const
export type EmbedPlayerSource = (typeof embedPlayerSources)[number] export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
@@ -44,6 +45,8 @@ export type EmbedPlayerType =
| 'giphy_gif' | 'giphy_gif'
| 'tenor_gif' | 'tenor_gif'
| 'flickr_album' | 'flickr_album'
| 'bandcamp_album'
| 'bandcamp_track'
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = { export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
youtube: 'YouTube', youtube: 'YouTube',
@@ -56,6 +59,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
appleMusic: 'Apple Music', appleMusic: 'Apple Music',
soundcloud: 'SoundCloud', soundcloud: 'SoundCloud',
flickr: 'Flickr', flickr: 'Flickr',
bandcamp: 'Bandcamp',
} }
export interface EmbedPlayerParams { export interface EmbedPlayerParams {
@@ -459,6 +463,32 @@ export function parseEmbedPlayerFromUrl(
return undefined 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({ export function getPlayerAspect({
@@ -498,6 +528,9 @@ export function getPlayerAspect({
return {height: 165} return {height: 165}
case 'apple_music_song': case 'apple_music_song':
return {height: 150} return {height: 150}
case 'bandcamp_album':
case 'bandcamp_track':
return {aspectRatio: 1}
default: default:
return {aspectRatio: 16 / 9} 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')) { 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.` 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: ')) { if (str.startsWith('Error: ')) {
return str.slice('Error: '.length) return str.slice('Error: '.length)
} }
@@ -42,6 +54,7 @@ const NETWORK_ERRORS = [
'Failed to fetch', 'Failed to fetch',
'Load failed', 'Load failed',
'Upstream service unreachable', 'Upstream service unreachable',
'NetworkError when attempting to fetch resource',
] ]
export function isNetworkError(e: unknown) { 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 {useLingui} from '@lingui/react'
import {usePreventRemove} from '@react-navigation/native' import {usePreventRemove} from '@react-navigation/native'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import { import {
type AllNavigatorParams, type AllNavigatorParams,
type NativeStackScreenProps, type NativeStackScreenProps,
@@ -37,8 +36,6 @@ export function FindContactsFlowScreen({navigation}: Props) {
}) })
}) })
useEnableKeyboardControllerScreen(true)
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const effect = useCallback(() => { const effect = useCallback(() => {
setMinimalShellMode(true) 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 {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -12,6 +12,7 @@ import {AccountList} from '#/components/AccountList'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
export const ChooseAccountForm = ({ export const ChooseAccountForm = ({
@@ -21,14 +22,14 @@ export const ChooseAccountForm = ({
onSelectAccount: (account?: SessionAccount) => void onSelectAccount: (account?: SessionAccount) => void
onPressBack: () => void onPressBack: () => void
}) => { }) => {
const [pendingDid, setPendingDid] = React.useState<string | null>(null) const [pendingDid, setPendingDid] = useState<string | null>(null)
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const onSelect = React.useCallback( const onSelect = useCallback(
async (account: SessionAccount) => { async (account: SessionAccount) => {
if (pendingDid) { if (pendingDid) {
// The session API isn't resilient to race conditions so let's just ignore this. // 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}`)) Toast.show(_(msg`Signed in as @${account.handle}`))
} catch (e: any) { } catch (e: any) {
logger.error('choose account: initSession failed', { logger.error('choose account: initSession failed', {
message: e.message, message: e instanceof Error ? e.message : 'Unknown error',
}) })
// Move to login form. // Move to login form.
onSelectAccount(account) onSelectAccount(account)
@@ -69,6 +70,7 @@ export const ChooseAccountForm = ({
onSelectAccount, onSelectAccount,
setShowLoggedOut, setShowLoggedOut,
_, _,
ax,
], ],
) )
@@ -78,26 +80,29 @@ export const ChooseAccountForm = ({
titleText={<Trans>Select account</Trans>} titleText={<Trans>Select account</Trans>}
style={web([a.py_2xl])}> style={web([a.py_2xl])}>
<View> <View>
<TextField.LabelText> {IS_WEB && (
<Trans>Sign in as...</Trans> <TextField.LabelText>
</TextField.LabelText> <Trans>Sign in as...</Trans>
</TextField.LabelText>
)}
<AccountList <AccountList
onSelectAccount={onSelect} onSelectAccount={onSelect}
onSelectOther={() => onSelectAccount()} onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid} pendingDid={pendingDid}
/> />
</View> </View>
<View style={[a.flex_row]}> {IS_WEB && (
<Button <View style={[a.flex_row]}>
label={_(msg`Back`)} <Button
variant="solid" label={_(msg`Back`)}
color="secondary" color="secondary"
size="large" size="large"
onPress={onPressBack}> onPress={onPressBack}>
<ButtonText>{_(msg`Back`)}</ButtonText> <ButtonText>{_(msg`Back`)}</ButtonText>
</Button> </Button>
<View style={[a.flex_1]} /> <View style={[a.flex_1]} />
</View> </View>
)}
</FormContainer> </FormContainer>
) )
} }
+36 -28
View File
@@ -1,21 +1,22 @@
import React, {useState} from 'react' 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 {type ComAtprotoServerDescribeServer} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import {isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Agent} from '#/state/session/agent' import {Agent} from '#/state/session/agent'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError' import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider' import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -118,43 +119,50 @@ export const ForgotPasswordForm = ({
<FormError error={error} /> <FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}> <View style={[web([a.flex_row, a.align_center]), a.pt_md]}>
<Button {IS_WEB && (
label={_(msg`Back`)} <>
variant="solid" <Button
color="secondary" label={_(msg`Back`)}
size="large" color="secondary"
onPress={onPressBack}> size="large"
<ButtonText> onPress={onPressBack}>
<Trans>Back</Trans> <ButtonText>
</ButtonText> <Trans>Back</Trans>
</Button> </ButtonText>
<View style={a.flex_1} /> </Button>
{!serviceDescription || isProcessing ? ( <View style={a.flex_1} />
<ActivityIndicator /> </>
)}
{!serviceDescription ? (
<Button
label={_(msg`Connecting to service...`)}
size="large"
color="secondary"
disabled>
<ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText>
</Button>
) : ( ) : (
<Button <Button
label={_(msg`Next`)} label={_(msg`Next`)}
variant="solid" accessibilityHint={_(msg`Navigates to the next screen`)}
color={'primary'} color="primary"
size="large" size="large"
onPress={onPressNext}> onPress={onPressNext}
disabled={isProcessing}>
<ButtonText> <ButtonText>
<Trans>Next</Trans> <Trans>Next</Trans>
</ButtonText> </ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button> </Button>
)} )}
{!serviceDescription || isProcessing ? (
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Processing...</Trans>
</Text>
) : undefined}
</View> </View>
<View <View
style={[ style={[
t.atoms.border_contrast_medium, t.atoms.border_contrast_medium,
a.border_t, a.border_t,
a.pt_2xl, a.pt_xl,
a.mt_md, a.mt_md,
a.flex_row, a.flex_row,
a.justify_center, a.justify_center,
+5 -6
View File
@@ -1,7 +1,7 @@
import {type StyleProp, View, type ViewStyle} from 'react-native' import {type StyleProp, View, type ViewStyle} from 'react-native'
import type React from 'react' 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' import {Text} from '#/components/Typography'
export function FormContainer({ export function FormContainer({
@@ -16,15 +16,14 @@ export function FormContainer({
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const t = useTheme() const gutter = useGutters([0, 'wide'])
return ( return (
<View <View
testID={testID} 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 && ( {titleText && !gtMobile && (
<Text style={[a.text_xl, a.font_semi_bold, t.atoms.text_contrast_high]}> <Text style={[a.text_3xl, a.font_bold]}>{titleText}</Text>
{titleText}
</Text>
)} )}
{children} {children}
</View> </View>
+47 -45
View File
@@ -1,11 +1,5 @@
import React, {useRef, useState} from 'react' import {useCallback, useRef, useState} from 'react'
import { import {Keyboard, type TextInput, View} from 'react-native'
ActivityIndicator,
Keyboard,
LayoutAnimation,
type TextInput,
View,
} from 'react-native'
import { import {
ComAtprotoServerCreateSession, ComAtprotoServerCreateSession,
type ComAtprotoServerDescribeServer, type ComAtprotoServerDescribeServer,
@@ -14,14 +8,13 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles' import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {useSessionApi} from '#/state/session' import {useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' 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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError' import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider' 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 {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env' import {IS_IOS, IS_WEB} from '#/env'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -62,9 +55,11 @@ export const LoginForm = ({
onAttemptFailed: () => void onAttemptFailed: () => void
}) => { }) => {
const t = useTheme() const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false) const [isProcessing, setIsProcessing] = useState(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = const [errorField, setErrorField] = useState<
useState<boolean>(false) 'none' | 'identifier' | 'password' | '2fa'
>('none')
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
const identifierValueRef = useRef<string>(initialHandle || '') const identifierValueRef = useRef<string>(initialHandle || '')
const passwordValueRef = useRef<string>('') const passwordValueRef = useRef<string>('')
const [authFactorToken, setAuthFactorToken] = useState('') const [authFactorToken, setAuthFactorToken] = useState('')
@@ -77,26 +72,28 @@ export const LoginForm = ({
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const onPressSelectService = React.useCallback(() => { const onPressSelectService = useCallback(() => {
Keyboard.dismiss() Keyboard.dismiss()
}, []) }, [])
const onPressNext = async () => { const onPressNext = async () => {
if (isProcessing) return if (isProcessing) return
Keyboard.dismiss() Keyboard.dismiss()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setError('') setError('')
setErrorField('none')
const identifier = identifierValueRef.current.toLowerCase().trim() const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current const password = passwordValueRef.current
if (!identifier) { if (!identifier) {
setError(_(msg`Please enter your username`)) setError(_(msg`Please enter your username`))
setErrorField('identifier')
return return
} }
if (!password) { if (!password) {
setError(_(msg`Please enter your password`)) setError(_(msg`Please enter your password`))
setErrorField('password')
return return
} }
@@ -141,7 +138,6 @@ export const LoginForm = ({
requestNotificationsPermission('Login') requestNotificationsPermission('Login')
} catch (e: any) { } catch (e: any) {
const errMsg = e.toString() const errMsg = e.toString()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setIsProcessing(false) setIsProcessing(false)
if ( if (
e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
@@ -154,6 +150,7 @@ export const LoginForm = ({
error: errMsg, error: errMsg,
}) })
setError(_(msg`Invalid 2FA confirmation code.`)) setError(_(msg`Invalid 2FA confirmation code.`))
setErrorField('2fa')
} else if ( } else if (
errMsg.includes('Authentication Required') || errMsg.includes('Authentication Required') ||
errMsg.includes('Invalid identifier or password') errMsg.includes('Invalid identifier or password')
@@ -194,7 +191,7 @@ export const LoginForm = ({
<Trans>Account</Trans> <Trans>Account</Trans>
</TextField.LabelText> </TextField.LabelText>
<View style={[a.gap_sm]}> <View style={[a.gap_sm]}>
<TextField.Root> <TextField.Root isInvalid={errorField === 'identifier'}>
<TextField.Icon icon={At} /> <TextField.Icon icon={At} />
<TextField.Input <TextField.Input
testID="loginUsernameInput" testID="loginUsernameInput"
@@ -209,6 +206,7 @@ export const LoginForm = ({
defaultValue={initialHandle || ''} defaultValue={initialHandle || ''}
onChangeText={v => { onChangeText={v => {
identifierValueRef.current = v identifierValueRef.current = v
if (errorField) setErrorField('none')
}} }}
onSubmitEditing={() => { onSubmitEditing={() => {
passwordRef.current?.focus() passwordRef.current?.focus()
@@ -221,7 +219,7 @@ export const LoginForm = ({
/> />
</TextField.Root> </TextField.Root>
<TextField.Root> <TextField.Root isInvalid={errorField === 'password'}>
<TextField.Icon icon={Lock} /> <TextField.Icon icon={Lock} />
<TextField.Input <TextField.Input
testID="loginPasswordInput" testID="loginPasswordInput"
@@ -236,6 +234,7 @@ export const LoginForm = ({
clearButtonMode="while-editing" clearButtonMode="while-editing"
onChangeText={v => { onChangeText={v => {
passwordValueRef.current = v passwordValueRef.current = v
if (errorField) setErrorField('none')
}} }}
onSubmitEditing={onPressNext} onSubmitEditing={onPressNext}
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing 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> <TextField.LabelText>
<Trans>2FA Confirmation</Trans> <Trans>2FA Confirmation</Trans>
</TextField.LabelText> </TextField.LabelText>
<TextField.Root> <TextField.Root isInvalid={errorField === '2fa'}>
<TextField.Icon icon={Ticket} /> <TextField.Icon icon={Ticket} />
<TextField.Input <TextField.Input
testID="loginAuthFactorTokenInput" testID="loginAuthFactorTokenInput"
@@ -288,8 +287,11 @@ export const LoginForm = ({
autoComplete="one-time-code" autoComplete="one-time-code"
returnKeyType="done" returnKeyType="done"
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field 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 value={authFactorToken} // controlled input due to uncontrolled input not receiving pasted values properly
onChangeText={text => {
setAuthFactorToken(text)
if (errorField) setErrorField('none')
}}
onSubmitEditing={onPressNext} onSubmitEditing={onPressNext}
editable={!isProcessing} editable={!isProcessing}
accessibilityHint={_( accessibilityHint={_(
@@ -308,25 +310,24 @@ export const LoginForm = ({
</View> </View>
)} )}
<FormError error={error} /> <FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}> <View style={[a.pt_md, web([a.justify_between, a.flex_row])]}>
<Button {IS_WEB && (
label={_(msg`Back`)} <Button
variant="solid" label={_(msg`Back`)}
color="secondary" color="secondary"
size="large" size="large"
onPress={onPressBack}> onPress={onPressBack}>
<ButtonText> <ButtonText>
<Trans>Back</Trans> <Trans>Back</Trans>
</ButtonText> </ButtonText>
</Button> </Button>
<View style={a.flex_1} /> )}
{!serviceDescription && error ? ( {!serviceDescription && error ? (
<Button <Button
testID="loginRetryButton" testID="loginRetryButton"
label={_(msg`Retry`)} label={_(msg`Retry`)}
accessibilityHint={_(msg`Retries signing in`)} accessibilityHint={_(msg`Retries signing in`)}
variant="solid" color="primary_subtle"
color="secondary"
size="large" size="large"
onPress={onPressRetryConnect}> onPress={onPressRetryConnect}>
<ButtonText> <ButtonText>
@@ -334,23 +335,24 @@ export const LoginForm = ({
</ButtonText> </ButtonText>
</Button> </Button>
) : !serviceDescription ? ( ) : !serviceDescription ? (
<> <Button
<ActivityIndicator /> label={_(msg`Connecting to service...`)}
<Text style={[t.atoms.text_contrast_high, a.pl_md]}> size="large"
<Trans>Connecting...</Trans> color="secondary"
</Text> disabled>
</> <ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText>
</Button>
) : ( ) : (
<Button <Button
testID="loginNextButton" testID="loginNextButton"
label={_(msg`Next`)} label={_(msg`Sign in`)}
accessibilityHint={_(msg`Navigates to the next screen`)} accessibilityHint={_(msg`Navigates to the next screen`)}
variant="solid"
color="primary" color="primary"
size="large" size="large"
onPress={onPressNext}> onPress={onPressNext}>
<ButtonText> <ButtonText>
<Trans>Next</Trans> <Trans>Sign in</Trans>
</ButtonText> </ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />} {isProcessing && <ButtonIcon icon={Loader} />}
</Button> </Button>
+3 -4
View File
@@ -2,7 +2,7 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
@@ -19,18 +19,17 @@ export const PasswordUpdatedForm = ({
<FormContainer <FormContainer
testID="passwordUpdatedForm" testID="passwordUpdatedForm"
style={[a.gap_2xl, !gtMobile && a.mt_5xl]}> 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> <Trans>Password updated!</Trans>
</Text> </Text>
<Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}> <Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}>
<Trans>You can now sign in with your new password.</Trans> <Trans>You can now sign in with your new password.</Trans>
</Text> </Text>
<View style={[a.flex_row, a.justify_center]}> <View style={web([a.flex_row, a.justify_center])}>
<Button <Button
onPress={onPressNext} onPress={onPressNext}
label={_(msg`Close alert`)} label={_(msg`Close alert`)}
accessibilityHint={_(msg`Closes password update alert`)} accessibilityHint={_(msg`Closes password update alert`)}
variant="solid"
color="primary" color="primary"
size="large"> size="large">
<ButtonText> <ButtonText>
+32 -34
View File
@@ -1,5 +1,5 @@
import {useState} from 'react' import {useState} from 'react'
import {ActivityIndicator, View} from 'react-native' import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -7,14 +7,16 @@ import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {checkAndFormatResetCode} from '#/lib/strings/password' import {checkAndFormatResetCode} from '#/lib/strings/password'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Agent} from '#/state/session/agent' import {Agent} from '#/state/session/agent'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError' import {FormError} from '#/components/forms/FormError'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
export const SetNewPasswordForm = ({ export const SetNewPasswordForm = ({
@@ -31,7 +33,6 @@ export const SetNewPasswordForm = ({
onPasswordSet: () => void onPasswordSet: () => void
}) => { }) => {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
const [isProcessing, setIsProcessing] = useState<boolean>(false) const [isProcessing, setIsProcessing] = useState<boolean>(false)
@@ -163,37 +164,34 @@ export const SetNewPasswordForm = ({
<FormError error={error} /> <FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_lg]}> <View style={[web([a.flex_row, a.align_center]), a.pt_lg]}>
<Button {IS_WEB && (
label={_(msg`Back`)} <>
variant="solid" <Button
color="secondary" label={_(msg`Back`)}
size="large" variant="solid"
onPress={onPressBack}> color="secondary"
<ButtonText> size="large"
<Trans>Back</Trans> onPress={onPressBack}>
</ButtonText> <ButtonText>
</Button> <Trans>Back</Trans>
<View style={a.flex_1} /> </ButtonText>
{isProcessing ? ( </Button>
<ActivityIndicator /> <View style={a.flex_1} />
) : ( </>
<Button
label={_(msg`Next`)}
variant="solid"
color="primary"
size="large"
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
</Button>
)} )}
{isProcessing ? (
<Text style={[t.atoms.text_contrast_high, a.pl_md]}> <Button
<Trans>Updating...</Trans> label={_(msg`Next`)}
</Text> color="primary"
) : undefined} size="large"
onPress={onPressNext}
disabled={isProcessing}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
</View> </View>
</FormContainer> </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 {ScreenTransition} from '#/components/ScreenTransition'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {ChooseAccountForm} from './ChooseAccountForm' import {ChooseAccountForm} from './ChooseAccountForm'
import * as AuthLayout from './components/AuthLayout'
import {AuthLayoutNavigationContext} from './components/AuthLayout/context'
enum Forms { enum Forms {
Login, Login,
@@ -131,11 +133,14 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
let content = null let content = null
let title = '' let title = ''
let description = '' let description = ''
let goBack = null
switch (currentForm) { switch (currentForm) {
case Forms.Login: case Forms.Login:
title = _(msg`Sign in`) title = _(msg`Sign in`)
description = _(msg`Enter your username and password`) description = _(msg`Enter your username and password`)
goBack = () =>
accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack()
content = ( content = (
<LoginForm <LoginForm
error={error} error={error}
@@ -146,9 +151,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
onAttemptFailed={onAttemptFailed} onAttemptFailed={onAttemptFailed}
onAttemptSuccess={onAttemptSuccess} onAttemptSuccess={onAttemptSuccess}
setServiceUrl={setServiceUrl} setServiceUrl={setServiceUrl}
onPressBack={() => onPressBack={goBack}
accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack()
}
onPressForgotPassword={onPressForgotPassword} onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={refetchService} onPressRetryConnect={refetchService}
/> />
@@ -157,16 +160,18 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
case Forms.ChooseAccount: case Forms.ChooseAccount:
title = _(msg`Sign in`) title = _(msg`Sign in`)
description = _(msg`Select from an existing account`) description = _(msg`Select from an existing account`)
goBack = handlePressBack
content = ( content = (
<ChooseAccountForm <ChooseAccountForm
onSelectAccount={onSelectAccount} onSelectAccount={onSelectAccount}
onPressBack={handlePressBack} onPressBack={goBack}
/> />
) )
break break
case Forms.ForgotPassword: case Forms.ForgotPassword:
title = _(msg`Forgot Password`) title = _(msg`Forgot Password`)
description = _(msg`Let's get your password reset!`) description = _(msg`Let's get your password reset!`)
goBack = () => gotoForm(Forms.Login)
content = ( content = (
<ForgotPasswordForm <ForgotPasswordForm
error={error} error={error}
@@ -174,7 +179,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
serviceDescription={serviceDescription} serviceDescription={serviceDescription}
setError={setError} setError={setError}
setServiceUrl={setServiceUrl} setServiceUrl={setServiceUrl}
onPressBack={() => gotoForm(Forms.Login)} onPressBack={goBack}
onEmailSent={() => gotoForm(Forms.SetNewPassword)} onEmailSent={() => gotoForm(Forms.SetNewPassword)}
/> />
) )
@@ -182,12 +187,13 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
case Forms.SetNewPassword: case Forms.SetNewPassword:
title = _(msg`Forgot Password`) title = _(msg`Forgot Password`)
description = _(msg`Let's get your password reset!`) description = _(msg`Let's get your password reset!`)
goBack = () => gotoForm(Forms.ForgotPassword)
content = ( content = (
<SetNewPasswordForm <SetNewPasswordForm
error={error} error={error}
serviceUrl={serviceUrl} serviceUrl={serviceUrl}
setError={setError} setError={setError}
onPressBack={() => gotoForm(Forms.ForgotPassword)} onPressBack={goBack}
onPasswordSet={() => gotoForm(Forms.PasswordUpdated)} onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}
/> />
) )
@@ -201,23 +207,35 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
break break
} }
const navigation = goBack ? {goBack} : null
return ( return (
<Animated.View style={a.flex_1} entering={native(FadeIn.duration(90))}> <AuthLayoutNavigationContext value={navigation}>
<KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}> <Animated.View style={a.flex_1} entering={native(FadeIn.duration(90))}>
<LoggedOutLayout <KeyboardAvoidingView
leadin="" testID="signIn"
title={title} behavior="padding"
description={description} style={a.flex_1}>
scrollable> <AuthLayout.Header.Outer>
<LayoutAnimationConfig skipEntering> <AuthLayout.Header.BackButton />
<ScreenTransition <AuthLayout.Header.Content />
key={currentForm} <AuthLayout.Header.Slot />
direction={screenTransitionDirection}> </AuthLayout.Header.Outer>
{content} <LoggedOutLayout
</ScreenTransition> leadin=""
</LayoutAnimationConfig> title={title}
</LoggedOutLayout> description={description}
</KeyboardAvoidingView> scrollable>
</Animated.View> <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' } from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import { import {
type CommonNavigatorParams, type CommonNavigatorParams,
@@ -68,8 +67,6 @@ export function MessagesConversationScreenInner({route}: Props) {
const convoId = route.params.conversation const convoId = route.params.conversation
const {setCurrentConvoId} = useCurrentConvoId() const {setCurrentConvoId} = useCurrentConvoId()
useEnableKeyboardControllerScreen(true)
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
setCurrentConvoId(convoId) setCurrentConvoId(convoId)
+4 -2
View File
@@ -103,10 +103,12 @@ export function MessagesSettingsScreenInner({}: Props) {
</Toggle.Item> </Toggle.Item>
<Toggle.Item <Toggle.Item
name="none" name="none"
label={_(msg`No one`)} label={_(
msg({context: 'allow messages from', message: `No one`}),
)}
style={[a.justify_between, a.py_sm]}> style={[a.justify_between, a.py_sm]}>
<Toggle.LabelText> <Toggle.LabelText>
<Trans>No one</Trans> <Trans context="allow messages from">No one</Trans>
</Toggle.LabelText> </Toggle.LabelText>
<Toggle.Radio /> <Toggle.Radio />
</Toggle.Item> </Toggle.Item>
@@ -96,10 +96,13 @@ export function StepFinished() {
const {selectedInterests} = interestsStepResults const {selectedInterests} = interestsStepResults
await Promise.all([ await Promise.all([
bulkWriteFollows(agent, [ bulkWriteFollows(
BSKY_APP_ACCOUNT_DID, agent,
...(listItems?.map(i => i.subject.did) ?? []), [BSKY_APP_ACCOUNT_DID, ...(listItems?.map(i => i.subject.did) ?? [])],
]), starterPack
? {uri: starterPack.uri, cid: starterPack.cid}
: undefined,
),
(async () => { (async () => {
// Interests need to get saved first, then we can write the feeds to prefs // Interests need to get saved first, then we can write the feeds to prefs
await agent.setInterestsPref({tags: selectedInterests}) await agent.setInterestsPref({tags: selectedInterests})
@@ -72,7 +72,10 @@ export function StarterPackCard({
let followUris: Map<string, string> let followUris: Map<string, string>
try { try {
followUris = await bulkWriteFollows(agent, dids) followUris = await bulkWriteFollows(agent, dids, {
uri: view.uri,
cid: view.cid,
})
} catch (e) { } catch (e) {
setIsProcessing(false) setIsProcessing(false)
Toast.show(_(msg`An error occurred while trying to follow all`), { 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 {View} from 'react-native'
import * as bcp47Match from 'bcp-47-match' import * as bcp47Match from 'bcp-47-match'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import { import {
Layout, Layout,
@@ -60,8 +59,6 @@ export function Onboarding() {
) )
const [contactsFlowState, contactsFlowDispatch] = useFindContactsFlowState() const [contactsFlowState, contactsFlowDispatch] = useFindContactsFlowState()
useEnableKeyboardControllerScreen(true)
return ( return (
<Portal> <Portal>
<View style={[a.absolute, a.inset_0, t.atoms.bg]}> <View style={[a.absolute, a.inset_0, t.atoms.bg]}>
+7 -1
View File
@@ -4,13 +4,18 @@ import {
type AppBskyGraphGetFollows, type AppBskyGraphGetFollows,
type BskyAgent, type BskyAgent,
type ComAtprotoRepoApplyWrites, type ComAtprotoRepoApplyWrites,
type ComAtprotoRepoStrongRef,
} from '@atproto/api' } from '@atproto/api'
import {TID} from '@atproto/common-web' import {TID} from '@atproto/common-web'
import chunk from 'lodash.chunk' import chunk from 'lodash.chunk'
import {until} from '#/lib/async/until' 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 const session = agent.session
if (!session) { if (!session) {
@@ -22,6 +27,7 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
$type: 'app.bsky.graph.follow', $type: 'app.bsky.graph.follow',
subject: did, subject: did,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
via,
} }
}) })
@@ -10,7 +10,6 @@ import {
import {msg, Plural, Trans} from '@lingui/macro' import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useTranslate} from '#/lib/hooks/useTranslate' import {useTranslate} from '#/lib/hooks/useTranslate'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
@@ -60,6 +59,7 @@ import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {WhoCanReply} from '#/components/WhoCanReply' import {WhoCanReply} from '#/components/WhoCanReply'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
export function ThreadItemAnchor({ export function ThreadItemAnchor({
@@ -452,14 +452,16 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text <Text
testID="repostCount-expanded" testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> <Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)">
{formatPostStatCount(post.repostCount)} <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
</Text>{' '} {formatPostStatCount(post.repostCount)}
<Plural </Text>{' '}
value={post.repostCount} <Plural
one="repost" value={post.repostCount}
other="reposts" one="repost"
/> other="reposts"
/>
</Trans>
</Text> </Text>
</Link> </Link>
) : null} ) : null}
@@ -470,14 +472,16 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text <Text
testID="quoteCount-expanded" testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> <Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)">
{formatPostStatCount(post.quoteCount)} <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
</Text>{' '} {formatPostStatCount(post.quoteCount)}
<Plural </Text>{' '}
value={post.quoteCount} <Plural
one="quote" value={post.quoteCount}
other="quotes" one="quote"
/> other="quotes"
/>
</Trans>
</Text> </Text>
</Link> </Link>
) : null} ) : null}
@@ -486,10 +490,12 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text <Text
testID="likeCount-expanded" testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> <Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
{formatPostStatCount(post.likeCount)} <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
</Text>{' '} {formatPostStatCount(post.likeCount)}
<Plural value={post.likeCount} one="like" other="likes" /> </Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
</Trans>
</Text> </Text>
</Link> </Link>
) : null} ) : null}
@@ -497,10 +503,16 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text <Text
testID="bookmarkCount-expanded" testID="bookmarkCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> <Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)">
{formatPostStatCount(post.bookmarkCount)} <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
</Text>{' '} {formatPostStatCount(post.bookmarkCount)}
<Plural value={post.bookmarkCount} one="save" other="saves" /> </Text>{' '}
<Plural
value={post.bookmarkCount}
one="save"
other="saves"
/>
</Trans>
</Text> </Text>
) : null} ) : null}
</View> </View>
@@ -8,7 +8,6 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {Trans} from '@lingui/macro' import {Trans} from '@lingui/macro'
import {useActorStatus} from '#/lib/actor-status'
import {MAX_POST_LINES} from '#/lib/constants' import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
@@ -44,6 +43,7 @@ import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton' import * as Skele from '#/components/Skeleton'
import {SubtleHover} from '#/components/SubtleHover' import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useActorStatus} from '#/features/liveNow'
export type ThreadItemPostProps = { export type ThreadItemPostProps = {
item: Extract<ThreadItem, {type: 'threadPost'}> item: Extract<ThreadItem, {type: 'threadPost'}>
@@ -10,7 +10,6 @@ import {
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
@@ -39,6 +38,8 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {IS_IOS} from '#/env' import {IS_IOS} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {GermButton} from '../components/GermButton'
import {EditProfileDialog} from './EditProfileDialog' import {EditProfileDialog} from './EditProfileDialog'
import {ProfileHeaderHandle} from './Handle' import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics' import {ProfileHeaderMetrics} from './Metrics'
@@ -156,6 +157,7 @@ let ProfileHeaderStandard = ({
testID="profileHeaderDescription" testID="profileHeaderDescription"
style={[a.text_md]} style={[a.text_md]}
numberOfLines={15} numberOfLines={15}
selectable
value={descriptionRT} value={descriptionRT}
enableTags enableTags
authorHandle={profile.handle} authorHandle={profile.handle}
@@ -163,6 +165,10 @@ let ProfileHeaderStandard = ({
</View> </View>
) : undefined} ) : undefined}
{profile.associated?.germ && (
<GermButton germ={profile.associated.germ} profile={profile} />
)}
{!isMe && {!isMe &&
!isBlockedUser && !isBlockedUser &&
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
+4 -4
View File
@@ -14,7 +14,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {BACK_HITSLOP} from '#/lib/constants' import {BACK_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {type NavigationProp} from '#/lib/routes/types' 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 {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 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 {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env' 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 {GrowableAvatar} from './GrowableAvatar'
import {GrowableBanner} from './GrowableBanner' import {GrowableBanner} from './GrowableBanner'
import {StatusBarShadow} from './StatusBarShadow' 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 {View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated' import {useAnimatedRef} from 'react-native-reanimated'
import { import {
@@ -35,12 +35,13 @@ import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {FAB} from '#/view/com/util/fab/FAB' import {FAB} from '#/view/com/util/fab/FAB'
import {type ListRef} from '#/view/com/util/List' import {type ListRef} from '#/view/com/util/List'
import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen' 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 {useDialogControl} from '#/components/Dialog'
import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog' import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Hider from '#/components/moderation/Hider' import * as Hider from '#/components/moderation/Hider'
import {IS_WEB} from '#/env'
import {AboutSection} from './AboutSection' import {AboutSection} from './AboutSection'
import {ErrorScreen} from './components/ErrorScreen' import {ErrorScreen} from './components/ErrorScreen'
import {Header} from './components/Header' import {Header} from './components/Header'
@@ -149,6 +150,7 @@ function ProfileListScreenLoaded({
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
preferences: UsePreferencesQueryResponse preferences: UsePreferencesQueryResponse
}) { }) {
const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
@@ -164,6 +166,8 @@ function ProfileListScreenLoaded({
const scrollElRef = useAnimatedRef() const scrollElRef = useAnimatedRef()
const addUserDialogControl = useDialogControl() const addUserDialogControl = useDialogControl()
const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)] const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)]
// modlist only
const [headerHeight, setHeaderHeight] = useState<number | null>(null)
const moderation = useMemo(() => { const moderation = useMemo(() => {
return moderateUserList(list, moderationOpts) return moderateUserList(list, moderationOpts)
@@ -263,13 +267,24 @@ function ProfileListScreenLoaded({
</Hider.Mask> </Hider.Mask>
<Hider.Content> <Hider.Content>
<View style={[a.util_screen_outer]}> <View style={[a.util_screen_outer]}>
<Layout.Center>{renderHeader()}</Layout.Center> <Layout.Center
<AboutSection onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}
list={list} style={[
scrollElRef={scrollElRef as ListRef} native([a.absolute, a.z_10, t.atoms.bg]),
onPressAddUser={addUserDialogControl.open}
headerHeight={0} 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 <FAB
testID="composeFAB" testID="composeFAB"
onPress={() => openComposer({logContext: 'Fab'})} onPress={() => openComposer({logContext: 'Fab'})}
@@ -1,7 +1,7 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api' 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 {useLingui} from '@lingui/react'
import {useModerationOpts} from '#/state/preferences/moderation-opts' 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 {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config' import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 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 {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack' import {AvatarStack} from '#/components/AvatarStack'
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
@@ -65,7 +64,7 @@ export function TrendRow({
onPress?: () => void onPress?: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_, i18n} = useLingui() const {_} = useLingui()
const gutters = useGutters([0, 'base']) const gutters = useGutters([0, 'base'])
const category = useCategoryDisplayName(trend?.category || 'other') const category = useCategoryDisplayName(trend?.category || 'other')
@@ -74,13 +73,6 @@ export function TrendRow({
(1000 * 60 * 60), (1000 * 60 * 60),
) )
const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age 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) const actors = useModerateTrendingActors(trend.actors)
@@ -132,8 +124,6 @@ export function TrendRow({
web(a.leading_snug), web(a.leading_snug),
]} ]}
numberOfLines={1}> numberOfLines={1}>
{postCount}
{postCount && category && <> &middot; </>}
{category} {category}
</Text> </Text>
</View> </View>
+7 -3
View File
@@ -3,7 +3,6 @@ import {useLingui} from '@lingui/react'
import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types' import {type CommonNavigatorParams} from '#/lib/routes/types'
import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import * as SettingsList from '#/screens/Settings/components/SettingsList' import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@@ -27,6 +26,7 @@ import * as Layout from '#/components/Layout'
import {ChangeHandleDialog} from './components/ChangeHandleDialog' import {ChangeHandleDialog} from './components/ChangeHandleDialog'
import {ChangePasswordDialog} from './components/ChangePasswordDialog' import {ChangePasswordDialog} from './components/ChangePasswordDialog'
import {DeactivateAccountDialog} from './components/DeactivateAccountDialog' import {DeactivateAccountDialog} from './components/DeactivateAccountDialog'
import {DeleteAccountDialog} from './components/DeleteAccountDialog'
import {ExportCarDialog} from './components/ExportCarDialog' import {ExportCarDialog} from './components/ExportCarDialog'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'>
@@ -34,13 +34,13 @@ export function AccountSettingsScreen({}: Props) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {openModal} = useModalControls()
const emailDialogControl = useEmailDialogControl() const emailDialogControl = useEmailDialogControl()
const birthdayControl = useDialogControl() const birthdayControl = useDialogControl()
const changeHandleControl = useDialogControl() const changeHandleControl = useDialogControl()
const changePasswordControl = useDialogControl() const changePasswordControl = useDialogControl()
const exportCarControl = useDialogControl() const exportCarControl = useDialogControl()
const deactivateAccountControl = useDialogControl() const deactivateAccountControl = useDialogControl()
const deleteAccountControl = useDialogControl()
return ( return (
<Layout.Screen> <Layout.Screen>
@@ -169,7 +169,7 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.PressableItem> </SettingsList.PressableItem>
<SettingsList.PressableItem <SettingsList.PressableItem
label={_(msg`Delete account`)} label={_(msg`Delete account`)}
onPress={() => openModal({name: 'delete-account'})} onPress={() => deleteAccountControl.open()}
destructive> destructive>
<SettingsList.ItemIcon icon={Trash_Stroke2_Corner2_Rounded} /> <SettingsList.ItemIcon icon={Trash_Stroke2_Corner2_Rounded} />
<SettingsList.ItemText> <SettingsList.ItemText>
@@ -185,6 +185,10 @@ export function AccountSettingsScreen({}: Props) {
<ChangePasswordDialog control={changePasswordControl} /> <ChangePasswordDialog control={changePasswordControl} />
<ExportCarDialog control={exportCarControl} /> <ExportCarDialog control={exportCarControl} />
<DeactivateAccountDialog control={deactivateAccountControl} /> <DeactivateAccountDialog control={deactivateAccountControl} />
<DeleteAccountDialog
control={deleteAccountControl}
deactivateDialogControl={deactivateAccountControl}
/>
</Layout.Screen> </Layout.Screen>
) )
} }
@@ -126,12 +126,12 @@ export function Inner({
</Toggle.LabelText> </Toggle.LabelText>
</Toggle.Item> </Toggle.Item>
<Toggle.Item <Toggle.Item
label={_(msg`No one`)} label={_(msg({context: 'enable for', message: `No one`}))}
name="none" name="none"
style={[a.flex_row, a.py_xs, a.gap_sm]}> style={[a.flex_row, a.py_xs, a.gap_sm]}>
<Toggle.Radio /> <Toggle.Radio />
<Toggle.LabelText style={[t.atoms.text, a.font_normal, a.text_md]}> <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.LabelText>
</Toggle.Item> </Toggle.Item>
</View> </View>
@@ -156,7 +156,7 @@ function NotificationDeclaration({
case 'mutuals': case 'mutuals':
return <Trans>Only followers who I follow</Trans> return <Trans>Only followers who I follow</Trans>
case 'none': case 'none':
return <Trans>No one</Trans> return <Trans context="enable for">No one</Trans>
case 'followers': case 'followers':
default: default:
return <Trans>Anyone who follows me</Trans> 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 {useNavigation} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useActorStatus} from '#/lib/actor-status'
import {HELP_DESK_URL} from '#/lib/constants' import {HELP_DESK_URL} from '#/lib/constants'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates' import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates'
@@ -70,6 +69,7 @@ import {
} from '#/components/verification/VerificationCheckButton' } from '#/components/verification/VerificationCheckButton'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env' import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {device, useStorage} from '#/storage' import {device, useStorage} from '#/storage'
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' 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 React, {useRef} from 'react'
import {type TextInput, View} from 'react-native' 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 {useLingui} from '@lingui/react'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import type tldts from 'tldts' import type tldts from 'tldts'
@@ -312,15 +312,15 @@ export function StepInfo({
<Admonition.Content> <Admonition.Content>
<Admonition.Text> <Admonition.Text>
{!isOverAppMinAccessAge ? ( {!isOverAppMinAccessAge ? (
<Trans> <Plural
You must be {MIN_ACCESS_AGE} years of age or older value={MIN_ACCESS_AGE}
to create an account. other="You must be # years of age or older to create an account."
</Trans> />
) : ( ) : (
<Trans> <Plural
You must be {aaRegionConfig.minAccessAge} years of value={aaRegionConfig.minAccessAge}
age or older to create an account in your region. other="You must be # years of age or older to create an account in your region."
</Trans> />
)} )}
</Admonition.Text> </Admonition.Text>
{IS_NATIVE && {IS_NATIVE &&
@@ -372,7 +372,10 @@ function Header({
let followUris: Map<string, string> let followUris: Map<string, string>
try { try {
followUris = await bulkWriteFollows(agent, dids) followUris = await bulkWriteFollows(agent, dids, {
uri: starterPack.uri,
cid: starterPack.cid,
})
} catch (e) { } catch (e) {
setIsProcessing(false) setIsProcessing(false)
Toast.show(_(msg`An error occurred while trying to follow all`), 'xmark') Toast.show(_(msg`An error occurred while trying to follow all`), 'xmark')
@@ -498,7 +501,7 @@ function Header({
value={starterPack.joinedAllTimeCount || 0} value={starterPack.joinedAllTimeCount || 0}
other="# people have" other="# people have"
/>{' '} />{' '}
used this starter pack! joined Bluesky via this starter pack!
</Trans> </Trans>
</Text> </Text>
</View> </View>
-3
View File
@@ -16,7 +16,6 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {STARTER_PACK_MAX_SIZE} from '#/lib/constants' import {STARTER_PACK_MAX_SIZE} from '#/lib/constants'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import { import {
type CommonNavigatorParams, type CommonNavigatorParams,
@@ -185,8 +184,6 @@ function WizardInner({
}) })
}, [navigation]) }, [navigation])
useEnableKeyboardControllerScreen(true)
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
setMinimalShellMode(true) setMinimalShellMode(true)
-3
View File
@@ -12,7 +12,6 @@ import {
BLUESKY_MOD_SERVICE_HEADERS, BLUESKY_MOD_SERVICE_HEADERS,
MAX_REPORT_REASON_GRAPHEME_LENGTH, MAX_REPORT_REASON_GRAPHEME_LENGTH,
} from '#/lib/constants' } from '#/lib/constants'
import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {useAgent, useSession, useSessionApi} from '#/state/session' import {useAgent, useSession, useSessionApi} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
@@ -121,8 +120,6 @@ export function Takendown() {
const webLayout = IS_WEB && gtMobile const webLayout = IS_WEB && gtMobile
useEnableKeyboardController(true)
return ( return (
<View style={[a.util_screen_outer, a.flex_1]}> <View style={[a.util_screen_outer, a.flex_1]}>
<KeyboardAwareScrollView style={[a.flex_1, t.atoms.bg]} centerContent> <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