Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey 90dc3edc5b Use Inter variable font 2024-09-30 12:40:16 -05:00
124 changed files with 2488 additions and 3529 deletions
+35
View File
@@ -0,0 +1,35 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
**To Reproduce**
Steps to reproduce the behavior:
1.
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**Details**
- Platform: <!-- desktop chrome windows, mobile safari, iOS, Android -->
- Platform version:
- App version:
**Additional context**
<!-- Add any other context about the problem here. -->
-67
View File
@@ -1,67 +0,0 @@
name: "Bug Report"
description: "Create a report for an issue you have experience in the app."
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report an issue you've found in the app! Before you submit this issue, please make
sure you've searched for similar issues that are already open and being tracked. If you find an open issue that
seems relevant to yours, it is best to leave a response there with your information instead of opening a new
issue, since it helps to consolidate the info in one place.
- type: textarea
attributes:
label: Steps to Reproduce
description: |
Please provide the steps that may reproduce the issue that you are experiencing. The more concise of a
reproduction that you provide, the easier it will be for us to identify the issue and fix it in a timely manner.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
attributes:
label: Attachments
description: |
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
validations:
required: false
- type: dropdown
attributes:
label: What platform(s) does this occur on?
multiple: true
options:
- iOS
- Android
- Web (Desktop)
- Web (Mobile)
validations:
required: true
- type: input
attributes:
label: Device Info
description: |
Please provide the device information that you are using when experiencing this issue. This can include the
device model, operating system version, and any other relevant information.
validations:
required: false
- type: input
attributes:
label: What version of the app are you using?
description: |
You can easily find this information by visting the Settings screen in the app, and tapping on the version info
at the bottom of the screen. All the relevant information will be copied to your clipboard, and you will be
able to share it here.
validations:
required: true
- type: textarea
attributes:
label: Additional Information
description: |
Please provide any additional information that you think may be helpful in identifying the issue you are
experiencing. This can include any error messages, logs, or other information that you think may be relevant.
validations:
required: false
@@ -1,72 +0,0 @@
name: "Bug Report - Fabric/New Architecture"
description: "Create a bug report for builds using the New Architecture. If you are not sure what that means, don't use this template, and instead use the default bug report template."
labels: ["bug", "fabric"]
body:
- type: checkboxes
attributes:
label: You're running a build that uses the React Native New Architecture, right?
description: This template is specifically for issues related to builds that use the React Native New Architecture. If you are not sure what that means, this template is probably not relevant to you, and you should use the default Bug Report template.
options:
- label: I am running a build that uses the New Architecture
required: true
- type: markdown
attributes:
value: |
Thanks for taking the time to report an issue you've found in the app! Before you submit this issue, please make
sure you've searched for similar issues that are already open and being tracked. If you find an open issue that
seems relevant to yours, it is best to leave a response there with your information instead of opening a new
issue, since it helps to consolidate the info in one place.
- type: textarea
attributes:
label: Steps to Reproduce
description: |
Please provide the steps that may reproduce the issue that you are experiencing. The more concise of a
reproduction that you provide, the easier it will be for us to identify the issue and fix it in a timely manner.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
attributes:
label: Attachments
description: |
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
validations:
required: false
- type: dropdown
attributes:
label: What platform(s) does this occur on?
multiple: true
options:
- iOS
- Android
validations:
required: true
- type: input
attributes:
label: Device Info
description: |
Please provide the device information that you are using when experiencing this issue. This can include the
device model, operating system version, and any other relevant information.
validations:
required: false
- type: input
attributes:
label: What version of the app are you using?
description: |
You can easily find this information by visting the Settings screen in the app, and tapping on the version info
at the bottom of the screen. All the relevant information will be copied to your clipboard, and you will be
able to share it here.
validations:
required: true
- type: textarea
attributes:
label: Additional Information
description: |
Please provide any additional information that you think may be helpful in identifying the issue you are
experiencing. This can include any error messages, logs, or other information that you think may be relevant.
validations:
required: false
+24
View File
@@ -0,0 +1,24 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature-request
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
**Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
**Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->
@@ -1,45 +0,0 @@
name: "Feature Request"
description: "Suggest an idea for the Bluesky app."
labels: ["feature-request"]
body:
- type: markdown
attributes:
value: |
Thanks for submitting a feature request! Before you submit this request, please make sure you've searched for
similar requests that are already open and being tracked. If you find an open request that seems relevant to
yours, it is best to leave a response there with your information instead of opening a new request, since it
helps to consolidate the info in one place.
- type: textarea
attributes:
label: Describe the Feature
description: |
Please provide a detailed description of the feature you would like to see implemented in the app. The more
information you provide, the easier it will be for us to understand the feature you are requesting and to
implement it in a timely manner.
validations:
required: true
- type: textarea
attributes:
label: Attachments
description: |
If helpful, provide some images or videos of similar features in other apps that you think would be helpful
for us to understand the feature you are requesting, or places within the app that you think the feature belongs
in or is missing from.
validations:
required: false
- type: textarea
attributes:
label: Describe Alternatives
description: |
If there are any alternative solutions or features that you think would be helpful to implement in place of the
feature you are requesting, please provide a description of those as well.
validations:
required: false
- type: textarea
attributes:
label: Additional Context
description: |
If there is any additional context or information that you think would be helpful for us to know about the
feature you are requesting, please provide that information here.
validations:
required: false
-3
View File
@@ -15,6 +15,3 @@ android
ios
src/locale/locales
lib/react-compiler-runtime
bskyweb/static
coverage
web-build
-2
View File
@@ -255,7 +255,6 @@ func serve(cctx *cli.Context) error {
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric)
e.GET("/intent/verify-email", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
@@ -280,7 +279,6 @@ func serve(cctx *cli.Context) error {
// starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/starter-pack-short/:code", server.WebGeneric)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
// ipcc
-5
View File
@@ -96,11 +96,6 @@ jest.mock('expo-modules-core', () => ({
getIsReducedMotionEnabled: () => false,
}
}
if (moduleName === 'BottomSheet') {
return {
dismissAll: () => {},
}
}
}),
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
return () => null
-57
View File
@@ -1,57 +0,0 @@
apply plugin: 'com.android.library'
group = 'expo.modules.bottomsheet'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
// Most of the time, you may like to manage the Android SDK versions yourself.
def useManagedAndroidSdkVersions = false
if (useManagedAndroidSdkVersions) {
useDefaultAndroidSdkVersions()
} else {
buildscript {
// Simple helper that allows the root project to override versions declared by this library.
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
}
project.android {
compileSdkVersion safeExtGet("compileSdkVersion", 34)
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 21)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
}
}
}
android {
namespace "expo.modules.bottomsheet"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
}
dependencies {
implementation project(':expo-modules-core')
implementation "androidx.compose.material3:material3:1.3.0"
implementation "androidx.compose.material:material:1.7.2"
implementation "com.facebook.react:react-native:+"
implementation("com.composables:core:1.12.0")
}
@@ -1,2 +0,0 @@
<manifest>
</manifest>
@@ -1,58 +0,0 @@
package expo.modules.bottomsheet
import android.graphics.Color
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class BottomSheetModule : Module() {
override fun definition() =
ModuleDefinition {
Name("BottomSheet")
AsyncFunction("dismissAll") {
SheetManager.dismissAll()
}
View(BottomSheetView::class) {
Events(
arrayOf(
"onAttemptDismiss",
"onSnapPointChange",
"onStateChange",
),
)
AsyncFunction("dismiss") { view: BottomSheetView ->
view.dismiss()
}
AsyncFunction("updateLayout") { view: BottomSheetView ->
view.updateLayout()
}
Prop("containerBackgroundColor") { view: BottomSheetView, prop: String ->
view.sheetState.value.containerBackgroundColor = Color.parseColor(prop)
}
Prop("cornerRadius") { view: BottomSheetView, prop: Float ->
view.sheetState.value.cornerRadius = prop
}
Prop("minHeight") { view: BottomSheetView, prop: Float ->
view.minHeight = prop
}
Prop("maxHeight") { view: BottomSheetView, prop: Float ->
view.maxHeight = prop
}
Prop("preventDismiss") { view: BottomSheetView, prop: Boolean ->
view.preventDismiss = prop
}
Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
view.sheetState.value.preventExpansion = prop
}
}
}
}
@@ -1,28 +0,0 @@
package expo.modules.bottomsheet
import java.lang.ref.WeakReference
class SheetManager {
companion object {
private val sheets = mutableSetOf<WeakReference<BottomSheetView>>()
fun add(view: BottomSheetView) {
sheets.add(WeakReference(view))
}
fun remove(view: BottomSheetView) {
sheets.forEach {
if (it.get() == view) {
sheets.remove(it)
return
}
}
}
fun dismissAll() {
sheets.forEach {
it.get()?.dismiss()
}
}
}
}
@@ -1,101 +0,0 @@
package expo.modules.bottomsheet
import android.util.Log
import android.view.View
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.composables.core.BottomSheet
import com.composables.core.SheetDetent
import com.composables.core.SheetDetent.Companion.FullyExpanded
import com.composables.core.SheetDetent.Companion.Hidden
import com.composables.core.rememberBottomSheetState
import kotlinx.coroutines.launch
import kotlin.annotations.jvm.Mutable
data class SheetState(
var isOpen: Boolean = false,
var cornerRadius: Float? = null,
var containerBackgroundColor: Int? = null,
var preventExpansion: Boolean = false,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SheetView(
state: MutableState<SheetState>,
openState: MutableState<Boolean>,
innerView: View,
contentHeight: Float,
onDismissRequest: () -> Unit,
onExpanded: () -> Unit,
onHidden: () -> Unit,
) {
val Peek = SheetDetent(identifier = "peek") { containerHeight, sheetHeight ->
containerHeight*0.6f
}
val scope = rememberCoroutineScope()
val sheetState = rememberBottomSheetState(
initialDetent = Hidden,
detents = listOf(Hidden, Peek, FullyExpanded)
)
BottomSheet(
state = sheetState,
modifier = Modifier.fillMaxWidth()
.shadow(4.dp)
.clip(RoundedCornerShape(topStart = state.value.cornerRadius ?: 0f, topEnd = state.value.cornerRadius ?: 0f))
.background(Color(state.value.containerBackgroundColor ?: android.graphics.Color.TRANSPARENT))
.imePadding()
// onDismissRequest = onDismissRequest,
// shape =
// RoundedCornerShape(
// topStart = state.value.cornerRadius ?: 0f,
// topEnd = state.value.cornerRadius ?: 0f,
// ),
// containerColor = Color(state.value.containerBackgroundColor ?: android.graphics.Color.TRANSPARENT),
) {
AndroidView(
factory = { innerView },
)
}
LaunchedEffect(openState) {
Log.d("SheetView", "openState: ${openState.value}")
if (openState.value) {
sheetState.currentDetent = Peek
} else {
sheetState.currentDetent = Hidden
}
}
LaunchedEffect(sheetState.currentDetent) {
if (sheetState.currentDetent == Peek || sheetState.currentDetent == FullyExpanded) {
onExpanded()
} else if (sheetState.currentDetent == Hidden) {
onHidden()
}
}
}
@@ -1,9 +0,0 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["BottomSheetModule"]
},
"android": {
"modules": ["expo.modules.bottomsheet.BottomSheetModule"]
}
}
-13
View File
@@ -1,13 +0,0 @@
import {BottomSheet} from './src/BottomSheet'
import {
BottomSheetSnapPoint,
BottomSheetState,
BottomSheetViewProps,
} from './src/BottomSheet.types'
export {
BottomSheet,
BottomSheetSnapPoint,
type BottomSheetState,
type BottomSheetViewProps,
}
@@ -1,21 +0,0 @@
Pod::Spec.new do |s|
s.name = 'BottomSheet'
s.version = '1.0.0'
s.summary = 'A bottom sheet for use in Bluesky'
s.description = 'A bottom sheet for use in Bluesky'
s.author = ''
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '15.0', :tvos => '15.0' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,swift}"
end
@@ -1,47 +0,0 @@
import ExpoModulesCore
public class BottomSheetModule: Module {
public func definition() -> ModuleDefinition {
Name("BottomSheet")
AsyncFunction("dismissAll") {
SheetManager.shared.dismissAll()
}
View(SheetView.self) {
Events([
"onAttemptDismiss",
"onSnapPointChange",
"onStateChange",
])
AsyncFunction("dismiss") { (view: SheetView) in
view.dismiss()
}
AsyncFunction("updateLayout") { (view: SheetView) in
view.updateLayout()
}
Prop("cornerRadius") { (view: SheetView, prop: Float) in
view.cornerRadius = CGFloat(prop)
}
Prop("minHeight") { (view: SheetView, prop: Double) in
view.minHeight = prop
}
Prop("maxHeight") { (view: SheetView, prop: Double) in
view.maxHeight = prop
}
Prop("preventDismiss") { (view: SheetView, prop: Bool) in
view.preventDismiss = prop
}
Prop("preventExpansion") { (view: SheetView, prop: Bool) in
view.preventExpansion = prop
}
}
}
}
@@ -1,28 +0,0 @@
//
// SheetManager.swift
// Pods
//
// Created by Hailey on 10/1/24.
//
import ExpoModulesCore
class SheetManager {
static let shared = SheetManager()
private var sheetViews = NSHashTable<SheetView>(options: .weakMemory)
func add(_ view: SheetView) {
sheetViews.add(view)
}
func remove(_ view: SheetView) {
sheetViews.remove(view)
}
func dismissAll() {
sheetViews.allObjects.forEach { sheetView in
sheetView.dismiss()
}
}
}
-185
View File
@@ -1,185 +0,0 @@
import ExpoModulesCore
import UIKit
class SheetView: ExpoView, UISheetPresentationControllerDelegate {
// Views
private var sheetVc: SheetViewController?
private var innerView: UIView?
// Events
private let onAttemptDismiss = EventDispatcher()
private let onSnapPointChange = EventDispatcher()
private let onStateChange = EventDispatcher()
// Open event firing
private var isOpen: Bool = false {
didSet {
onStateChange([
"state": isOpen ? "open" : "closed"
])
}
}
// React view props
var preventDismiss = false
var preventExpansion = false
var cornerRadius: CGFloat?
var minHeight = 0.0
var maxHeight: CGFloat! {
didSet {
let screenHeight = Util.getScreenHeight() ?? 0
if maxHeight > screenHeight {
maxHeight = screenHeight
}
}
}
private var isOpening = false {
didSet {
if isOpening {
onStateChange([
"state": "opening"
])
}
}
}
private var isClosing = false {
didSet {
if isClosing {
onStateChange([
"state": "closing"
])
}
}
}
private var selectedDetentIdentifier: UISheetPresentationController.Detent.Identifier? {
didSet {
if selectedDetentIdentifier == .large {
onSnapPointChange([
"snapPoint": 2
])
} else {
onSnapPointChange([
"snapPoint": 1
])
}
}
}
// MARK: - Lifecycle
required init (appContext: AppContext? = nil) {
super.init(appContext: appContext)
self.maxHeight = Util.getScreenHeight()
SheetManager.shared.add(self)
}
deinit {
self.destroy()
}
// We don't want this view to actually get added to the tree, so we'll simply store it for adding
// to the SheetViewController
override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
self.innerView = subview
}
// We'll grab the content height from here so we know the initial detent to set
override func layoutSubviews() {
super.layoutSubviews()
guard let innerView = self.innerView else {
return
}
if innerView.subviews.count != 1 {
return
}
self.present()
}
private func destroy() {
self.isClosing = false
self.isOpen = false
self.sheetVc = nil
self.innerView = nil
SheetManager.shared.remove(self)
}
// MARK: - Presentation
func present() {
guard !self.isOpen,
let innerView = self.innerView,
let contentHeight = innerView.subviews.first?.frame.height,
let rvc = self.reactViewController() else {
return
}
let sheetVc = SheetViewController()
sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion)
if let sheet = sheetVc.sheetPresentationController {
sheet.delegate = self
sheet.preferredCornerRadius = self.cornerRadius
if sheet.detents.count == 1 {
sheet.selectedDetentIdentifier = .large
} else {
self.selectedDetentIdentifier = sheet.selectedDetentIdentifier
}
}
sheetVc.view.addSubview(innerView)
self.sheetVc = sheetVc
self.isOpening = true
rvc.present(sheetVc, animated: true) { [weak self] in
self?.isOpening = false
self?.isOpen = true
}
}
func updateLayout() {
if let contentHeight = self.innerView?.subviews.first?.frame.size.height {
self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
preventExpansion: self.preventExpansion)
}
}
func dismiss() {
self.isClosing = true
self.sheetVc?.dismiss(animated: true) { [weak self] in
self?.destroy()
}
}
// MARK: - Utils
private func clampHeight(_ height: CGFloat) -> CGFloat {
if height < self.minHeight {
return self.minHeight
} else if height > self.maxHeight {
return self.maxHeight
}
return height
}
// MARK: - UISheetPresentationControllerDelegate
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
self.onAttemptDismiss()
return !self.preventDismiss
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
self.isClosing = true
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
self.destroy()
}
func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
self.selectedDetentIdentifier = sheetPresentationController.selectedDetentIdentifier
}
}
@@ -1,67 +0,0 @@
//
// SheetViewController.swift
// Pods
//
// Created by Hailey on 9/30/24.
//
import Foundation
import UIKit
class SheetViewController: UIViewController {
init() {
super.init(nibName: nil, bundle: nil)
self.modalPresentationStyle = .formSheet
self.isModalInPresentation = false
if let sheet = self.sheetPresentationController {
sheet.prefersGrabberVisible = true
}
}
func setDetents(contentHeight: CGFloat, preventExpansion: Bool) {
guard let sheet = self.sheetPresentationController,
let screenHeight = Util.getScreenHeight()
else {
return
}
if contentHeight > screenHeight {
sheet.detents = [
.large()
]
} else {
if #available(iOS 16.0, *) {
sheet.detents = [
.custom { _ in
return contentHeight
}
]
} else {
sheet.detents = [
.medium()
]
}
}
if !preventExpansion {
sheet.detents.append(.large())
}
}
func updateDetents(contentHeight: CGFloat, preventExpansion: Bool) {
if let sheet = self.sheetPresentationController {
sheet.animateChanges {
self.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion)
if #available(iOS 16.0, *) {
sheet.invalidateDetents()
}
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
-18
View File
@@ -1,18 +0,0 @@
//
// Util.swift
// Pods
//
// Created by Hailey on 10/2/24.
//
class Util {
static func getScreenHeight() -> CGFloat? {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
let safeAreaInsets = window.safeAreaInsets
let fullScreenHeight = UIScreen.main.bounds.height
return fullScreenHeight - (safeAreaInsets.top + safeAreaInsets.bottom)
}
return nil
}
}
-111
View File
@@ -1,111 +0,0 @@
import * as React from 'react'
import {
ColorValue,
Dimensions,
NativeSyntheticEvent,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import {requireNativeModule} from 'expo'
import {requireNativeViewManager} from 'expo-modules-core'
import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
const screenHeight = Dimensions.get('screen').height
const NativeView: React.ComponentType<
BottomSheetViewProps & {
ref: React.RefObject<any>
style: StyleProp<ViewStyle>
}
> = requireNativeViewManager('BottomSheet')
const NativeModule = requireNativeModule('BottomSheet')
export class BottomSheet extends React.Component<
BottomSheetViewProps,
{
open: boolean
}
> {
ref = React.createRef<any>()
constructor(props: BottomSheetViewProps) {
super(props)
this.state = {
open: false,
}
}
present() {
this.setState({open: true})
}
dismiss() {
this.ref.current?.dismiss()
}
private onStateChange = (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => {
const {state} = event.nativeEvent
const isOpen = state !== 'closed'
this.setState({open: isOpen})
this.props.onStateChange?.(event)
}
private getBackgroundColor = (): ColorValue | undefined => {
const parent = React.Children.toArray(
this.props.children,
)[0] as React.ReactElement
if (parent?.props?.style) {
const parentStyle = StyleSheet.flatten(parent.props.style) as ViewStyle
return parentStyle.backgroundColor ?? 'transparent'
}
return undefined
}
private updateLayout = () => {
this.ref.current?.updateLayout()
}
static dismissAll = async () => {
await NativeModule.dismissAll()
}
render() {
const {children, ...rest} = this.props
const topInset = rest.topInset ?? 0
const bottomInset = rest.bottomInset ?? 0
if (!this.state.open) {
return null
}
const backgroundColor = this.getBackgroundColor()
return (
<NativeView
{...rest}
onStateChange={this.onStateChange}
ref={this.ref}
style={{
position: 'absolute',
height: screenHeight - topInset - bottomInset,
width: '100%',
}}
containerBackgroundColor={backgroundColor}>
<View
style={{
flex: 1,
backgroundColor,
paddingTop: topInset,
paddingBottom: bottomInset,
}}>
<View onLayout={this.updateLayout}>{children}</View>
</View>
</NativeView>
)
}
}
@@ -1,31 +0,0 @@
import React from 'react'
import {ColorValue, NativeSyntheticEvent} from 'react-native'
export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
export enum BottomSheetSnapPoint {
Hidden,
Partial,
Full,
}
export interface BottomSheetViewProps {
children: React.ReactNode
cornerRadius?: number
preventDismiss?: boolean
preventExpansion?: boolean
containerBackgroundColor?: ColorValue
topInset?: number
bottomInset?: number
minHeight?: number
maxHeight?: number
onAttemptDismiss?: (event: NativeSyntheticEvent<object>) => void
onSnapPointChange?: (
event: NativeSyntheticEvent<{snapPoint: BottomSheetSnapPoint}>,
) => void
onStateChange?: (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => void
}
@@ -1,5 +0,0 @@
import {BottomSheetViewProps} from './BottomSheet.types'
export function BottomSheet(_: BottomSheetViewProps) {
throw new Error('BottomSheetView is not available on web')
}
+2 -2
View File
@@ -171,11 +171,11 @@
"react-native-compressor": "^1.8.24",
"react-native-date-picker": "^4.4.2",
"react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-gesture-handler": "2.20.0",
"react-native-gesture-handler": "~2.16.2",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "0.41.2",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.14.0",
"react-native-keyboard-controller": "^1.12.1",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^9.1.3",
+1 -1
View File
@@ -78,8 +78,8 @@ import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesScreen} from '#/screens/Messages/List'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
-28
View File
@@ -901,32 +901,4 @@ export const atoms = {
hidden: {
display: 'none',
},
/*
* Transition
*/
transition_none: web({
transitionProperty: 'none',
}),
transition_all: web({
transitionProperty: 'all',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_color: web({
transitionProperty:
'color, background-color, border-color, text-decoration-color, fill, stroke',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_opacity: web({
transitionProperty: 'opacity',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_transform: web({
transitionProperty: 'transform',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
} as const
-14
View File
@@ -1,5 +1,3 @@
import {useFonts} from 'expo-font'
import {isWeb} from '#/platform/detection'
import {Device, device} from '#/storage'
@@ -65,15 +63,3 @@ export function applyFonts(
*/
style.fontVariant = ['no-contextual']
}
/*
* IMPORTANT: This is unused. Expo statically extracts these fonts.
*
* All used fonts MUST be configured here. Unused fonts can be commented out.
*/
export function DO_NOT_USE() {
return useFonts({
InterVariable: require('../../assets/fonts/inter/InterVariable.ttf'),
'InterVariable-Italic': require('../../assets/fonts/inter/InterVariable-Italic.ttf'),
})
}
-92
View File
@@ -1,92 +0,0 @@
import React from 'react'
import {StackActions, useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {flatten, useTheme} from '#/alf'
import {useDialogContext} from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {InlineLinkProps, useLink} from '#/components/Link'
import {NormalizedRNGHPressable} from '#/components/NormalizedRNGHPressable'
import {Text} from '#/components/Typography'
import {router} from '#/routes'
export function BottomSheetInlineLinkText({
children,
to,
action = 'push',
disableMismatchWarning,
style,
onPress: outerOnPress,
label,
shareOnLongPress,
disableUnderline,
...rest
}: InlineLinkProps) {
const t = useTheme()
const stringChildren = typeof children === 'string'
const navigation = useNavigation<NavigationProp>()
const dialog = useDialogContext()
const {href, isExternal, onLongPress} = useLink({
to,
displayText: stringChildren ? children : '',
action,
disableMismatchWarning,
onPress: outerOnPress,
shareOnLongPress,
})
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const onPress = () => {
if (isExternal) {
return
}
dialog.close()
if (action === 'push') {
navigation.dispatch(StackActions.push(...router.matchPath(href)))
} else if (action === 'replace') {
navigation.dispatch(StackActions.replace(...router.matchPath(href)))
} else if (action === 'navigate') {
// @ts-ignore
navigation.navigate(...router.matchPath(href))
} else {
throw Error('Unsupported navigator action.')
}
}
const flattenedStyle = flatten(style) || {}
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
return (
<NormalizedRNGHPressable
onPress={onPress}
onLongPress={onLongPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
role="link"
accessibilityLabel={label}
accessibilityHint=""
style={{flexDirection: 'row'}}>
<Text
{...rest}
style={[
{color: t.palette.primary_500},
pressed &&
!disableUnderline && {
textDecorationLine: 'underline',
textDecorationColor:
flattenedStyle.color ?? t.palette.primary_500,
},
flattenedStyle,
]}>
{children}
</Text>
</NormalizedRNGHPressable>
)
}
-1
View File
@@ -1 +0,0 @@
export {Link as BottomSheetInlineLinkText} from './Link'
+2 -20
View File
@@ -15,9 +15,7 @@ import {
import {LinearGradient} from 'expo-linear-gradient'
import {atoms as a, flatten, select, tokens, useTheme, web} from '#/alf'
import {useDialogContext} from '#/components/Dialog'
import {Props as SVGIconProps} from '#/components/icons/common'
import {NormalizedRNGHPressable} from '#/components/NormalizedRNGHPressable'
import {Text} from '#/components/Typography'
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
@@ -89,7 +87,6 @@ export type ButtonProps = Pick<
style?: StyleProp<ViewStyle>
hoverStyle?: StyleProp<ViewStyle>
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
PressableComponent?: React.ComponentType<PressableProps>
}
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
@@ -117,24 +114,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
disabled = false,
style,
hoverStyle: hoverStyleProp,
PressableComponent,
...rest
},
ref,
) => {
/*
* This will pick the correct default pressable to use. If we are inside a
* native dialog, we need to use the RNGH pressable.
*/
const {isNativeDialog} = useDialogContext()
if (!PressableComponent) {
if (isNativeDialog) {
PressableComponent = NormalizedRNGHPressable
} else {
PressableComponent = Pressable
}
}
const t = useTheme()
const [state, setState] = React.useState({
pressed: false,
@@ -466,11 +449,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
const flattenedBaseStyles = flatten([baseStyles, style])
return (
<PressableComponent
<Pressable
role="button"
accessibilityHint={undefined} // optional
{...rest}
// @ts-ignore - this will always be a pressable
ref={ref}
aria-label={label}
aria-pressed={state.pressed}
@@ -518,7 +500,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
<Context.Provider value={context}>
{typeof children === 'function' ? children(context) : children}
</Context.Provider>
</PressableComponent>
</Pressable>
)
},
)
-3
View File
@@ -6,12 +6,9 @@ import {
DialogControlRefProps,
DialogOuterProps,
} from '#/components/Dialog/types'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
export const Context = React.createContext<DialogContextProps>({
close: () => {},
isNativeDialog: false,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
})
export function useDialogContext() {
+217 -80
View File
@@ -1,33 +1,86 @@
import React, {useImperativeHandle} from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {
GestureHandlerRootView,
ScrollView,
TextInput as RNGHTextInput,
} from 'react-native-gesture-handler'
import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
Dimensions,
Keyboard,
Pressable,
StyleProp,
View,
ViewStyle,
} from 'react-native'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import BottomSheet, {
BottomSheetBackdropProps,
BottomSheetFlatList,
BottomSheetFlatListMethods,
BottomSheetScrollView,
BottomSheetScrollViewMethods,
BottomSheetTextInput,
BottomSheetView,
useBottomSheet,
WINDOW_HEIGHT,
} from '@discord/bottom-sheet/src'
import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {atoms as a, flatten, useTheme} from '#/alf'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {Context} from '#/components/Dialog/context'
import {
DialogControlProps,
DialogInnerProps,
DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {Portal as DefaultPortal} from '#/components/Portal'
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {Portal} from '#/components/Portal'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
export * from '#/components/Dialog/utils'
// @ts-ignore
export const Input = createInput(RNGHTextInput)
export const Input = createInput(BottomSheetTextInput)
function Backdrop(props: BottomSheetBackdropProps) {
const t = useTheme()
const bottomSheet = useBottomSheet()
const animatedStyle = useAnimatedStyle(() => {
const opacity =
(Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000
return {
opacity: Math.min(Math.max(opacity, 0), 0.55),
}
})
const onPress = React.useCallback(() => {
bottomSheet.close()
}, [bottomSheet])
return (
<Animated.View
style={[
t.atoms.bg_contrast_300,
{
top: 0,
left: 0,
right: 0,
bottom: 0,
position: 'absolute',
},
animatedStyle,
]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Dialog backdrop"
accessibilityHint="Press the backdrop to close the dialog"
style={{flex: 1}}
onPress={onPress}
/>
</Animated.View>
)
}
export function Outer({
children,
@@ -35,17 +88,24 @@ export function Outer({
onClose,
nativeOptions,
testID,
Portal = DefaultPortal,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const ref = React.useRef<BottomSheet>(null)
const sheet = React.useRef<BottomSheet>(null)
const sheetOptions = nativeOptions?.sheet || {}
const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets()
const closeCallbacks = React.useRef<(() => void)[]>([])
const {setDialogIsOpen} = useDialogStateControlContext()
const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Partial,
)
/*
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
*/
const [openIndex, setOpenIndex] = React.useState(-1)
/*
* `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
*/
const isOpen = openIndex > -1
const callQueuedCallbacks = React.useCallback(() => {
for (const cb of closeCallbacks.current) {
@@ -59,19 +119,25 @@ export function Outer({
closeCallbacks.current = []
}, [])
const open = React.useCallback<DialogControlProps['open']>(() => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
ref.current?.present()
}, [setDialogIsOpen, control.id, callQueuedCallbacks])
const open = React.useCallback<DialogControlProps['open']>(
({index} = {}) => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
setOpenIndex(index || 0)
sheet.current?.snapToIndex(index || 0)
},
[setDialogIsOpen, control.id, callQueuedCallbacks],
)
// This is the function that we call when we want to dismiss the dialog.
const close = React.useCallback<DialogControlProps['close']>(cb => {
if (typeof cb === 'function') {
closeCallbacks.current.push(cb)
}
ref.current?.dismiss()
sheet.current?.close()
}, [])
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
@@ -80,6 +146,8 @@ export function Outer({
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
// tells us that we need to toggle the accessibility overlay setting
setDialogIsOpen(control.id, false)
setOpenIndex(-1)
callQueuedCallbacks()
onClose?.()
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
@@ -93,82 +161,120 @@ export function Outer({
[open, close],
)
const context = React.useMemo(
() => ({close, isNativeDialog: true, nativeSnapPoint: snapPoint}),
[close, snapPoint],
)
React.useEffect(() => {
return () => {
setDialogIsOpen(control.id, false)
}
}, [control.id, setDialogIsOpen])
const Wrapper = isIOS ? View : GestureHandlerRootView
const context = React.useMemo(() => ({close}), [close])
return (
<Portal>
<Context.Provider value={context}>
<BottomSheet
ref={ref}
topInset={30}
bottomInset={insets.bottom}
onSnapPointChange={e => {
setSnapPoint(e.nativeEvent.snapPoint)
}}
onStateChange={e => {
if (e.nativeEvent.state === 'closed') {
onCloseAnimationComplete()
}
}}
cornerRadius={20}
{...nativeOptions}>
<Wrapper testID={testID} style={[t.atoms.bg]}>
{children}
</Wrapper>
</BottomSheet>
</Context.Provider>
</Portal>
isOpen && (
<Portal>
<FullWindowOverlay>
<View
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}
testID={testID}
onTouchMove={() => Keyboard.dismiss()}>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
snapPoints={sheetOptions.snapPoints || ['100%']}
ref={sheet}
index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={Backdrop}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onCloseAnimationComplete}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</View>
</FullWindowOverlay>
</Portal>
)
)
}
export function Inner({children, style}: DialogInnerProps) {
const insets = useSafeAreaInsets()
return (
<View
<BottomSheetView
style={[
a.py_xl,
a.px_xl,
{
paddingTop: 40,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
},
style,
flatten(style),
]}>
{children}
</View>
</BottomSheetView>
)
}
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner({children, style, ...props}, ref) {
const insets = useSafeAreaInsets()
const {nativeSnapPoint} = useDialogContext()
return (
<KeyboardAwareScrollView
style={[a.px_xl, style]}
ref={ref}
{...props}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
nestedScrollEnabled={true}
bottomOffset={30}
ScrollViewComponent={ScrollView}>
{children}
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
</KeyboardAwareScrollView>
)
},
)
export const InnerFlatList = React.forwardRef<
ListMethods,
ListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
export const ScrollableInner = React.forwardRef<
BottomSheetScrollViewMethods,
DialogInnerProps
>(function ScrollableInner({children, style}, ref) {
const insets = useSafeAreaInsets()
return (
<List
<BottomSheetScrollView
keyboardShouldPersistTaps="handled"
style={[
a.flex_1, // main diff is this
a.p_xl,
a.h_full,
{
paddingTop: 40,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
style,
]}
contentContainerStyle={a.pb_4xl}
ref={ref}>
{children}
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
</BottomSheetScrollView>
)
})
export const InnerFlatList = React.forwardRef<
BottomSheetFlatListMethods,
BottomSheetFlatListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
const insets = useSafeAreaInsets()
return (
<BottomSheetFlatList
keyboardShouldPersistTaps="handled"
contentContainerStyle={[a.pb_4xl, flatten(contentContainerStyle)]}
ListFooterComponent={
@@ -176,11 +282,42 @@ export const InnerFlatList = React.forwardRef<
}
ref={ref}
{...props}
style={style}
style={[
a.flex_1,
a.p_xl,
a.pt_0,
a.h_full,
{
marginTop: 40,
},
flatten(style),
]}
/>
)
})
export function Handle() {
const t = useTheme()
return (
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
<View
style={[
a.rounded_sm,
{
top: a.pt_lg.paddingTop,
width: 35,
height: 4,
alignSelf: 'center',
backgroundColor: t.palette.contrast_900,
opacity: 0.5,
},
]}
/>
</View>
)
}
export function Close() {
return null
}
+4 -2
View File
@@ -103,8 +103,6 @@ export function Outer({
const context = React.useMemo(
() => ({
close,
isNativeDialog: false,
nativeSnapPoint: 0,
}),
[close],
)
@@ -231,6 +229,10 @@ export const InnerFlatList = React.forwardRef<
)
})
export function Handle() {
return null
}
export function Close() {
const {_} = useLingui()
const {close} = React.useContext(Context)
+4 -7
View File
@@ -4,11 +4,9 @@ import type {
GestureResponderEvent,
ScrollViewProps,
} from 'react-native'
import {BottomSheetProps} from '@discord/bottom-sheet/src'
import {ViewStyleProp} from '#/alf'
import {PortalComponent} from '#/components/Portal'
import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
type A11yProps = Required<AccessibilityProps>
@@ -39,8 +37,6 @@ export type DialogControlProps = DialogControlRefProps & {
export type DialogContextProps = {
close: DialogControlProps['close']
isNativeDialog: boolean
nativeSnapPoint: BottomSheetSnapPoint
}
export type DialogControlOpenOptions = {
@@ -56,10 +52,11 @@ export type DialogControlOpenOptions = {
export type DialogOuterProps = {
control: DialogControlProps
onClose?: () => void
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
nativeOptions?: {
sheet?: Omit<BottomSheetProps, 'children'>
}
webOptions?: {}
testID?: string
Portal?: PortalComponent
}
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
@@ -0,0 +1,31 @@
import React from 'react'
import {useKeyboardHandler} from 'react-native-keyboard-controller'
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
export function KeyboardControllerPadding({maxHeight}: {maxHeight?: number}) {
const keyboardHeight = useSharedValue(0)
useKeyboardHandler(
{
onMove: e => {
'worklet'
if (maxHeight && e.height > maxHeight) {
keyboardHeight.value = maxHeight
} else {
keyboardHeight.value = e.height
}
},
},
[maxHeight],
)
const animatedStyle = useAnimatedStyle(() => ({
height: keyboardHeight.value,
}))
return <Animated.View style={animatedStyle} />
}
@@ -0,0 +1,7 @@
export function KeyboardControllerPadding({
maxHeight: _,
}: {
maxHeight?: number
}) {
return null
}
+11 -8
View File
@@ -1,19 +1,20 @@
import React, {useCallback, useMemo} from 'react'
import React, {useMemo, useCallback} from 'react'
import {ActivityIndicator, FlatList, View} from 'react-native'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import * as Dialog from '#/components/Dialog'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {Loader} from '#/components/Loader'
interface LikesDialogProps {
control: Dialog.DialogOuterProps['control']
@@ -23,6 +24,8 @@ interface LikesDialogProps {
export function LikesDialog(props: LikesDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LikesDialogInner {...props} />
</Dialog.Outer>
)
+12 -44
View File
@@ -5,7 +5,6 @@ import {
StyleProp,
ViewStyle,
} from 'react-native'
import {TouchableWithoutFeedback} from 'react-native-gesture-handler'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions, useLinkProps} from '@react-navigation/native'
@@ -25,7 +24,6 @@ import {useModalControls} from '#/state/modals'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
import {useDialogContext} from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text, TextProps} from '#/components/Typography'
import {router} from '#/routes'
@@ -105,17 +103,17 @@ export function useLink({
linkRequiresWarning(href, displayText),
)
if (isWeb) {
e.preventDefault()
}
if (requiresWarning) {
e.preventDefault()
openModal({
name: 'link-warning',
text: displayText,
href: href,
})
} else {
e.preventDefault()
if (isExternal) {
openLink(href)
} else {
@@ -287,49 +285,13 @@ export function InlineLinkText({
onOut: onPressOut,
} = useInteractionState()
const flattenedStyle = flatten(style) || {}
const {isNativeDialog} = useDialogContext()
const pressableProps = {
accessibilityLabel: label,
accessibilityHint: '',
accessibilityRole: 'link',
role: 'link',
onPress: download ? undefined : onPress,
onLongPress: onLongPress,
onPressIn: onPressIn,
onPressOut: onPressOut,
} as const
if (isNativeDialog) {
/* eslint-disable-next-line bsky-internal/avoid-unwrapped-text */
return (
<TouchableWithoutFeedback {...pressableProps}>
<Text
{...rest}
selectable={selectable}
style={[
{color: t.palette.primary_500},
(hovered || focused || pressed) &&
!disableUnderline && {
...web({outline: 0}),
textDecorationLine: 'underline',
textDecorationColor:
flattenedStyle.color ?? t.palette.primary_500,
},
flattenedStyle,
{lineHeight: 20},
]}>
{children}
</Text>
</TouchableWithoutFeedback>
)
}
return (
<Text
selectable={selectable}
accessibilityHint=""
accessibilityLabel={label}
{...rest}
{...pressableProps}
style={[
{color: t.palette.primary_500},
(hovered || focused || pressed) &&
@@ -340,10 +302,16 @@ export function InlineLinkText({
},
flattenedStyle,
]}
role="link"
onPress={download ? undefined : onPress}
onLongPress={onLongPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
onFocus={onFocus}
onBlur={onBlur}
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}
accessibilityRole="link"
href={href}
{...web({
hrefAttrs: {
+9 -11
View File
@@ -1,10 +1,10 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import flattenReactChildren from 'react-keyed-flatten-children'
import {isNative} from '#/platform/detection'
import {isNative} from 'platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -18,7 +18,6 @@ import {
ItemTextProps,
TriggerProps,
} from '#/components/Menu/types'
import {NormalizedRNGHPressable} from '#/components/NormalizedRNGHPressable'
import {Text} from '#/components/Typography'
export {
@@ -85,9 +84,9 @@ export function Outer({
const context = React.useContext(Context)
return (
<Dialog.Outer
control={context.control}
nativeOptions={{preventExpansion: true}}>
<Dialog.Outer control={context.control}>
<Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}>
<Dialog.ScrollableInner label="Menu TODO">
@@ -113,20 +112,19 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
} = useInteractionState()
return (
<NormalizedRNGHPressable
<Pressable
{...rest}
accessibilityHint=""
accessibilityLabel={label}
onFocus={onFocus}
onBlur={onBlur}
onPress={e => {
control?.close()
onPress(e)
if (!e.defaultPrevented) {
control?.close()
}
}}
onFocus={onFocus}
onBlur={onBlur}
onPressIn={e => {
onPressIn()
rest.onPressIn?.(e)
@@ -151,7 +149,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
{children}
</ItemContext.Provider>
</NormalizedRNGHPressable>
</Pressable>
)
}
+4 -3
View File
@@ -5,12 +5,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
import {HITSLOP_10} from '#/lib/constants'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isNative} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {HITSLOP_10} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -78,6 +78,7 @@ export function NewskieDialog({
</Button>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`New user info dialog`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
-130
View File
@@ -1,130 +0,0 @@
import React from 'react'
import {
GestureResponderEvent,
MeasureOnSuccessCallback,
NativeMouseEvent,
NativeSyntheticEvent,
PressableProps,
} from 'react-native'
import {Pressable as BSPressable} from 'react-native-gesture-handler'
import {PressableEvent} from 'react-native-gesture-handler/lib/typescript/components/Pressable/PressableProps'
function pressableEventToGestureResponderEvent(
event: PressableEvent,
target: NormalizedRNGHPressable,
): GestureResponderEvent {
return {
nativeEvent: {
...event.nativeEvent,
touches: [],
changedTouches: [],
identifier: event.nativeEvent.identifier.toString(),
target: event.nativeEvent.target.toString(),
},
// @ts-expect-error
target: target,
// @ts-expect-error
currentTarget: target,
preventDefault() {},
stopPropagation() {},
cancelable: false,
defaultPrevented: false,
eventPhase: 0,
isTrusted: false,
bubbles: false,
timeStamp: event.nativeEvent.timestamp,
isDefaultPrevented(): boolean {
return false
},
isPropagationStopped(): boolean {
return false
},
persist() {},
type: 'press',
}
}
function pressableEventToMouseEvent(
event: PressableEvent,
target: NormalizedRNGHPressable,
): MouseEvent & NativeSyntheticEvent<NativeMouseEvent> {
return {
...event.nativeEvent,
// @ts-expect-error
target: target,
// @ts-expect-error
currentTarget: target,
preventDefault() {},
stopPropagation() {},
cancelable: false,
defaultPrevented: false,
eventPhase: 0,
isTrusted: false,
bubbles: false,
timeStamp: event.nativeEvent.timestamp,
}
}
export class NormalizedRNGHPressable extends React.Component<PressableProps> {
static displayName = 'Pressable'
measure = (_: MeasureOnSuccessCallback) => {}
measureLayout = (_: number) => {}
measureInWindow = (
_: (x: number, y: number, width: number, height: number) => void,
) => {}
setNativeProps = (_: PressableProps) => {}
focus = () => {}
blur = () => {}
onPress = (event: PressableEvent) => {
if (!this.props.onPress) return
this.props.onPress(pressableEventToGestureResponderEvent(event, this))
}
onLongPress = (event: PressableEvent) => {
if (!this.props.onLongPress) return
this.props.onLongPress(pressableEventToGestureResponderEvent(event, this))
}
onPressIn = (event: PressableEvent) => {
if (!this.props.onPressIn) return
this.props.onPressIn(pressableEventToGestureResponderEvent(event, this))
}
onPressOut = (event: PressableEvent) => {
if (!this.props.onPressOut) return
this.props.onPressOut(pressableEventToGestureResponderEvent(event, this))
}
onHoverIn = (event: PressableEvent) => {
if (!this.props.onHoverIn) return
this.props.onHoverIn(pressableEventToMouseEvent(event, this))
}
onHoverOut = (event: PressableEvent) => {
if (!this.props.onHoverOut) return
this.props.onHoverOut(pressableEventToMouseEvent(event, this))
}
render() {
return (
<BSPressable
accessible={true}
accessibilityRole="button"
{...this.props}
onPress={this.onPress}
onLongPress={this.onLongPress}
onPressIn={this.onPressIn}
onPressOut={this.onPressOut}
onHoverIn={this.onHoverIn}
onHoverOut={this.onHoverOut}
/>
)
}
}
@@ -1,3 +0,0 @@
import {Pressable as NormalizedRNGHPressable} from 'react-native'
export {NormalizedRNGHPressable}
-2
View File
@@ -12,8 +12,6 @@ type ComponentMap = {
[id: string]: Component
}
export type PortalComponent = ({children}: {children?: React.ReactNode}) => null
export function createPortalGroup() {
const Context = React.createContext<ContextType>({
outlet: null,
+7 -10
View File
@@ -4,9 +4,8 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonColor, ButtonText} from '#/components/Button'
import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
export {
@@ -26,11 +25,9 @@ export function Outer({
children,
control,
testID,
Portal,
}: React.PropsWithChildren<{
control: Dialog.DialogControlProps
testID?: string
Portal?: PortalComponent
}>) {
const {gtMobile} = useBreakpoints()
const titleId = React.useId()
@@ -42,8 +39,10 @@ export function Outer({
)
return (
<Dialog.Outer control={control} testID={testID} Portal={Portal}>
<Dialog.Outer control={control} testID={testID}>
<Context.Provider value={context}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
@@ -142,7 +141,7 @@ export function Action({
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onPress: (e: GestureResponderEvent) => void
onPress: ButtonProps['onPress']
color?: ButtonColor
/**
* Optional i18n string. If undefined, it will default to "Confirm".
@@ -182,7 +181,6 @@ export function Basic({
onConfirm,
confirmButtonColor,
showCancel = true,
Portal,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
title: string
@@ -196,13 +194,12 @@ export function Basic({
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onConfirm: (e: GestureResponderEvent) => void
onConfirm: ButtonProps['onPress']
confirmButtonColor?: ButtonColor
showCancel?: boolean
Portal?: PortalComponent
}>) {
return (
<Outer control={control} testID="confirmModal" Portal={Portal}>
<Outer control={control} testID="confirmModal">
<TitleText>{title}</TitleText>
<DescriptionText>{description}</DescriptionText>
<Actions>
@@ -4,6 +4,7 @@ import {AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, useButtonContext} from '#/components/Button'
+1 -3
View File
@@ -16,7 +16,6 @@ import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {Loader} from '#/components/Loader'
import {NormalizedRNGHPressable} from '#/components/NormalizedRNGHPressable'
import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types'
@@ -154,8 +153,7 @@ export function SubmitView({
<Toggle.Item
key={labeler.creator.did}
name={labeler.creator.did}
label={title}
PressableComponent={NormalizedRNGHPressable}>
label={title}>
<LabelerToggle title={title} />
</Toggle.Item>
)
+8 -2
View File
@@ -1,6 +1,5 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -9,10 +8,12 @@ import {useMyLabelersQuery} from '#/state/queries/preferences'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {AppBskyLabelerDefs} from '@atproto/api'
import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src'
import {atoms as a} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {SelectLabelerView} from './SelectLabelerView'
@@ -23,6 +24,8 @@ import {ReportDialogProps} from './types'
export function ReportDialog(props: ReportDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<ReportDialogInner {...props} />
</Dialog.Outer>
)
@@ -37,7 +40,10 @@ function ReportDialogInner(props: ReportDialogProps) {
} = useMyLabelersQuery()
const isLoading = useDelayedLoading(500, isLabelerLoading)
const ref = React.useRef<ScrollView>(null)
const ref = React.useRef<BottomSheetScrollViewMethods>(null)
useOnKeyboardDidShow(() => {
ref.current?.scrollToEnd({animated: true})
})
return (
<Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
@@ -149,6 +149,7 @@ export function QrCodeDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Create a QR code for a starter pack`)}>
<View style={[a.flex_1, a.align_center, a.gap_5xl]}>
+8 -7
View File
@@ -6,14 +6,14 @@ import {AppBskyGraphDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {saveImageToMediaLibrary} from '#/lib/media/manip'
import {shareUrl} from '#/lib/sharing'
import {logEvent} from '#/lib/statsig/statsig'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import * as Toast from '#/view/com/util/Toast'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {saveImageToMediaLibrary} from 'lib/media/manip'
import {shareUrl} from 'lib/sharing'
import {logEvent} from 'lib/statsig/statsig'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {isNative, isWeb} from 'platform/detection'
import * as Toast from 'view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {DialogControlProps} from '#/components/Dialog'
@@ -84,6 +84,7 @@ function ShareDialogInner({
return (
<>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Share link dialog`)}>
{!imageLoaded || !link ? (
<View style={[a.p_xl, a.align_center]}>
@@ -3,13 +3,13 @@ import type {ListRenderItemInfo} from 'react-native'
import {View} from 'react-native'
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {isWeb} from '#/platform/detection'
import {useSession} from '#/state/session'
import {ListMethods} from '#/view/com/util/List'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -45,7 +45,7 @@ export function WizardEditListDialog({
const {currentAccount} = useSession()
const initialNumToRender = useInitialNumToRender()
const listRef = useRef<ListMethods>(null)
const listRef = useRef<BottomSheetFlatListMethods>(null)
const getData = () => {
if (state.currentStep === 'Feeds') return state.feeds
@@ -76,7 +76,11 @@ export function WizardEditListDialog({
)
return (
<Dialog.Outer control={control} testID="newChatDialog">
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['95%']}}}>
<Dialog.Handle />
<Dialog.InnerFlatList
ref={listRef}
data={getData()}
@@ -99,7 +103,13 @@ export function WizardEditListDialog({
height: 48,
},
]
: [a.pb_sm, a.align_end],
: [
a.pb_sm,
a.align_end,
{
height: 68,
},
],
]}>
<View style={{width: 60}} />
<Text style={[a.font_bold, a.text_xl]}>
@@ -133,6 +143,8 @@ export function WizardEditListDialog({
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
+2
View File
@@ -84,6 +84,8 @@ export function TagMenu({
{children}
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
{isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}>
+1
View File
@@ -170,6 +170,7 @@ function WhoCanReplyDialog({
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Dialog: adjust who can interact with this post`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
@@ -30,6 +30,8 @@ export function BirthDateSettingsDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`My Birthday`)}>
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
+1
View File
@@ -27,6 +27,7 @@ type EmbedDialogProps = {
let EmbedDialog = ({control, ...rest}: EmbedDialogProps): React.ReactNode => {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<EmbedDialogInner {...rest} />
</Dialog.Outer>
)
+2
View File
@@ -49,6 +49,8 @@ export function EmbedConsentDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`External Media`)}
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
+2
View File
@@ -22,6 +22,7 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Button, ButtonText} from '../Button'
import {Handle} from '../Dialog'
import {useThrottledValue} from '../hooks/useThrottledValue'
import {ListFooter, ListMaybePlaceholder} from '../Lists'
import {GifPreview} from './GifSelect.shared'
@@ -69,6 +70,7 @@ export function GifSelectDialog({
aria-modal
accessibilityViewIsModal>
<View style={[a.flex_1, t.atoms.bg]}>
<Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<GifList onSelectGif={onSelectGif} close={close} />
</ErrorBoundary>
+7 -3
View File
@@ -6,6 +6,7 @@ import React, {
useState,
} from 'react'
import {TextInput, View} from 'react-native'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -18,7 +19,6 @@ import {
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {ListMethods} from '#/view/com/util/List'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
@@ -57,7 +57,11 @@ export function GifSelectDialog({
)
return (
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.Outer
control={control}
nativeOptions={{sheet: {snapPoints: ['100%']}}}
onClose={onClose}>
<Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<GifList control={control} onSelectGif={onSelectGif} />
</ErrorBoundary>
@@ -76,7 +80,7 @@ function GifList({
const t = useTheme()
const {gtMobile} = useBreakpoints()
const textInputRef = useRef<TextInput>(null)
const listRef = useRef<ListMethods>(null)
const listRef = useRef<BottomSheetFlatListMethods>(null)
const [undeferredSearch, setSearch] = useState('')
const search = useThrottledValue(undeferredSearch, 500)
+288 -341
View File
@@ -30,19 +30,16 @@ import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/P
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import {NormalizedRNGHPressable} from '#/components/NormalizedRNGHPressable'
import {createPortalGroup} from '#/components/Portal'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
const ONE_DAY = 24 * 60 * 60 * 1000
const Portal = createPortalGroup()
export function MutedWordsDialog() {
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<MutedWordsInner />
</Dialog.Outer>
)
@@ -108,356 +105,307 @@ function MutedWordsInner() {
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
return (
<Portal.Provider>
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<View>
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<View>
<Text
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
<Trans>Add muted words and tags</Trans>
</Text>
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Posts can be muted based on their text, their tags, or both. We
recommend avoiding common words that appear in many posts, since it
can result in no posts being shown.
</Trans>
</Text>
<View style={[a.pb_sm]}>
<Dialog.Input
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
label={_(msg`Enter a word or tag`)}
placeholder={_(msg`Enter a word or tag`)}
value={field}
onChangeText={value => {
if (error) {
setError('')
}
setField(value)
}}
onSubmitEditing={submit}
/>
</View>
<View style={[a.pb_xl, a.gap_sm]}>
<Toggle.Group
label={_(msg`Select how long to mute this word for.`)}
type="radio"
values={durations}
onChange={setDurations}>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Duration:</Trans>
</Text>
<View
style={[
gtMobile && [a.flex_row, a.align_center, a.justify_start],
a.gap_sm,
]}>
<View
style={[
a.flex_1,
a.flex_row,
a.justify_start,
a.align_center,
a.gap_sm,
]}>
<Toggle.Item
label={_(msg`Mute this word until you unmute it`)}
name="forever"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Forever</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word for 24 hours`)}
name="24_hours"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>24 hours</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
<View
style={[
a.flex_1,
a.flex_row,
a.justify_start,
a.align_center,
a.gap_sm,
]}>
<Toggle.Item
label={_(msg`Mute this word for 7 days`)}
name="7_days"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>7 days</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word for 30 days`)}
name="30_days"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>30 days</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
</View>
</Toggle.Group>
<Toggle.Group
label={_(msg`Select what content this mute word should apply to.`)}
type="radio"
values={targets}
onChange={setTargets}>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Mute in:</Trans>
</Text>
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
<Toggle.Item
label={_(msg`Mute this word in post text and tags`)}
name="content"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Text & tags</Trans>
</Toggle.LabelText>
</View>
<PageText size="sm" />
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word in tags only`)}
name="tag"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Tags only</Trans>
</Toggle.LabelText>
</View>
<Hashtag size="sm" />
</TargetToggle>
</Toggle.Item>
</View>
</Toggle.Group>
<View>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Options:</Trans>
</Text>
<Toggle.Item
label={_(msg`Do not apply this mute word to users you follow`)}
name="exclude_following"
style={[a.flex_row, a.justify_between]}
value={excludeFollowing}
onChange={setExcludeFollowing}>
<TargetToggle>
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Exclude users you follow</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
<View style={[a.pt_xs]}>
<Button
disabled={isPending || !field}
label={_(msg`Add mute word for configured settings`)}
size="large"
color="primary"
variant="solid"
style={[]}
onPress={submit}>
<ButtonText>
<Trans>Add</Trans>
</ButtonText>
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
</Button>
</View>
{error && (
<View
style={[
a.mb_lg,
a.flex_row,
a.rounded_sm,
a.p_md,
a.mb_xs,
t.atoms.bg_contrast_25,
{
backgroundColor: t.palette.negative_400,
},
]}>
<Text
style={[
a.italic,
{color: t.palette.white},
native({marginTop: 2}),
]}>
{error}
</Text>
</View>
)}
</View>
<Divider />
<View style={[a.pt_2xl]}>
<Text
style={[
a.text_md,
a.font_bold,
a.pb_sm,
a.pb_md,
t.atoms.text_contrast_high,
]}>
<Trans>Add muted words and tags</Trans>
</Text>
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Posts can be muted based on their text, their tags, or both. We
recommend avoiding common words that appear in many posts, since
it can result in no posts being shown.
</Trans>
<Trans>Your muted words</Trans>
</Text>
<View style={[a.pb_sm]}>
<Dialog.Input
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
label={_(msg`Enter a word or tag`)}
placeholder={_(msg`Enter a word or tag`)}
value={field}
onChangeText={value => {
if (error) {
setError('')
}
setField(value)
}}
onSubmitEditing={submit}
/>
</View>
<View style={[a.pb_xl, a.gap_sm]}>
<Toggle.Group
label={_(msg`Select how long to mute this word for.`)}
type="radio"
values={durations}
onChange={setDurations}>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Duration:</Trans>
{isPreferencesLoading ? (
<Loader />
) : preferencesError || !preferences ? (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>
We're sorry, but we weren't able to load your muted words at
this time. Please try again.
</Trans>
</Text>
<View
style={[
gtMobile && [a.flex_row, a.align_center, a.justify_start],
a.gap_sm,
]}>
<View
style={[
a.flex_1,
a.flex_row,
a.justify_start,
a.align_center,
a.gap_sm,
]}>
<Toggle.Item
label={_(msg`Mute this word until you unmute it`)}
name="forever"
style={[a.flex_1]}
PressableComponent={NormalizedRNGHPressable}>
<TargetToggle>
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Forever</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word for 24 hours`)}
name="24_hours"
style={[a.flex_1]}
PressableComponent={NormalizedRNGHPressable}>
<TargetToggle>
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>24 hours</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
<View
style={[
a.flex_1,
a.flex_row,
a.justify_start,
a.align_center,
a.gap_sm,
]}>
<Toggle.Item
label={_(msg`Mute this word for 7 days`)}
name="7_days"
style={[a.flex_1]}
PressableComponent={NormalizedRNGHPressable}>
<TargetToggle>
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>7 days</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word for 30 days`)}
name="30_days"
style={[a.flex_1]}
PressableComponent={NormalizedRNGHPressable}>
<TargetToggle>
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>30 days</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
</View>
</Toggle.Group>
<Toggle.Group
label={_(
msg`Select what content this mute word should apply to.`,
)}
type="radio"
values={targets}
onChange={setTargets}>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Mute in:</Trans>
</Text>
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
<Toggle.Item
label={_(msg`Mute this word in post text and tags`)}
name="content"
style={[a.flex_1]}
PressableComponent={NormalizedRNGHPressable}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Text & tags</Trans>
</Toggle.LabelText>
</View>
<PageText size="sm" />
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word in tags only`)}
name="tag"
style={[a.flex_1]}
PressableComponent={NormalizedRNGHPressable}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Tags only</Trans>
</Toggle.LabelText>
</View>
<Hashtag size="sm" />
</TargetToggle>
</Toggle.Item>
</View>
</Toggle.Group>
<View>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Options:</Trans>
</Text>
<Toggle.Item
label={_(msg`Do not apply this mute word to users you follow`)}
name="exclude_following"
style={[a.flex_row, a.justify_between]}
value={excludeFollowing}
onChange={setExcludeFollowing}
PressableComponent={NormalizedRNGHPressable}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Exclude users you follow</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
<View style={[a.pt_xs]}>
<Button
disabled={isPending || !field}
label={_(msg`Add mute word for configured settings`)}
size="large"
color="primary"
variant="solid"
style={[]}
onPress={submit}>
<ButtonText>
<Trans>Add</Trans>
</ButtonText>
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
</Button>
) : preferences.moderationPrefs.mutedWords.length ? (
[...preferences.moderationPrefs.mutedWords]
.reverse()
.map((word, i) => (
<MutedWordRow
key={word.value + i}
word={word}
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
/>
))
) : (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>You haven't muted any words or tags yet</Trans>
</Text>
</View>
{error && (
<View
style={[
a.mb_lg,
a.flex_row,
a.rounded_sm,
a.p_md,
a.mb_xs,
t.atoms.bg_contrast_25,
{
backgroundColor: t.palette.negative_400,
},
]}>
<Text
style={[
a.italic,
{color: t.palette.white},
native({marginTop: 2}),
]}>
{error}
</Text>
</View>
)}
</View>
<Divider />
<View style={[a.pt_2xl]}>
<Text
style={[
a.text_md,
a.font_bold,
a.pb_md,
t.atoms.text_contrast_high,
]}>
<Trans>Your muted words</Trans>
</Text>
{isPreferencesLoading ? (
<Loader />
) : preferencesError || !preferences ? (
<View
style={[
a.py_md,
a.px_lg,
a.rounded_md,
t.atoms.bg_contrast_25,
]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>
We're sorry, but we weren't able to load your muted words at
this time. Please try again.
</Trans>
</Text>
</View>
) : preferences.moderationPrefs.mutedWords.length ? (
[...preferences.moderationPrefs.mutedWords]
.reverse()
.map((word, i) => (
<MutedWordRow
key={word.value + i}
word={word}
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
/>
))
) : (
<View
style={[
a.py_md,
a.px_lg,
a.rounded_md,
t.atoms.bg_contrast_25,
]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>You haven't muted any words or tags yet</Trans>
</Text>
</View>
)}
</View>
{isNative && <View style={{height: 20}} />}
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
{isNative && <View style={{height: 20}} />}
</View>
<Portal.Outlet />
</Portal.Provider>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
@@ -489,7 +437,6 @@ function MutedWordRow({
onConfirm={remove}
confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative"
Portal={Portal.Portal}
/>
<View
@@ -37,8 +37,6 @@ import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {NormalizedRNGHPressable} from '#/components/NormalizedRNGHPressable'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
export type PostInteractionSettingsFormProps = {
@@ -56,15 +54,14 @@ export type PostInteractionSettingsFormProps = {
export function PostInteractionSettingsControlledDialog({
control,
Portal,
...rest
}: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps
Portal?: PortalComponent
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control} Portal={Portal}>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
@@ -99,6 +96,7 @@ export function PostInteractionSettingsDialog(
) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<PostInteractionSettingsDialogControlledInner {...props} />
</Dialog.Outer>
)
@@ -233,6 +231,7 @@ export function PostInteractionSettingsForm({
}: PostInteractionSettingsFormProps) {
const t = useTheme()
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = React.useState(
!(
@@ -306,8 +305,7 @@ export function PostInteractionSettingsForm({
}
value={quotesEnabled}
onChange={onChangeQuotesEnabled}
style={[, a.justify_between, a.pt_xs]}
PressableComponent={NormalizedRNGHPressable}>
style={[, a.justify_between, a.pt_xs]}>
<Text style={[t.atoms.text_contrast_medium]}>
{quotesEnabled ? (
<Trans>Quote posts enabled</Trans>
@@ -439,6 +437,7 @@ export function PostInteractionSettingsForm({
<Button
label={_(msg`Save`)}
onPress={onSave}
onAccessibilityEscape={control.close}
color="primary"
size="large"
variant="solid"
+1
View File
@@ -18,6 +18,7 @@ export function SigninDialog() {
const {signinDialogControl: control} = useGlobalDialogsControlContext()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<SigninDialogInner control={control} />
</Dialog.Outer>
)
+2
View File
@@ -42,6 +42,8 @@ export function SwitchAccountDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
<View style={[a.gap_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
@@ -43,6 +43,8 @@ export function NeueTypography() {
return (
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Introducing new font settings`)}>
<View style={[a.gap_xl]}>
<View style={[a.gap_md]}>
+3 -3
View File
@@ -136,7 +136,7 @@ let ConvoMenu = ({
<Menu.Outer>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
onPress={leaveConvoControl.open}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
@@ -195,7 +195,7 @@ let ConvoMenu = ({
</Menu.Item>
<Menu.Item
label={_(msg`Report conversation`)}
onPress={() => reportControl.open()}>
onPress={reportControl.open}>
<Menu.ItemText>
<Trans>Report conversation</Trans>
</Menu.ItemText>
@@ -206,7 +206,7 @@ let ConvoMenu = ({
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
onPress={leaveConvoControl.open}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
+5 -5
View File
@@ -7,11 +7,11 @@ import {useLingui} from '@lingui/react'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {isWeb} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {useSession} from '#/state/session'
import {isWeb} from 'platform/detection'
import {useConvoActive} from 'state/messages/convo'
import {useSession} from 'state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {ReportDialog} from '#/components/dms/ReportDialog'
@@ -120,7 +120,7 @@ export let MessageMenu = ({
<Menu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
onPress={() => deleteControl.open()}>
onPress={deleteControl.open}>
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
@@ -128,7 +128,7 @@ export let MessageMenu = ({
<Menu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={() => reportControl.open()}>
onPress={reportControl.open}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
+175
View File
@@ -0,0 +1,175 @@
import React, {useCallback, useEffect} from 'react'
import {View} from 'react-native'
import {ChatBskyActorDeclaration} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Message_Stroke2_Corner0_Rounded} from '#/components/icons/Message'
import {Text} from '#/components/Typography'
export function MessagesNUX() {
const control = Dialog.useDialogControl()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({
did: currentAccount!.did,
})
useEffect(() => {
if (profile && typeof profile.associated?.chat === 'undefined') {
const timeout = setTimeout(() => {
control.open()
}, 1000)
return () => {
clearTimeout(timeout)
}
}
}, [profile, control])
if (!profile) return null
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<DialogInner chatDeclation={profile.associated?.chat} />
</Dialog.Outer>
)
}
function DialogInner({
chatDeclation,
}: {
chatDeclation?: ChatBskyActorDeclaration.Record
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const t = useTheme()
const [initialized, setInitialzed] = React.useState(false)
const {mutate: updateDeclaration} = useUpdateActorDeclaration({
onError: () => {
Toast.show(_(msg`Failed to update settings`), 'xmark')
},
})
const onSelectItem = useCallback(
(keys: string[]) => {
const key = keys[0]
if (!key) return
updateDeclaration(key as 'all' | 'none' | 'following')
},
[updateDeclaration],
)
useEffect(() => {
if (!chatDeclation && !initialized) {
updateDeclaration('following')
setInitialzed(true)
}
}, [chatDeclation, updateDeclaration, initialized])
return (
<Dialog.ScrollableInner
label={_(msg`Introducing Direct Messages`)}
style={web({maxWidth: 440})}>
<View style={a.gap_xl}>
<View style={[a.align_center, a.pt_sm, a.pb_xs]}>
<Message_Stroke2_Corner0_Rounded width={64} />
<Text style={[a.text_2xl, a.font_bold, a.text_center, a.mt_md]}>
<Trans>Direct messages are here!</Trans>
</Text>
<Text style={[a.text_md, a.text_center, a.mt_sm]}>
<Trans>Privately chat with other users.</Trans>
</Text>
</View>
<View
style={[
a.gap_xs,
a.border,
a.overflow_hidden,
a.rounded_sm,
t.atoms.border_contrast_low,
]}>
<View
style={[
a.p_md,
a.border_b,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
]}>
<Text style={[a.text_sm, a.font_bold]}>
<Trans>Who can message you?</Trans>
</Text>
<Text
style={[
a.mt_xs,
a.text_sm,
a.italic,
t.atoms.text_contrast_medium,
]}>
<Trans>You can change this at any time.</Trans>
</Text>
</View>
<View style={[a.px_md, a.py_xs]}>
<Toggle.Group
label={_(msg`Who can message you?`)}
type="radio"
values={[chatDeclation?.allowIncoming ?? 'following']}
onChange={onSelectItem}>
<View>
<Toggle.Item
name="all"
label={_(msg`Everyone`)}
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
<Toggle.LabelText>
<Trans>Everyone</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
<Toggle.Item
name="following"
label={_(msg`Users I follow`)}
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
<Toggle.LabelText>
<Trans>Users I follow</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
<Toggle.Item
name="none"
label={_(msg`No one`)}
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
<Toggle.LabelText>
<Trans>No one</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
</View>
</Toggle.Group>
</View>
</View>
<Button
label={_(msg`Start chatting`)}
accessibilityHint={_(msg`Close modal`)}
size="large"
color="primary"
variant="solid"
onPress={() => control.close()}>
<ButtonText>
<Trans>Get started</Trans>
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
+7 -1
View File
@@ -10,11 +10,13 @@ import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {isAndroid} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {KeyboardControllerPadding} from '#/components/KeyboardControllerPadding'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {Divider} from '../Divider'
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
@@ -39,10 +41,14 @@ let ReportDialog = ({
}): React.ReactNode => {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Outer
control={control}
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Report this message`)}>
<DialogInner params={params} />
<Dialog.Close />
<KeyboardControllerPadding />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
+5 -2
View File
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast'
import {useTheme} from '#/alf'
@@ -55,7 +55,10 @@ export function NewChat({
accessibilityHint=""
/>
<Dialog.Outer control={control} testID="newChatDialog">
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SearchablePeopleList
title={_(msg`Start a new chat`)}
onSelectChat={onCreateChat}
@@ -5,8 +5,10 @@ import React, {
useRef,
useState,
} from 'react'
import {TextInput, View} from 'react-native'
import type {TextInput as TextInputType} from 'react-native'
import {View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -14,15 +16,15 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useListConvosQuery} from '#/state/queries/messages/list-converations'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import {ListMethods} from '#/view/com/util/List'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {TextInput} from '#/components/dms/dialogs/TextInput'
import {canBeMessaged} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
@@ -64,9 +66,9 @@ export function SearchablePeopleList({
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const listRef = useRef<ListMethods>(null)
const listRef = useRef<BottomSheetFlatListMethods>(null)
const {currentAccount} = useSession()
const inputRef = useRef<TextInput>(null)
const inputRef = useRef<TextInputType>(null)
const [searchText, setSearchText] = useState('')
@@ -240,12 +242,13 @@ export function SearchablePeopleList({
<View
style={[
a.relative,
web(a.pt_md),
a.pt_md,
a.pb_xs,
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
t.atoms.bg,
native([a.pt_lg]),
]}>
<View
style={[
@@ -471,7 +474,7 @@ function SearchInput({
value: string
onChangeText: (text: string) => void
onEscape: () => void
inputRef: React.RefObject<TextInput>
inputRef: React.RefObject<TextInputType>
}) {
const t = useTheme()
const {_} = useLingui()
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import * as Toast from '#/view/com/util/Toast'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from './SearchablePeopleList'
@@ -17,7 +17,10 @@ export function SendViaChatDialog({
onSelectChat: (chatId: string) => void
}) {
return (
<Dialog.Outer control={control} testID="sendViaChatChatDialog">
<Dialog.Outer
control={control}
testID="sendViaChatChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
</Dialog.Outer>
)
+1
View File
@@ -57,6 +57,7 @@ export function DateField({
accessibilityHint={accessibilityHint}
/>
<Dialog.Outer control={control} testID={testID}>
<Dialog.Handle />
<Dialog.Inner label={label}>
<View style={a.gap_lg}>
<View style={[a.relative, a.w_full, a.align_center]}>
-75
View File
@@ -1,75 +0,0 @@
import React from 'react'
import {TextInput, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {isNative} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass2'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
type SearchInputProps = Omit<TextField.InputProps, 'label'> & {
label?: TextField.InputProps['label']
/**
* Called when the user presses the (X) button
*/
onClearText?: () => void
}
export const SearchInput = React.forwardRef<TextInput, SearchInputProps>(
function SearchInput({value, label, onClearText, ...rest}, ref) {
const t = useTheme()
const {_} = useLingui()
return (
<View style={[a.w_full, a.relative]}>
<TextField.Root>
<TextField.Icon icon={MagnifyingGlassIcon} />
<TextField.Input
inputRef={ref}
label={label || _(msg`Search`)}
value={value}
placeholder={_(msg`Search`)}
returnKeyType="search"
keyboardAppearance={t.scheme}
selectTextOnFocus={isNative}
autoFocus={false}
accessibilityRole="search"
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
{...rest}
/>
</TextField.Root>
{value && value.length > 0 && (
<View
style={[
a.absolute,
a.z_10,
a.my_auto,
a.inset_0,
a.justify_center,
a.pr_sm,
{left: 'auto'},
]}>
<Button
testID="searchTextInputClearBtn"
onPress={onClearText}
label={_(msg`Clear search query`)}
hitSlop={HITSLOP_10}
size="tiny"
shape="round"
variant="ghost"
color="secondary">
<ButtonIcon icon={X} size="xs" />
</Button>
</View>
)}
</View>
)
},
)
+1 -1
View File
@@ -126,7 +126,7 @@ export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
value?: string
onChangeText?: (value: string) => void
isInvalid?: boolean
inputRef?: React.RefObject<TextInput> | React.ForwardedRef<TextInput>
inputRef?: React.RefObject<TextInput>
}
export function createInput(Component: typeof TextInput) {
+4 -6
View File
@@ -1,9 +1,9 @@
import React from 'react'
import {Pressable, PressableProps, View, ViewStyle} from 'react-native'
import {Pressable, View, ViewStyle} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import {HITSLOP_10} from '#/lib/constants'
import {isNative} from '#/platform/detection'
import {HITSLOP_10} from 'lib/constants'
import {
atoms as a,
flatten,
@@ -68,7 +68,6 @@ export type ItemProps = ViewStyleProp & {
onChange?: (selected: boolean) => void
isInvalid?: boolean
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
PressableComponent?: React.ComponentType<PressableProps>
}
export function useItemContext() {
@@ -160,7 +159,6 @@ export function Item({
style,
type = 'checkbox',
label,
PressableComponent = Pressable,
...rest
}: ItemProps) {
const {
@@ -208,7 +206,7 @@ export function Item({
return (
<ItemContext.Provider value={state}>
<PressableComponent
<Pressable
accessibilityHint={undefined} // optional
hitSlop={HITSLOP_10}
{...rest}
@@ -233,7 +231,7 @@ export function Item({
onBlur={onBlur}
style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}>
{typeof children === 'function' ? children(state) : children}
</PressableComponent>
</Pressable>
</ItemContext.Provider>
)
}
@@ -3,14 +3,11 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection'
import {useAgent, useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useAgent, useSession} from 'state/session'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {DialogControlProps} from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotateCounterClockwise'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
@@ -20,14 +17,13 @@ export function VerifyEmailIntentDialog() {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Inner control={control} />
</Dialog.Outer>
)
}
function Inner({}: {control: DialogControlProps}) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
function Inner({control}: {control: DialogControlProps}) {
const {_} = useLingui()
const {verifyEmailState: state} = useIntentDialogs()
const [status, setStatus] = React.useState<
@@ -62,47 +58,43 @@ function Inner({}: {control: DialogControlProps}) {
}
return (
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
<Dialog.ScrollableInner label={_(msg`Verify email dialog`)}>
<Dialog.Close />
<View style={[a.gap_xl]}>
{status === 'loading' ? (
<View style={[a.py_2xl, a.align_center, a.justify_center]}>
<Loader size="xl" fill={t.atoms.text_contrast_low.color} />
<Loader size="xl" />
</View>
) : status === 'success' ? (
<View style={[a.gap_sm, isNative && a.pb_xl]}>
<Text style={[a.font_heavy, a.text_2xl]}>
<>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>Email Verified</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Text style={[a.text_md, a.leading_tight]}>
<Trans>
Thanks, you have successfully verified your email address. You
can close this dialog.
Thanks, you have successfully verified your email address.
</Trans>
</Text>
</View>
</>
) : status === 'failure' ? (
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
<>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>Invalid Verification Code</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Text style={[a.text_md, a.leading_tight]}>
<Trans>
The verification code you have provided is invalid. Please make
sure that you have used the correct verification link or request
a new one.
</Trans>
</Text>
</View>
</>
) : (
<View style={[a.gap_sm, isNative && a.pb_xl]}>
<Text style={[a.font_heavy, a.text_2xl]}>
<>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>Email Resent</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Text style={[a.text_md, a.leading_tight]}>
<Trans>
We have sent another verification email to{' '}
<Text style={[a.text_md, a.font_bold]}>
@@ -111,29 +103,38 @@ function Inner({}: {control: DialogControlProps}) {
.
</Trans>
</Text>
</View>
)}
{status === 'failure' && (
<>
<Divider />
<Button
label={_(msg`Resend Verification Email`)}
onPress={onPressResendEmail}
variant="solid"
color="secondary_inverted"
size="large"
disabled={sending}>
<ButtonIcon icon={sending ? Loader : Resend} position="left" />
<ButtonText>
<Trans>Resend Email</Trans>
</ButtonText>
</Button>
</>
)}
{status !== 'loading' ? (
<View style={[a.w_full, a.flex_row, a.gap_sm, {marginLeft: 'auto'}]}>
<Button
label={_(msg`Close`)}
onPress={() => control.close()}
variant="solid"
color={status === 'failure' ? 'secondary' : 'primary'}
size="large"
style={{marginLeft: 'auto'}}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
{status === 'failure' ? (
<Button
label={_(msg`Resend Verification Email`)}
onPress={onPressResendEmail}
variant="solid"
color="primary"
size="large"
disabled={sending}>
<ButtonText>
<Trans>Resend Email</Trans>
</ButtonText>
{sending ? <Loader size="sm" style={{color: 'white'}} /> : null}
</Button>
) : null}
</View>
) : null}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
+18 -19
View File
@@ -13,7 +13,6 @@ import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {BottomSheetInlineLinkText} from '#/components/BottomSheetLink'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {InlineLinkText} from '#/components/Link'
@@ -32,6 +31,8 @@ export interface LabelsOnMeDialogProps {
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LabelsOnMeDialogInner {...props} />
</Dialog.Outer>
)
@@ -157,25 +158,23 @@ function Label({
<Divider />
<View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}>
{isSelfLabel ? (
<Text style={[t.atoms.text_contrast_medium]}>
<Text style={[t.atoms.text_contrast_medium]}>
{isSelfLabel ? (
<Trans>This label was applied by you.</Trans>
</Text>
) : (
<View style={{flexDirection: 'row'}}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Source: </Trans>{' '}
</Text>
<BottomSheetInlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}>
{sourceName}
</BottomSheetInlineLinkText>
</View>
)}
) : (
<Trans>
Source:{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}>
{sourceName}
</InlineLinkText>
</Trans>
)}
</Text>
</View>
</View>
)
@@ -10,7 +10,6 @@ import {listUriToHref} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {BottomSheetInlineLinkText} from '#/components/BottomSheetLink'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {InlineLinkText} from '#/components/Link'
@@ -27,6 +26,7 @@ export interface ModerationDetailsDialogProps {
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<ModerationDetailsDialogInner {...props} />
</Dialog.Outer>
)
@@ -141,24 +141,23 @@ function ModerationDetailsDialogInner({
{modcause?.type === 'label' && (
<View style={[a.pt_lg]}>
<Divider />
{modcause.source.type === 'user' ? (
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
{modcause.source.type === 'user' ? (
<Trans>This label was applied by the author.</Trans>
</Text>
) : (
<>
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
<Trans>This label was applied by </Trans>
</Text>
<BottomSheetInlineLinkText
label={desc.source || _(msg`an unknown labeler`)}
to={makeProfileLink({did: modcause.label.src, handle: ''})}
onPress={() => control.close()}
style={a.text_md}>
{desc.source || _(msg`an unknown labeler`)}
</BottomSheetInlineLinkText>
</>
)}
) : (
<Trans>
This label was applied by{' '}
<InlineLinkText
label={desc.source || _(msg`an unknown labeler`)}
to={makeProfileLink({did: modcause.label.src, handle: ''})}
onPress={() => control.close()}
style={a.text_md}>
{desc.source || _(msg`an unknown labeler`)}
</InlineLinkText>
.
</Trans>
)}
</Text>
</View>
)}
+137 -119
View File
@@ -13,8 +13,6 @@ import {
RichText,
} from '@atproto/api'
import {isNetworkError} from '#/lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {logger} from '#/logger'
import {ComposerImage, compressImage} from '#/state/gallery'
import {writePostgateRecord} from '#/state/queries/postgate'
@@ -24,7 +22,8 @@ import {
threadgateAllowUISettingToAllowRecordValue,
writeThreadgateRecord,
} from '#/state/queries/threadgate'
import {ComposerState} from '#/view/com/composer/state'
import {isNetworkError} from 'lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
import {LinkMeta} from '../link-meta/link-meta'
import {uploadBlob} from './upload-blob'
@@ -39,7 +38,6 @@ export interface ExternalEmbedDraft {
}
interface PostOpts {
composerState: ComposerState // TODO: Not used yet.
rawText: string
replyTo?: string
quote?: {
@@ -62,6 +60,13 @@ interface PostOpts {
}
export async function post(agent: BskyAgent, opts: PostOpts) {
let embed:
| AppBskyEmbedImages.Main
| AppBskyEmbedExternal.Main
| AppBskyEmbedRecord.Main
| AppBskyEmbedVideo.Main
| AppBskyEmbedRecordWithMedia.Main
| undefined
let reply
let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true})
@@ -72,7 +77,134 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
rt = shortenLinks(rt)
rt = stripInvalidMentions(rt)
const embed = await resolveEmbed(agent, opts)
// add quote embed if present
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.record',
record: {
uri: opts.quote.uri,
cid: opts.quote.cid,
},
} as AppBskyEmbedRecord.Main
}
// add image embed if present
if (opts.images?.length) {
logger.debug(`Uploading images`, {
count: opts.images.length,
})
const images: AppBskyEmbedImages.Image[] = []
for (const image of opts.images) {
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
logger.debug(`Compressing image`)
const {path, width, height, mime} = await compressImage(image)
logger.debug(`Uploading image`)
const res = await uploadBlob(agent, path, mime)
images.push({
image: res.data.blob,
alt: image.alt,
aspectRatio: {width, height},
})
}
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.recordWithMedia',
record: embed,
media: {
$type: 'app.bsky.embed.images',
images,
},
} as AppBskyEmbedRecordWithMedia.Main
} else {
embed = {
$type: 'app.bsky.embed.images',
images,
} as AppBskyEmbedImages.Main
}
}
// add video embed if present
if (opts.video) {
const captions = await Promise.all(
opts.video.captions
.filter(caption => caption.lang !== '')
.map(async caption => {
const {data} = await agent.uploadBlob(caption.file, {
encoding: 'text/vtt',
})
return {lang: caption.lang, file: data.blob}
}),
)
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.recordWithMedia',
record: embed,
media: {
$type: 'app.bsky.embed.video',
video: opts.video.blobRef,
alt: opts.video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
} as AppBskyEmbedVideo.Main,
} as AppBskyEmbedRecordWithMedia.Main
} else {
embed = {
$type: 'app.bsky.embed.video',
video: opts.video.blobRef,
alt: opts.video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
} as AppBskyEmbedVideo.Main
}
}
// add external embed if present
if (opts.extLink && !opts.images?.length) {
if (opts.extLink.embed) {
embed = opts.extLink.embed
} else {
let thumb
if (opts.extLink.localThumb) {
opts.onStateChange?.('Uploading link thumbnail...')
const {path, mime} = opts.extLink.localThumb.source
const res = await uploadBlob(agent, path, mime)
thumb = res.data.blob
}
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.recordWithMedia',
record: embed,
media: {
$type: 'app.bsky.embed.external',
external: {
uri: opts.extLink.uri,
title: opts.extLink.meta?.title || '',
description: opts.extLink.meta?.description || '',
thumb,
},
} as AppBskyEmbedExternal.Main,
} as AppBskyEmbedRecordWithMedia.Main
} else {
embed = {
$type: 'app.bsky.embed.external',
external: {
uri: opts.extLink.uri,
title: opts.extLink.meta?.title || '',
description: opts.extLink.meta?.description || '',
thumb,
},
} as AppBskyEmbedExternal.Main
}
}
}
// add replyTo if post is a reply to another post
if (opts.replyTo) {
@@ -181,117 +313,3 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
return res
}
async function resolveEmbed(
agent: BskyAgent,
opts: PostOpts,
): Promise<
| AppBskyEmbedImages.Main
| AppBskyEmbedVideo.Main
| AppBskyEmbedExternal.Main
| AppBskyEmbedRecord.Main
| AppBskyEmbedRecordWithMedia.Main
| undefined
> {
const media = await resolveMedia(agent, opts)
if (opts.quote) {
const quoteRecord = {
$type: 'app.bsky.embed.record',
record: {
uri: opts.quote.uri,
cid: opts.quote.cid,
},
}
if (media) {
return {
$type: 'app.bsky.embed.recordWithMedia',
record: quoteRecord,
media,
}
} else {
return quoteRecord
}
}
if (media) {
return media
}
if (opts.extLink?.embed) {
return opts.extLink.embed
}
return undefined
}
async function resolveMedia(
agent: BskyAgent,
opts: PostOpts,
): Promise<
| AppBskyEmbedExternal.Main
| AppBskyEmbedImages.Main
| AppBskyEmbedVideo.Main
| undefined
> {
if (opts.images?.length) {
logger.debug(`Uploading images`, {
count: opts.images.length,
})
opts.onStateChange?.(`Uploading images...`)
const images: AppBskyEmbedImages.Image[] = await Promise.all(
opts.images.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(image)
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(agent, path, mime)
return {
image: res.data.blob,
alt: image.alt,
aspectRatio: {width, height},
}
}),
)
return {
$type: 'app.bsky.embed.images',
images,
}
}
if (opts.video) {
const captions = await Promise.all(
opts.video.captions
.filter(caption => caption.lang !== '')
.map(async caption => {
const {data} = await agent.uploadBlob(caption.file, {
encoding: 'text/vtt',
})
return {lang: caption.lang, file: data.blob}
}),
)
return {
$type: 'app.bsky.embed.video',
video: opts.video.blobRef,
alt: opts.video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
}
}
if (opts.extLink) {
if (opts.extLink.embed) {
return undefined
}
let thumb
if (opts.extLink.localThumb) {
opts.onStateChange?.('Uploading link thumbnail...')
const {path, mime} = opts.extLink.localThumb.source
const res = await uploadBlob(agent, path, mime)
thumb = res.data.blob
}
return {
$type: 'app.bsky.embed.external',
external: {
uri: opts.extLink.uri,
title: opts.extLink.meta?.title || '',
description: opts.extLink.meta?.description || '',
thumb,
},
}
}
return undefined
}
@@ -54,6 +54,7 @@ function AppealDialog() {
</Button>
<Dialog.Outer control={control}>
<Dialog.Handle />
<DialogInner />
</Dialog.Outer>
</>
@@ -18,11 +18,11 @@ import Graphemer from 'graphemer'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {isIOS} from '#/platform/detection'
import {
useMessageDraft,
useSaveMessageDraft,
} from '#/state/messages/message-drafts'
import {isIOS} from 'platform/detection'
import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
@@ -5,13 +5,13 @@ import {useLingui} from '@lingui/react'
import Graphemer from 'graphemer'
import TextareaAutosize from 'react-textarea-autosize'
import {isSafari, isTouchDevice} from '#/lib/browser'
import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {
useMessageDraft,
useSaveMessageDraft,
} from '#/state/messages/message-drafts'
import {isSafari, isTouchDevice} from 'lib/browser'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
Emoji,
@@ -15,8 +15,6 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
import {clamp} from '#/lib/numbers'
import {ScrollProvider} from '#/lib/ScrollContext'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {
convertBskyAppUrlIfNeeded,
@@ -24,19 +22,21 @@ import {
} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {isWeb} from '#/platform/detection'
import {isConvoActive, useConvoActive} from '#/state/messages/convo'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session'
import {clamp} from 'lib/numbers'
import {ScrollProvider} from 'lib/ScrollContext'
import {isWeb} from 'platform/detection'
import {
EmojiPicker,
EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {List} from '#/view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {List} from 'view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
@@ -8,16 +8,16 @@ import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {isWeb} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types'
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile'
import {useSetMinimalShellMode} from '#/state/shell'
import {CenteredView} from '#/view/com/util/Views'
import {MessagesList} from '#/screens/Messages/components/MessagesList'
import {isWeb} from 'platform/detection'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo'
import {ConvoStatus} from 'state/messages/convo/types'
import {useSetMinimalShellMode} from 'state/shell'
import {CenteredView} from 'view/com/util/Views'
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter'
import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
@@ -22,6 +22,7 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {MessagesNUX} from '#/components/dms/MessagesNUX'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
@@ -32,7 +33,7 @@ import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ChatListItem} from './components/ChatListItem'
import {ChatListItem} from './ChatListItem'
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
@@ -150,6 +151,8 @@ export function MessagesScreen({navigation, route}: Props) {
if (conversations.length < 1) {
return (
<View style={a.flex_1}>
<MessagesNUX />
<CenteredView sideBorders={gtMobile} style={[a.h_full_vh]}>
{gtMobile ? (
<DesktopHeader
@@ -237,6 +240,7 @@ export function MessagesScreen({navigation, route}: Props) {
return (
<View style={a.flex_1}>
<MessagesNUX />
{!gtMobile && (
<ViewHeader
title={_(msg`Messages`)}
@@ -46,31 +46,29 @@ export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
return (
<View style={styles.container}>
<React.Suspense fallback={null}>
<LazyViewShot
// @ts-ignore this library doesn't have types
ref={viewshotRef}
options={{
fileName: 'placeholderAvatar',
format: 'jpg',
quality: 0.8,
height: 150 * SIZE_MULTIPLIER,
width: 150 * SIZE_MULTIPLIER,
}}>
<View
style={[
styles.imageContainer,
{backgroundColor: avatar.backgroundColor},
]}
collapsable={false}>
<Icon
height={85 * SIZE_MULTIPLIER}
width={85 * SIZE_MULTIPLIER}
style={{color: 'white'}}
/>
</View>
</LazyViewShot>
</React.Suspense>
<LazyViewShot
// @ts-ignore this library doesn't have types
ref={viewshotRef}
options={{
fileName: 'placeholderAvatar',
format: 'jpg',
quality: 0.8,
height: 150 * SIZE_MULTIPLIER,
width: 150 * SIZE_MULTIPLIER,
}}>
<View
style={[
styles.imageContainer,
{backgroundColor: avatar.backgroundColor},
]}
collapsable={false}>
<Icon
height={85 * SIZE_MULTIPLIER}
width={85 * SIZE_MULTIPLIER}
style={{color: 'white'}}
/>
</View>
</LazyViewShot>
</View>
)
},
@@ -286,6 +286,7 @@ export function StepProfile() {
</View>
<Dialog.Outer control={creatorControl}>
<Dialog.Handle />
<Dialog.Inner
label="Avatar creator"
style={[
+22 -22
View File
@@ -15,35 +15,35 @@ import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {HITSLOP_20} from '#/lib/constants'
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {makeProfileLink, makeStarterPackLink} from '#/lib/routes/links'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {getAllListMembers} from '#/state/queries/list-members'
import {useResolvedStarterPackShortLink} from '#/state/queries/resolve-short-link'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useShortenLink} from '#/state/queries/shorten-link'
import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs'
import {useStarterPackQuery} from '#/state/queries/starter-packs'
import {useAgent, useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader'
import {batchedUpdates} from 'lib/batchedUpdates'
import {HITSLOP_20} from 'lib/constants'
import {isBlockedOrBlocking, isMuted} from 'lib/moderation/blocked-and-muted'
import {makeProfileLink, makeStarterPackLink} from 'lib/routes/links'
import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
import {logEvent} from 'lib/statsig/statsig'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {isWeb} from 'platform/detection'
import {updateProfileShadow} from 'state/cache/profile-shadow'
import {useModerationOpts} from 'state/preferences/moderation-opts'
import {getAllListMembers} from 'state/queries/list-members'
import {useResolvedStarterPackShortLink} from 'state/queries/resolve-short-link'
import {useResolveDidQuery} from 'state/queries/resolve-uri'
import {useShortenLink} from 'state/queries/shorten-link'
import {useStarterPackQuery} from 'state/queries/starter-packs'
import {useAgent, useSession} from 'state/session'
import {useLoggedOutViewControls} from 'state/shell/logged-out'
import {useSetActiveStarterPack} from 'state/shell/starter-pack'
import * as Toast from '#/view/com/util/Toast'
import {CenteredView} from '#/view/com/util/Views'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
import {CenteredView} from 'view/com/util/Views'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -591,7 +591,7 @@ function OverflowMenu({
<Menu.Item
label={_(msg`Report starter pack`)}
onPress={() => reportDialogControl.open()}>
onPress={reportDialogControl.open}>
<Menu.ItemText>
<Trans>Report starter pack</Trans>
</Menu.ItemText>
+10 -8
View File
@@ -4,17 +4,17 @@ import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
import {AppBskyFeedDefs, ModerationOpts} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {useA11y} from '#/state/a11y'
import {DISCOVER_FEED_URI} from 'lib/constants'
import {
useGetPopularFeedsQuery,
usePopularFeedsSearch,
useSavedFeeds,
} from '#/state/queries/feed'
import {List} from '#/view/com/util/List'
} from 'state/queries/feed'
import {SearchInput} from 'view/com/util/forms/SearchInput'
import {List} from 'view/com/util/List'
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, useTheme} from '#/alf'
import {SearchInput} from '#/components/forms/SearchInput'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {Loader} from '#/components/Loader'
import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
@@ -81,11 +81,12 @@ export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
return (
<ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}>
<View style={[a.border_b, t.atoms.border_contrast_medium]}>
<View style={[a.py_sm, a.px_md, {height: 60}]}>
<View style={[a.my_sm, a.px_md, {height: 40}]}>
<SearchInput
value={query}
onChangeText={t => setQuery(t)}
onClearText={() => setQuery('')}
query={query}
onChangeQuery={t => setQuery(t)}
onPressCancelSearch={() => setQuery('')}
onSubmitQuery={() => {}}
/>
</View>
</View>
@@ -93,6 +94,7 @@ export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
data={query ? searchedFeeds : suggestedFeeds}
renderItem={renderItem}
keyExtractor={keyExtractor}
contentContainerStyle={{paddingTop: 6}}
onEndReached={
!query && !screenReaderEnabled ? () => fetchNextPage() : undefined
}
@@ -4,14 +4,14 @@ import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {isNative} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {List} from '#/view/com/util/List'
import {isNative} from 'platform/detection'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {useActorSearchPaginated} from 'state/queries/actor-search'
import {SearchInput} from 'view/com/util/forms/SearchInput'
import {List} from 'view/com/util/List'
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, useTheme} from '#/alf'
import {SearchInput} from '#/components/forms/SearchInput'
import {Loader} from '#/components/Loader'
import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardListCard'
@@ -65,11 +65,12 @@ export function StepProfiles({
return (
<ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}>
<View style={[a.border_b, t.atoms.border_contrast_medium]}>
<View style={[a.py_sm, a.px_md, {height: 60}]}>
<View style={[a.my_sm, a.px_md, {height: 40}]}>
<SearchInput
value={query}
onChangeText={setQuery}
onClearText={() => setQuery('')}
query={query}
onChangeQuery={setQuery}
onPressCancelSearch={() => setQuery('')}
onSubmitQuery={() => {}}
/>
</View>
</View>
+33 -21
View File
@@ -1,9 +1,8 @@
import React from 'react'
import {SharedValue, useSharedValue} from 'react-native-reanimated'
import {isWeb} from '#/platform/detection'
import {DialogControlRefProps} from '#/components/Dialog'
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
import {BottomSheet} from '../../../modules/bottom-sheet'
interface IDialogContext {
/**
@@ -17,6 +16,14 @@ interface IDialogContext {
* `useId`.
*/
openDialogs: React.MutableRefObject<Set<string>>
/**
* The counterpart to `accessibilityViewIsModal` for Android. This property
* applies to the parent of all non-modal views, and prevents TalkBack from
* navigating within content beneath an open dialog.
*
* @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
*/
importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'>
}
const DialogContext = React.createContext<IDialogContext>({} as IDialogContext)
@@ -42,35 +49,40 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
Map<string, React.MutableRefObject<DialogControlRefProps>>
>(new Map())
const openDialogs = React.useRef<Set<string>>(new Set())
const importantForAccessibility = useSharedValue<
'auto' | 'no-hide-descendants'
>('auto')
const closeAllDialogs = React.useCallback(() => {
if (isWeb) {
openDialogs.current.forEach(id => {
const dialog = activeDialogs.current.get(id)
if (dialog) dialog.current.close()
})
return openDialogs.current.size > 0
} else {
BottomSheet.dismissAll()
return false
}
openDialogs.current.forEach(id => {
const dialog = activeDialogs.current.get(id)
if (dialog) dialog.current.close()
})
return openDialogs.current.size > 0
}, [])
const setDialogIsOpen = React.useCallback((id: string, isOpen: boolean) => {
if (isOpen) {
openDialogs.current.add(id)
} else {
openDialogs.current.delete(id)
}
}, [])
const setDialogIsOpen = React.useCallback(
(id: string, isOpen: boolean) => {
if (isOpen) {
openDialogs.current.add(id)
importantForAccessibility.value = 'no-hide-descendants'
} else {
openDialogs.current.delete(id)
if (openDialogs.current.size < 1) {
importantForAccessibility.value = 'auto'
}
}
},
[importantForAccessibility],
)
const context = React.useMemo<IDialogContext>(
() => ({
activeDialogs,
openDialogs,
importantForAccessibility,
}),
[activeDialogs, openDialogs],
[importantForAccessibility, activeDialogs, openDialogs],
)
const controls = React.useMemo(
() => ({closeAllDialogs, setDialogIsOpen}),
+39
View File
@@ -0,0 +1,39 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {useMutation} from '@tanstack/react-query'
import {cancelable} from '#/lib/async/cancelable'
import {CompressedVideo} from '#/lib/media/video/types'
import {compressVideo} from 'lib/media/video/compress'
export function useCompressVideoMutation({
onProgress,
onSuccess,
onError,
signal,
}: {
onProgress: (progress: number) => void
onError: (e: any) => void
onSuccess: (video: CompressedVideo) => void
signal: AbortSignal
}) {
return useMutation({
mutationKey: ['video', 'compress'],
mutationFn: cancelable(
(asset: ImagePickerAsset) =>
compressVideo(asset, {
onProgress: num => onProgress(trunc2dp(num)),
signal,
}),
signal,
),
onError,
onSuccess,
onMutate: () => {
onProgress(0)
},
})
}
function trunc2dp(num: number) {
return Math.trunc(num * 100) / 100
}
+7 -4
View File
@@ -1,3 +1,4 @@
import {useMemo} from 'react'
import {AtpAgent} from '@atproto/api'
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
@@ -16,10 +17,12 @@ export const createVideoEndpointUrl = (
return url.href
}
export function createVideoAgent() {
return new AtpAgent({
service: VIDEO_SERVICE,
})
export function useVideoAgent() {
return useMemo(() => {
return new AtpAgent({
service: VIDEO_SERVICE,
})
}, [])
}
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
+50 -38
View File
@@ -1,61 +1,73 @@
import {BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {VIDEO_SERVICE_DID} from '#/lib/constants'
import {UploadLimitError} from '#/lib/media/video/errors'
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
import {createVideoAgent} from './util'
import {useAgent} from '#/state/session'
import {useVideoAgent} from './util'
export async function getServiceAuthToken({
agent,
export function useServiceAuthToken({
aud,
lxm,
exp,
}: {
agent: BskyAgent
aud?: string
lxm: string
exp?: number
}) {
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!pdsAud) {
throw new Error('Agent does not have a PDS URL')
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
aud: aud ?? pdsAud,
lxm,
exp,
})
return serviceAuth.token
const agent = useAgent()
return useCallback(async () => {
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!pdsAud) {
throw new Error('Agent does not have a PDS URL')
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
aud: aud ?? pdsAud,
lxm,
exp,
})
return serviceAuth.token
}, [agent, aud, lxm, exp])
}
export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) {
const token = await getServiceAuthToken({
agent,
export function useVideoUploadLimits() {
const agent = useVideoAgent()
const getToken = useServiceAuthToken({
lxm: 'app.bsky.video.getUploadLimits',
aud: VIDEO_SERVICE_DID,
})
const videoAgent = createVideoAgent()
const {data: limits} = await videoAgent.app.bsky.video
.getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}})
.catch(err => {
if (err instanceof Error) {
throw new UploadLimitError(err.message)
} else {
throw err
}
})
const {_} = useLingui()
if (!limits.canUpload) {
if (limits.message) {
throw new UploadLimitError(limits.message)
} else {
throw new UploadLimitError(
_(
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
),
return useCallback(async () => {
const {data: limits} = await agent.app.bsky.video
.getUploadLimits(
{},
{headers: {Authorization: `Bearer ${await getToken()}`}},
)
.catch(err => {
if (err instanceof Error) {
throw new UploadLimitError(err.message)
} else {
throw err
}
})
if (!limits.canUpload) {
if (limits.message) {
throw new UploadLimitError(limits.message)
} else {
throw new UploadLimitError(
_(
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
),
)
}
}
}
}, [agent, _, getToken])
}
+54 -57
View File
@@ -1,79 +1,76 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
import {AppBskyVideoDefs, BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {AppBskyVideoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared'
import {useSession} from '#/state/session'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export async function uploadVideo({
video,
agent,
did,
export const useUploadVideoMutation = ({
onSuccess,
onError,
setProgress,
signal,
_,
}: {
video: CompressedVideo
agent: BskyAgent
did: string
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
onError: (e: any) => void
setProgress: (progress: number) => void
signal: AbortSignal
_: I18n['_']
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, _)
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
if (signal.aborted) {
throw new AbortError()
}
const token = await getServiceAuthToken({
agent,
}) => {
const {currentAccount} = useSession()
const getToken = useServiceAuthToken({
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
const uploadTask = createUploadTask(
uri,
video.uri,
{
headers: {
'content-type': video.mimeType,
Authorization: `Bearer ${token}`,
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
},
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
)
const checkLimits = useVideoUploadLimits()
const {_} = useLingui()
if (signal.aborted) {
throw new AbortError()
}
const res = await uploadTask.uploadAsync()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
if (!res?.body) {
throw new Error('No response')
}
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
const uploadTask = createUploadTask(
uri,
video.uri,
{
headers: {
'content-type': video.mimeType,
Authorization: `Bearer ${await getToken()}`,
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
},
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
)
const res = await uploadTask.uploadAsync()
if (!responseBody.jobId) {
throw new ServerError(responseBody.error || _(msg`Failed to upload video`))
}
if (!res?.body) {
throw new Error('No response')
}
if (signal.aborted) {
throw new AbortError()
}
return responseBody
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
if (!responseBody.jobId) {
throw new ServerError(
responseBody.error || _(msg`Failed to upload video`),
)
}
return responseBody
}, signal),
onError,
onSuccess,
})
}
+65 -74
View File
@@ -1,95 +1,86 @@
import {AppBskyVideoDefs} from '@atproto/api'
import {BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared'
import {useSession} from '#/state/session'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export async function uploadVideo({
video,
agent,
did,
export const useUploadVideoMutation = ({
onSuccess,
onError,
setProgress,
signal,
_,
}: {
video: CompressedVideo
agent: BskyAgent
did: string
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
onError: (e: any) => void
setProgress: (progress: number) => void
signal: AbortSignal
_: I18n['_']
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, _)
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
let bytes = video.bytes
if (!bytes) {
if (signal.aborted) {
throw new AbortError()
}
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
}
if (signal.aborted) {
throw new AbortError()
}
const token = await getServiceAuthToken({
agent,
}) => {
const {currentAccount} = useSession()
const getToken = useServiceAuthToken({
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
const checkLimits = useVideoUploadLimits()
const {_} = useLingui()
if (signal.aborted) {
throw new AbortError()
}
const xhr = new XMLHttpRequest()
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
(resolve, reject) => {
xhr.upload.addEventListener('progress', e => {
const progress = e.loaded / e.total
setProgress(progress)
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
xhr.onloadend = () => {
if (signal.aborted) {
reject(new AbortError())
} else if (xhr.readyState === 4) {
const uploadRes = JSON.parse(
xhr.responseText,
) as AppBskyVideoDefs.JobStatus
resolve(uploadRes)
} else {
reject(new ServerError(_(msg`Failed to upload video`)))
}
}
xhr.onerror = () => {
reject(new ServerError(_(msg`Failed to upload video`)))
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType)
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.send(bytes)
},
)
if (!res.jobId) {
throw new ServerError(res.error || _(msg`Failed to upload video`))
}
let bytes = video.bytes
if (!bytes) {
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
}
if (signal.aborted) {
throw new AbortError()
}
return res
const token = await getToken()
const xhr = new XMLHttpRequest()
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
(resolve, reject) => {
xhr.upload.addEventListener('progress', e => {
const progress = e.loaded / e.total
setProgress(progress)
})
xhr.onloadend = () => {
if (xhr.readyState === 4) {
const uploadRes = JSON.parse(
xhr.responseText,
) as AppBskyVideoDefs.JobStatus
resolve(uploadRes)
} else {
reject(new ServerError(_(msg`Failed to upload video`)))
}
}
xhr.onerror = () => {
reject(new ServerError(_(msg`Failed to upload video`)))
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType)
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.send(bytes)
},
)
if (!res.jobId) {
throw new ServerError(res.error || _(msg`Failed to upload video`))
}
return res
}, signal),
onError,
onSuccess,
})
}
+300 -355
View File
@@ -1,11 +1,12 @@
import React, {useCallback, useEffect} from 'react'
import {ImagePickerAsset} from 'expo-image-picker'
import {AppBskyVideoDefs, BlobRef, BskyAgent} from '@atproto/api'
import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs'
import {I18n} from '@lingui/core'
import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {AbortError} from '#/lib/async/cancelable'
import {compressVideo} from '#/lib/media/video/compress'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {
ServerError,
UploadLimitError,
@@ -13,394 +14,338 @@ import {
} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {logger} from '#/logger'
import {createVideoAgent} from '#/state/queries/video/util'
import {uploadVideo} from '#/state/queries/video/video-upload'
import {isWeb} from '#/platform/detection'
import {useCompressVideoMutation} from '#/state/queries/video/compress-video'
import {useVideoAgent} from '#/state/queries/video/util'
import {useUploadVideoMutation} from '#/state/queries/video/video-upload'
export type VideoAction =
| {
type: 'compressing_to_uploading'
video: CompressedVideo
signal: AbortSignal
}
| {
type: 'uploading_to_processing'
jobId: string
signal: AbortSignal
}
| {type: 'to_error'; error: string; signal: AbortSignal}
| {
type: 'to_done'
blobRef: BlobRef
signal: AbortSignal
}
| {type: 'update_progress'; progress: number; signal: AbortSignal}
| {
type: 'update_dimensions'
width: number
height: number
signal: AbortSignal
}
| {
type: 'update_job_status'
jobStatus: AppBskyVideoDefs.JobStatus
signal: AbortSignal
}
type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
const noopController = new AbortController()
noopController.abort()
type Action =
| {type: 'SetStatus'; status: Status}
| {type: 'SetProgress'; progress: number}
| {type: 'SetError'; error: string | undefined}
| {type: 'Reset'}
| {type: 'SetAsset'; asset: ImagePickerAsset}
| {type: 'SetDimensions'; width: number; height: number}
| {type: 'SetVideo'; video: CompressedVideo}
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
| {type: 'SetComplete'; blobRef: BlobRef}
export const NO_VIDEO = Object.freeze({
status: 'idle',
progress: 0,
abortController: noopController,
asset: undefined,
video: undefined,
jobId: undefined,
pendingPublish: undefined,
})
export type NoVideoState = typeof NO_VIDEO
type ErrorState = {
status: 'error'
progress: 100
abortController: AbortController
asset: ImagePickerAsset | null
export interface State {
status: Status
progress: number
asset?: ImagePickerAsset
video: CompressedVideo | null
jobId: string | null
error: string
pendingPublish?: undefined
}
type CompressingState = {
status: 'compressing'
progress: number
jobStatus?: AppBskyVideoDefs.JobStatus
blobRef?: BlobRef
error?: string
abortController: AbortController
asset: ImagePickerAsset
video?: undefined
jobId?: undefined
pendingPublish?: undefined
pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean}
}
type UploadingState = {
status: 'uploading'
progress: number
abortController: AbortController
asset: ImagePickerAsset
video: CompressedVideo
jobId?: undefined
pendingPublish?: undefined
}
export type VideoUploadDispatch = (action: Action) => void
type ProcessingState = {
status: 'processing'
progress: number
abortController: AbortController
asset: ImagePickerAsset
video: CompressedVideo
jobId: string
jobStatus: AppBskyVideoDefs.JobStatus | null
pendingPublish?: undefined
}
type DoneState = {
status: 'done'
progress: 100
abortController: AbortController
asset: ImagePickerAsset
video: CompressedVideo
jobId?: undefined
pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean}
}
export type VideoState =
| ErrorState
| CompressingState
| UploadingState
| ProcessingState
| DoneState
export function createVideoState(
asset: ImagePickerAsset,
abortController: AbortController,
): CompressingState {
return {
status: 'compressing',
progress: 0,
abortController,
asset,
}
}
export function videoReducer(
state: VideoState,
action: VideoAction,
): VideoState {
if (action.signal.aborted || action.signal !== state.abortController.signal) {
// This action is stale and the process that spawned it is no longer relevant.
return state
}
if (action.type === 'to_error') {
return {
status: 'error',
progress: 100,
abortController: state.abortController,
error: action.error,
asset: state.asset ?? null,
video: state.video ?? null,
jobId: state.jobId ?? null,
}
} else if (action.type === 'update_progress') {
if (state.status === 'compressing' || state.status === 'uploading') {
return {
...state,
progress: action.progress,
}
}
} else if (action.type === 'update_dimensions') {
if (state.asset) {
return {
...state,
asset: {...state.asset, width: action.width, height: action.height},
}
}
} else if (action.type === 'compressing_to_uploading') {
if (state.status === 'compressing') {
return {
status: 'uploading',
function reducer(queryClient: QueryClient) {
return (state: State, action: Action): State => {
let updatedState = state
if (action.type === 'SetStatus') {
updatedState = {...state, status: action.status}
} else if (action.type === 'SetProgress') {
updatedState = {...state, progress: action.progress}
} else if (action.type === 'SetError') {
updatedState = {...state, error: action.error}
} else if (action.type === 'Reset') {
state.abortController.abort()
queryClient.cancelQueries({
queryKey: ['video'],
})
updatedState = {
status: 'idle',
progress: 0,
abortController: state.abortController,
asset: state.asset,
video: action.video,
video: null,
blobRef: undefined,
abortController: new AbortController(),
}
}
return state
} else if (action.type === 'uploading_to_processing') {
if (state.status === 'uploading') {
return {
status: 'processing',
progress: 0,
abortController: state.abortController,
asset: state.asset,
video: state.video,
jobId: action.jobId,
jobStatus: null,
}
}
} else if (action.type === 'update_job_status') {
if (state.status === 'processing') {
return {
} else if (action.type === 'SetAsset') {
updatedState = {
...state,
jobStatus: action.jobStatus,
progress:
action.jobStatus.progress !== undefined
? action.jobStatus.progress / 100
: state.progress,
asset: action.asset,
status: 'compressing',
error: undefined,
}
}
} else if (action.type === 'to_done') {
if (state.status === 'processing') {
return {
status: 'done',
progress: 100,
abortController: state.abortController,
asset: state.asset,
video: state.video,
} else if (action.type === 'SetDimensions') {
updatedState = {
...state,
asset: state.asset
? {...state.asset, width: action.width, height: action.height}
: undefined,
}
} else if (action.type === 'SetVideo') {
updatedState = {...state, video: action.video, status: 'uploading'}
} else if (action.type === 'SetJobStatus') {
updatedState = {...state, jobStatus: action.jobStatus}
} else if (action.type === 'SetComplete') {
updatedState = {
...state,
pendingPublish: {
blobRef: action.blobRef,
mutableProcessed: false,
},
status: 'done',
}
}
return updatedState
}
console.error(
'Unexpected video action (' +
action.type +
') while in ' +
state.status +
' state',
)
return state
}
function trunc2dp(num: number) {
return Math.trunc(num * 100) / 100
}
export async function processVideo(
asset: ImagePickerAsset,
dispatch: (action: VideoAction) => void,
agent: BskyAgent,
did: string,
signal: AbortSignal,
_: I18n['_'],
) {
let video: CompressedVideo | undefined
try {
video = await compressVideo(asset, {
onProgress: num => {
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
},
signal,
})
} catch (e) {
const message = getCompressErrorMessage(e, _)
if (message !== null) {
dispatch({
type: 'to_error',
error: message,
signal,
})
}
return
}
dispatch({
type: 'compressing_to_uploading',
video,
signal,
export function useUploadVideo({
setStatus,
initialVideoUri,
}: {
setStatus: (status: string) => void
onSuccess: () => void
initialVideoUri?: string
}) {
const {_} = useLingui()
const queryClient = useQueryClient()
const [state, dispatch] = React.useReducer(reducer(queryClient), {
status: 'idle',
progress: 0,
video: null,
abortController: new AbortController(),
})
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
try {
uploadResponse = await uploadVideo({
video,
agent,
did,
signal,
_,
setProgress: p => {
dispatch({type: 'update_progress', progress: p, signal})
},
})
} catch (e) {
const message = getUploadErrorMessage(e, _)
if (message !== null) {
const {setJobId} = useUploadStatusQuery({
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => {
// This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user
// Leaving it for now though
dispatch({
type: 'to_error',
error: message,
signal,
type: 'SetJobStatus',
jobStatus: status,
})
}
return
}
const jobId = uploadResponse.jobId
dispatch({
type: 'uploading_to_processing',
jobId,
signal,
setStatus(status.state.toString())
},
onSuccess: blobRef => {
dispatch({
type: 'SetComplete',
blobRef,
})
},
onError: useCallback(
error => {
logger.error('Error processing video', {safeMessage: error})
dispatch({
type: 'SetError',
error: _(msg`Video failed to process`),
})
},
[_],
),
})
let pollFailures = 0
while (true) {
if (signal.aborted) {
return // Exit async loop
}
const videoAgent = createVideoAgent()
let status: JobStatus | undefined
let blob: BlobRef | undefined
try {
const response = await videoAgent.app.bsky.video.getJobStatus({jobId})
status = response.data.jobStatus
pollFailures = 0
if (status.state === 'JOB_STATE_COMPLETED') {
blob = status.blob
if (!blob) {
throw new Error('Job completed, but did not return a blob')
const {mutate: onVideoCompressed} = useUploadVideoMutation({
onSuccess: response => {
dispatch({
type: 'SetStatus',
status: 'processing',
})
setJobId(response.jobId)
},
onError: e => {
if (e instanceof AbortError) {
return
} else if (e instanceof ServerError || e instanceof UploadLimitError) {
let message
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
switch (e.message) {
case 'User is not allowed to upload videos':
message = _(msg`You are not allowed to upload videos.`)
break
case 'Uploading is disabled at the moment':
message = _(
msg`Hold up! Were gradually giving access to video, and youre still waiting in line. Check back soon!`,
)
break
case "Failed to get user's upload stats":
message = _(
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
)
break
case 'User has exceeded daily upload bytes limit':
message = _(
msg`You've reached your daily limit for video uploads (too many bytes)`,
)
break
case 'User has exceeded daily upload videos limit':
message = _(
msg`You've reached your daily limit for video uploads (too many videos)`,
)
break
case 'Account is not old enough to upload videos':
message = _(
msg`Your account is not yet old enough to upload videos. Please try again later.`,
)
break
default:
message = e.message
break
}
dispatch({
type: 'SetError',
error: message,
})
} else {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while uploading the video.`),
})
}
logger.error('Error uploading video', {safeMessage: e})
},
setProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
signal: state.abortController.signal,
})
const {mutate: onSelectVideo} = useCompressVideoMutation({
onProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
onSuccess: (video: CompressedVideo) => {
dispatch({
type: 'SetVideo',
video,
})
onVideoCompressed(video)
},
onError: e => {
if (e instanceof AbortError) {
return
} else if (e instanceof VideoTooLargeError) {
dispatch({
type: 'SetError',
error: _(msg`The selected video is larger than 50MB.`),
})
} else {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while compressing the video.`),
})
logger.error('Error compressing video', {safeMessage: e})
}
},
signal: state.abortController.signal,
})
const selectVideo = React.useCallback(
(asset: ImagePickerAsset) => {
// compression step on native converts to mp4, so no need to check there
if (isWeb) {
const mimeType = getMimeType(asset)
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
throw new Error(_(msg`Unsupported video type: ${mimeType}`))
}
}
dispatch({
type: 'SetAsset',
asset,
})
onSelectVideo(asset)
},
[_, onSelectVideo],
)
const clearVideo = () => {
dispatch({type: 'Reset'})
}
const updateVideoDimensions = useCallback((width: number, height: number) => {
dispatch({
type: 'SetDimensions',
width,
height,
})
}, [])
// Whenever we receive an initial video uri, we should immediately run compression if necessary
useEffect(() => {
if (initialVideoUri) {
selectVideo({uri: initialVideoUri} as ImagePickerAsset)
}
}, [initialVideoUri, selectVideo])
return {
state,
dispatch,
selectVideo,
clearVideo,
updateVideoDimensions,
}
}
const useUploadStatusQuery = ({
onStatusChange,
onSuccess,
onError,
}: {
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
onSuccess: (blobRef: BlobRef) => void
onError: (error: Error) => void
}) => {
const videoAgent = useVideoAgent()
const [enabled, setEnabled] = React.useState(true)
const [jobId, setJobId] = React.useState<string>()
const {error} = useQuery({
queryKey: ['video', 'upload status', jobId],
queryFn: async () => {
if (!jobId) return // this won't happen, can ignore
const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId})
const status = data.jobStatus
if (status.state === 'JOB_STATE_COMPLETED') {
setEnabled(false)
if (!status.blob)
throw new Error('Job completed, but did not return a blob')
onSuccess(status.blob)
} else if (status.state === 'JOB_STATE_FAILED') {
throw new Error(status.error ?? 'Job failed to process')
}
} catch (e) {
if (!status) {
pollFailures++
if (pollFailures < 50) {
await new Promise(resolve => setTimeout(resolve, 5000))
continue // Continue async loop
}
}
onStatusChange(status)
return status
},
enabled: Boolean(jobId && enabled),
refetchInterval: 1500,
})
logger.error('Error processing video', {safeMessage: e})
dispatch({
type: 'to_error',
error: _(msg`Video failed to process`),
signal,
})
return // Exit async loop
useEffect(() => {
if (error) {
onError(error)
setEnabled(false)
}
}, [error, onError])
if (blob) {
dispatch({
type: 'to_done',
blobRef: blob,
signal,
})
} else {
dispatch({
type: 'update_job_status',
jobStatus: status,
signal,
})
}
if (
status.state !== 'JOB_STATE_COMPLETED' &&
status.state !== 'JOB_STATE_FAILED'
) {
await new Promise(resolve => setTimeout(resolve, 1500))
continue // Continue async loop
}
return // Exit async loop
return {
setJobId: (_jobId: string) => {
setJobId(_jobId)
setEnabled(true)
},
}
}
function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null {
if (e instanceof AbortError) {
return null
}
if (e instanceof VideoTooLargeError) {
return _(msg`The selected video is larger than 50MB.`)
}
logger.error('Error compressing video', {safeMessage: e})
return _(msg`An error occurred while compressing the video.`)
}
function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
if (e instanceof AbortError) {
return null
}
logger.error('Error uploading video', {safeMessage: e})
if (e instanceof ServerError || e instanceof UploadLimitError) {
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
switch (e.message) {
case 'User is not allowed to upload videos':
return _(msg`You are not allowed to upload videos.`)
case 'Uploading is disabled at the moment':
return _(
msg`Hold up! Were gradually giving access to video, and youre still waiting in line. Check back soon!`,
)
case "Failed to get user's upload stats":
return _(
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
)
case 'User has exceeded daily upload bytes limit':
return _(
msg`You've reached your daily limit for video uploads (too many bytes)`,
)
case 'User has exceeded daily upload videos limit':
return _(
msg`You've reached your daily limit for video uploads (too many videos)`,
)
case 'Account is not old enough to upload videos':
return _(
msg`Your account is not yet old enough to upload videos. Please try again later.`,
)
default:
return e.message
function getMimeType(asset: ImagePickerAsset) {
if (isWeb) {
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
if (!mimeType) {
throw new Error('Could not determine mime type')
}
return mimeType
}
return _(msg`An error occurred while uploading the video.`)
if (!asset.mimeType) {
throw new Error('Could not determine mime type')
}
return asset.mimeType
}

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