Merge remote-tracking branch 'origin/main' into halo/base

* origin/main: (47 commits)
  Add option to remove quoted post in composer (#3670)
  Add border radius to GIF loading state (#3669)
  use mp4 over webm (#3666)
  Release 1.79 (#3661)
  Update Japanese translation (#3656)
  [Statsig] Add test gates and events (#3659)
  Quick fix to ensure disable 2fa dialog adjusts to keyboard (#3658)
  Email auth factor (#3602)
  GIF Viewer (#3605)
  Ungate profile scroll fix (#3655)
  rm country param (#3653)
  [GIFs] Remove consent flow (#3652)
  [GIFs] Replace GIPHY with Tenor (#3651)
  properly close the switch account dialog (#3558)
  [GIFs] Reset scroll on query change (#3642)
  Update French translations (#3644)
  [GIFs] Add error boundary to GIF picker (#3643)
  Revert "Add layout transitions to the composer photo gallery on iOS (#3609)" (#3649)
  Update Finnish translations  (#3630)
  Update Japanese translations (#3632)
  ...
This commit is contained in:
Eric Bailey
2024-04-23 18:59:03 -05:00
97 changed files with 12791 additions and 9823 deletions
+3 -5
View File
@@ -1,8 +1,9 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {beforeAll, describe, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer, sleep} from '../util'
import {createServer, loginAsAlice, openApp, sleep} from '../util'
describe('Composer', () => {
beforeAll(async () => {
@@ -41,7 +42,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText(
'Post with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
@@ -72,7 +72,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText(
'Reply with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
@@ -104,7 +103,6 @@ describe('Composer', () => {
await element(by.id('composerTextInput')).typeText(
'QP with a https://example.com link card',
)
await element(by.id('addLinkCardBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
})
+51 -37
View File
@@ -1,17 +1,18 @@
import {RichText} from '@atproto/api'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen, pluralize} from '../../src/lib/strings/helpers'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
import {ago} from '../../src/lib/strings/time'
import {
makeRecordUri,
toNiceDomain,
toShortUrl,
toShareUrl,
toShortUrl,
} from '../../src/lib/strings/url-helpers'
import {pluralize, enforceLen} from '../../src/lib/strings/helpers'
import {ago} from '../../src/lib/strings/time'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
import {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles'
import {cleanError} from '../../src/lib/strings/errors'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
describe('detectLinkables', () => {
const inputs = [
@@ -434,6 +435,8 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://giphy.com/gif/some-random-gif-name-gifId',
'https://giphy.com/gifs/',
'https://giphy.com/gifs/39248209509382934029?hh=100&ww=100',
'https://media.giphy.com/media/gifId/giphy.webp',
'https://media0.giphy.com/media/gifId/giphy.webp',
'https://media1.giphy.com/media/gifId/giphy.gif',
@@ -456,6 +459,12 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://tenor.com/view',
'https://tenor.com/view/gifId.gif',
'https://tenor.com/intl/view/gifId.gif',
'https://media.tenor.com/someID_AAAAC/someName.gif?hh=100&ww=100',
'https://media.tenor.com/someID_AAAAC/someName.gif',
'https://media.tenor.com/someID/someName.gif',
'https://media.tenor.com/someID',
'https://media.tenor.com',
]
const outputs = [
@@ -621,18 +630,17 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
undefined,
undefined,
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
metaUri: 'https://giphy.com/gifs/39248209509382934029',
playerUri: 'https://i.giphy.com/media/39248209509382934029/200.webp',
},
{
type: 'giphy_gif',
@@ -640,7 +648,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -648,7 +656,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -656,7 +664,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -664,7 +672,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -672,7 +680,15 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
undefined,
undefined,
@@ -684,7 +700,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
@@ -693,7 +709,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -701,7 +717,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -709,7 +725,7 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
{
type: 'giphy_gif',
@@ -717,32 +733,30 @@ describe('parseEmbedPlayerFromUrl', () => {
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
},
undefined,
undefined,
undefined,
undefined,
undefined,
{
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: 'https://tenor.com/view/gifId.gif',
playerUri: 'https://t.gifs.bsky.app/someID_AAAAM/someName.gif',
dimensions: {
width: 100,
height: 100,
},
},
undefined,
undefined,
{
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: 'https://tenor.com/view/gifId.gif',
},
{
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: 'https://tenor.com/intl/view/gifId.gif',
},
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 281 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V5h14v14H5Zm10.725-5.2c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Zm-3.367.872c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm-3.879.078C6.92 14.75 6 13.827 6 12.287v-.617c0-1.47.955-2.42 2.472-2.42.589 0 1.139.147 1.548.388.404.236.664.562.664.915 0 .373-.271.636-.656.636a.82.82 0 0 1-.41-.108 2.34 2.34 0 0 1-.271-.177c-.208-.148-.421-.3-.746-.3-.644 0-.95.38-.95 1.155v.52c0 .768.306 1.168.903 1.168.436 0 .735-.248.735-.61v-.061h-.146c-.412 0-.632-.194-.632-.551 0-.353.216-.535.632-.535h.806c.617 0 .884.256.884.834v.166c0 1.253-.92 2.06-2.354 2.06Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1000 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3Zm1 14V6h16v12H4Zm2-5.713c0 1.54.92 2.463 2.48 2.463 1.434 0 2.353-.807 2.353-2.06v-.166c0-.578-.267-.834-.884-.834h-.806c-.416 0-.632.182-.632.535 0 .357.22.55.632.55h.146v.063c0 .36-.299.609-.735.609-.597 0-.904-.4-.904-1.168v-.52c0-.775.307-1.155.951-1.155.325 0 .538.152.746.3.089.064.176.127.272.177a.82.82 0 0 0 .409.108c.385 0 .656-.263.656-.636 0-.353-.26-.679-.664-.915-.409-.24-.96-.388-1.548-.388C6.955 9.25 6 10.2 6 11.67v.617Zm6.358 2.385c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm3.367-.872c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 993 B

+1
View File
@@ -190,6 +190,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/saved-feeds", server.WebGeneric)
e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/settings/accessibility", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
+17 -5
View File
@@ -2,7 +2,8 @@
## App Build
- Set up your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
- Set up your environment [using the expo instructions](https://docs.expo.dev/guides/local-app-development/).
- make sure that the JAVA_HOME points to the zulu-17 directory in your `.zshrc` or `.bashrc` file: `export JAVA_HOME=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home`. DO NOT use another JDK or you will encounter build errors.
- If you're running macOS, make sure you are running the correct versions of Ruby and Cocoapods:
- Check if you've installed Cocoapods through `homebrew`. If you have, remove it:
- `brew info cocoapods`
@@ -14,7 +15,7 @@
- `rbenv global 2.7.6`
- Add `eval "$(rbenv init - zsh)"` to your `~/.zshrc`
- From inside the project directory:
- `bundler install`
- `bundler install` (this will install Cocoapods)
- Setup your environment [for e2e testing using detox](https://wix.github.io/Detox/docs/introduction/getting-started):
- `yarn global add detox-cli`
- `brew tap wix/brew`
@@ -27,7 +28,7 @@
- `git clone git@github.com:bluesky-social/atproto.git`
- `cd atproto`
- `brew install pnpm`
- `brew install jq`
- optional: `brew install jq`
- `pnpm i`
- `pnpm build`
- Start the docker daemon (on MacOS this entails starting the Docker Desktop app)
@@ -38,11 +39,22 @@
- Xcode must be installed for this to run.
- A simulator must be preconfigured in Xcode settings.
- if no iOS versions are available, install the iOS runtime at `Xcode > Settings > Platforms`.
- if the simulator download keeps failing you can download it from the developer website.
- [Apple Developer](https://developer.apple.com/download/all/?q=Simulator%20Runtime)
- `xcode-select -s /Applications/Xcode.app`
- `xcodebuild -runFirstLaunch`
- `xcrun simctl runtime add "~/Downloads/iOS_17.4_Simulator_Runtime.dmg"` (adapt the path to the downloaded file)
- In addition, ensure Xcode Command Line Tools are installed using `xcode-select --install`.
- Pods must be installed:
- From the project directory root: `cd ios && pod install`.
- Expo will require you to configure Xcode Signing. Follow the linked instructions. Error messages in Xcode related to the signing process can be safely ignored when installing on the iOS Simulator; Expo merely requires the profile to exist in order to install the app on the Simulator.
- Make sure you do have a certificate: open Xcode > Settings > Accounts > (sign-in) > Manage Certificates > + > Apple Development > Done.
- If you still encounter issues, try `rm -rf ios` before trying to build again (`yarn ios`)
- Android: `yarn android`
- Install "Android Studio"
- Make sure you have the Android SDK installed (Android Studio > Tools > Android SDK).
- In "SDK Platforms": "Android x" (where x is Android's current version).
- In "SDK Tools": "Android SDK Build-Tools" and "Android Emulator" are required.
- Add `export ANDROID_HOME=/Users/<your_username>/Library/Android/sdk` to your `.zshrc` or `.bashrc` (and restart your terminal).
- Setup an emulator (Android Studio > Tools > Device Manager).
- Web: `yarn web`
- If you are cloning or forking this repo as an open-source developer, please check the tips below as well
- Run e2e tests
@@ -0,0 +1,98 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'maven-publish'
group = 'expo.modules.blueskygifview'
version = '0.5.0'
buildscript {
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
if (expoModulesCorePlugin.exists()) {
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
}
// 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
}
// Ensures backward compatibility
ext.getKotlinVersion = {
if (ext.has("kotlinVersion")) {
ext.kotlinVersion()
} else {
ext.safeExtGet("kotlinVersion", "1.8.10")
}
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
}
}
afterEvaluate {
publishing {
publications {
release(MavenPublication) {
from components.release
}
}
repositories {
maven {
url = mavenLocal().url
}
}
}
}
android {
compileSdkVersion safeExtGet("compileSdkVersion", 33)
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.majorVersion
}
}
namespace "expo.modules.blueskygifview"
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 21)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
versionCode 1
versionName "0.5.0"
}
lintOptions {
abortOnError false
}
publishing {
singleVariant("release") {
withSourcesJar()
}
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
def GLIDE_VERSION = "4.13.2"
implementation project(':expo-modules-core')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
// Keep glide version up to date with expo-image so that we don't have duplicate deps
implementation 'com.github.bumptech.glide:glide:4.13.2'
}
@@ -0,0 +1,2 @@
<manifest>
</manifest>
@@ -0,0 +1,37 @@
package expo.modules.blueskygifview
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.Animatable
import androidx.appcompat.widget.AppCompatImageView
class AppCompatImageViewExtended(context: Context, private val parent: GifView): AppCompatImageView(context) {
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (this.drawable is Animatable) {
if (!parent.isLoaded) {
parent.isLoaded = true
parent.firePlayerStateChange()
}
if (!parent.isPlaying) {
this.pause()
}
}
}
fun pause() {
val drawable = this.drawable
if (drawable is Animatable) {
drawable.stop()
}
}
fun play() {
val drawable = this.drawable
if (drawable is Animatable) {
drawable.start()
}
}
}
@@ -0,0 +1,54 @@
package expo.modules.blueskygifview
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoBlueskyGifViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoBlueskyGifView")
AsyncFunction("prefetchAsync") { sources: List<String> ->
val activity = appContext.currentActivity ?: return@AsyncFunction
val glide = Glide.with(activity)
sources.forEach { source ->
glide
.download(source)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.submit()
}
}
View(GifView::class) {
Events(
"onPlayerStateChange"
)
Prop("source") { view: GifView, source: String ->
view.source = source
}
Prop("placeholderSource") { view: GifView, source: String ->
view.placeholderSource = source
}
Prop("autoplay") { view: GifView, autoplay: Boolean ->
view.autoplay = autoplay
}
AsyncFunction("playAsync") { view: GifView ->
view.play()
}
AsyncFunction("pauseAsync") { view: GifView ->
view.pause()
}
AsyncFunction("toggleAsync") { view: GifView ->
view.toggle()
}
}
}
}
@@ -0,0 +1,180 @@
package expo.modules.blueskygifview
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
class GifView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
// Events
private val onPlayerStateChange by EventDispatcher()
// Glide
private val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
private val glide = Glide.with(activity)
val imageView = AppCompatImageViewExtended(context, this)
var isPlaying = true
var isLoaded = false
// Requests
private var placeholderRequest: Target<Drawable>? = null
private var webpRequest: Target<Drawable>? = null
// Props
var placeholderSource: String? = null
var source: String? = null
var autoplay: Boolean = true
set(value) {
field = value
if (value) {
this.play()
} else {
this.pause()
}
}
//<editor-fold desc="Lifecycle">
init {
this.setBackgroundColor(Color.TRANSPARENT)
this.imageView.setBackgroundColor(Color.TRANSPARENT)
this.imageView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
this.addView(this.imageView)
}
override fun onAttachedToWindow() {
if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) {
this.load()
} else if (this.isPlaying) {
this.imageView.play()
}
super.onAttachedToWindow()
}
override fun onDetachedFromWindow() {
this.imageView.pause()
super.onDetachedFromWindow()
}
//</editor-fold>
//<editor-fold desc="Loading">
private fun load() {
if (placeholderSource == null || source == null) {
return
}
this.webpRequest = glide.load(source)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.skipMemoryCache(false)
.listener(object: RequestListener<Drawable> {
override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: com.bumptech.glide.load.DataSource?,
isFirstResource: Boolean
): Boolean {
if (placeholderRequest != null) {
glide.clear(placeholderRequest)
}
return false
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
return true
}
})
.into(this.imageView)
if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) {
this.placeholderRequest = glide.load(placeholderSource)
.diskCacheStrategy(DiskCacheStrategy.DATA)
// Let's not bloat the memory cache with placeholders
.skipMemoryCache(true)
.listener(object: RequestListener<Drawable> {
override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: com.bumptech.glide.load.DataSource?,
isFirstResource: Boolean
): Boolean {
// Incase this request finishes after the webp, let's just not set
// the drawable. This shouldn't happen because the request should get cancelled
if (imageView.drawable == null) {
imageView.setImageDrawable(resource)
}
return true
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
return true
}
})
.submit()
}
}
//</editor-fold>
//<editor-fold desc="Controls">
fun play() {
this.imageView.play()
this.isPlaying = true
this.firePlayerStateChange()
}
fun pause() {
this.imageView.pause()
this.isPlaying = false
this.firePlayerStateChange()
}
fun toggle() {
if (this.isPlaying) {
this.pause()
} else {
this.play()
}
}
//</editor-fold>
//<editor-fold desc="Util">
fun firePlayerStateChange() {
onPlayerStateChange(mapOf(
"isPlaying" to this.isPlaying,
"isLoaded" to this.isLoaded,
))
}
//</editor-fold>
}
@@ -0,0 +1,9 @@
{
"platforms": ["ios", "android", "web"],
"ios": {
"modules": ["ExpoBlueskyGifViewModule"]
},
"android": {
"modules": ["expo.modules.blueskygifview.ExpoBlueskyGifViewModule"]
}
}
+1
View File
@@ -0,0 +1 @@
export {GifView} from './src/GifView'
@@ -0,0 +1,23 @@
Pod::Spec.new do |s|
s.name = 'ExpoBlueskyGifView'
s.version = '1.0.0'
s.summary = 'A simple GIF player for Bluesky'
s.description = 'A simple GIF player for Bluesky'
s.author = ''
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'SDWebImage', '~> 5.17.0'
s.dependency 'SDWebImageWebPCoder', '~> 0.13.0'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -0,0 +1,47 @@
import ExpoModulesCore
import SDWebImage
import SDWebImageWebPCoder
public class ExpoBlueskyGifViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoBlueskyGifView")
OnCreate {
SDImageCodersManager.shared.addCoder(SDImageGIFCoder.shared)
}
AsyncFunction("prefetchAsync") { (sources: [URL]) in
SDWebImagePrefetcher.shared.prefetchURLs(sources, context: Util.createContext(), progress: nil)
}
View(GifView.self) {
Events(
"onPlayerStateChange"
)
Prop("source") { (view: GifView, prop: String) in
view.source = prop
}
Prop("placeholderSource") { (view: GifView, prop: String) in
view.placeholderSource = prop
}
Prop("autoplay") { (view: GifView, prop: Bool) in
view.autoplay = prop
}
AsyncFunction("toggleAsync") { (view: GifView) in
view.toggle()
}
AsyncFunction("playAsync") { (view: GifView) in
view.play()
}
AsyncFunction("pauseAsync") { (view: GifView) in
view.pause()
}
}
}
}
@@ -0,0 +1,185 @@
import ExpoModulesCore
import SDWebImage
import SDWebImageWebPCoder
typealias SDWebImageContext = [SDWebImageContextOption: Any]
public class GifView: ExpoView, AVPlayerViewControllerDelegate {
// Events
private let onPlayerStateChange = EventDispatcher()
// SDWebImage
private let imageView = SDAnimatedImageView(frame: .zero)
private let imageManager = SDWebImageManager(
cache: SDImageCache.shared,
loader: SDImageLoadersManager.shared
)
private var isPlaying = true
private var isLoaded = false
// Requests
private var webpOperation: SDWebImageCombinedOperation?
private var placeholderOperation: SDWebImageCombinedOperation?
// Props
var source: String? = nil
var placeholderSource: String? = nil
var autoplay = true {
didSet {
if !autoplay {
self.pause()
} else {
self.play()
}
}
}
// MARK: - Lifecycle
public required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
self.clipsToBounds = true
self.imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.imageView.layer.masksToBounds = false
self.imageView.backgroundColor = .clear
self.imageView.contentMode = .scaleToFill
// We have to explicitly set this to false. If we don't, every time
// the view comes into the viewport, it will start animating again
self.imageView.autoPlayAnimatedImage = false
self.addSubview(self.imageView)
}
public override func willMove(toWindow newWindow: UIWindow?) {
if newWindow == nil {
// Don't cancel the placeholder operation, because we really want that to complete for
// when we scroll back up
self.webpOperation?.cancel()
self.placeholderOperation?.cancel()
} else if self.imageView.image == nil {
self.load()
}
}
// MARK: - Loading
private func load() {
guard let source = self.source, let placeholderSource = self.placeholderSource else {
return
}
self.webpOperation?.cancel()
self.placeholderOperation?.cancel()
// We only need to start an operation for the placeholder if it doesn't exist
// in the cache already. Cache key is by default the absolute URL of the image.
// See:
// https://github.com/SDWebImage/SDWebImage/blob/master/Docs/HowToUse.md#using-asynchronous-image-caching-independently
if !SDImageCache.shared.diskImageDataExists(withKey: source),
let url = URL(string: placeholderSource)
{
self.placeholderOperation = imageManager.loadImage(
with: url,
options: [.retryFailed],
context: Util.createContext(),
progress: onProgress(_:_:_:),
completed: onLoaded(_:_:_:_:_:_:)
)
}
if let url = URL(string: source) {
self.webpOperation = imageManager.loadImage(
with: url,
options: [.retryFailed],
context: Util.createContext(),
progress: onProgress(_:_:_:),
completed: onLoaded(_:_:_:_:_:_:)
)
}
}
private func setImage(_ image: UIImage) {
if self.imageView.image == nil || image.sd_isAnimated {
self.imageView.image = image
}
if image.sd_isAnimated {
self.firePlayerStateChange()
if isPlaying {
self.imageView.startAnimating()
}
}
}
// MARK: - Loading blocks
private func onProgress(_ receivedSize: Int, _ expectedSize: Int, _ imageUrl: URL?) {}
private func onLoaded(
_ image: UIImage?,
_ data: Data?,
_ error: Error?,
_ cacheType: SDImageCacheType,
_ finished: Bool,
_ imageUrl: URL?
) {
guard finished else {
return
}
if let placeholderSource = self.placeholderSource,
imageUrl?.absoluteString == placeholderSource,
self.imageView.image == nil,
let image = image
{
self.setImage(image)
return
}
if let source = self.source,
imageUrl?.absoluteString == source,
// UIImage perf suckssss if the image is animated
let data = data,
let animatedImage = SDAnimatedImage(data: data)
{
self.placeholderOperation?.cancel()
self.isPlaying = self.autoplay
self.isLoaded = true
self.setImage(animatedImage)
self.firePlayerStateChange()
}
}
// MARK: - Playback Controls
func play() {
self.imageView.startAnimating()
self.isPlaying = true
self.firePlayerStateChange()
}
func pause() {
self.imageView.stopAnimating()
self.isPlaying = false
self.firePlayerStateChange()
}
func toggle() {
if self.isPlaying {
self.pause()
} else {
self.play()
}
}
// MARK: - Util
private func firePlayerStateChange() {
onPlayerStateChange([
"isPlaying": self.isPlaying,
"isLoaded": self.isLoaded
])
}
}
@@ -0,0 +1,17 @@
import SDWebImage
class Util {
static func createContext() -> SDWebImageContext {
var context = SDWebImageContext()
// SDAnimatedImage for some reason has issues whenever loaded from memory. Instead, we
// will just use the disk. SDWebImage will manage this cache for us, so we don't need
// to worry about clearing it.
context[.originalQueryCacheType] = SDImageCacheType.disk.rawValue
context[.originalStoreCacheType] = SDImageCacheType.disk.rawValue
context[.queryCacheType] = SDImageCacheType.disk.rawValue
context[.storeCacheType] = SDImageCacheType.disk.rawValue
return context
}
}
@@ -0,0 +1,39 @@
import React from 'react'
import {requireNativeModule} from 'expo'
import {requireNativeViewManager} from 'expo-modules-core'
import {GifViewProps} from './GifView.types'
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
const NativeView: React.ComponentType<
GifViewProps & {ref: React.RefObject<any>}
> = requireNativeViewManager('ExpoBlueskyGifView')
export class GifView extends React.PureComponent<GifViewProps> {
// TODO native types, should all be the same as those in this class
private nativeRef: React.RefObject<any> = React.createRef()
constructor(props: GifViewProps | Readonly<GifViewProps>) {
super(props)
}
static async prefetchAsync(sources: string[]): Promise<void> {
return await NativeModule.prefetchAsync(sources)
}
async playAsync(): Promise<void> {
await this.nativeRef.current.playAsync()
}
async pauseAsync(): Promise<void> {
await this.nativeRef.current.pauseAsync()
}
async toggleAsync(): Promise<void> {
await this.nativeRef.current.toggleAsync()
}
render() {
return <NativeView {...this.props} ref={this.nativeRef} />
}
}
@@ -0,0 +1,15 @@
import {ViewProps} from 'react-native'
export interface GifViewStateChangeEvent {
nativeEvent: {
isPlaying: boolean
isLoaded: boolean
}
}
export interface GifViewProps extends ViewProps {
autoplay?: boolean
source?: string
placeholderSource?: string
onPlayerStateChange?: (event: GifViewStateChangeEvent) => void
}
@@ -0,0 +1,82 @@
import * as React from 'react'
import {StyleSheet} from 'react-native'
import {GifViewProps} from './GifView.types'
export class GifView extends React.PureComponent<GifViewProps> {
private readonly videoPlayerRef: React.RefObject<HTMLMediaElement> =
React.createRef()
private isLoaded = false
constructor(props: GifViewProps | Readonly<GifViewProps>) {
super(props)
}
componentDidUpdate(prevProps: Readonly<GifViewProps>) {
if (prevProps.autoplay !== this.props.autoplay) {
if (this.props.autoplay) {
this.playAsync()
} else {
this.pauseAsync()
}
}
}
static async prefetchAsync(_: string[]): Promise<void> {
console.warn('prefetchAsync is not supported on web')
}
private firePlayerStateChangeEvent = () => {
this.props.onPlayerStateChange?.({
nativeEvent: {
isPlaying: !this.videoPlayerRef.current?.paused,
isLoaded: this.isLoaded,
},
})
}
private onLoad = () => {
// Prevent multiple calls to onLoad because onCanPlay will fire after each loop
if (this.isLoaded) {
return
}
this.isLoaded = true
this.firePlayerStateChangeEvent()
}
async playAsync(): Promise<void> {
this.videoPlayerRef.current?.play()
}
async pauseAsync(): Promise<void> {
this.videoPlayerRef.current?.pause()
}
async toggleAsync(): Promise<void> {
if (this.videoPlayerRef.current?.paused) {
await this.playAsync()
} else {
await this.pauseAsync()
}
}
render() {
return (
<video
src={this.props.source}
autoPlay={this.props.autoplay ? 'autoplay' : undefined}
preload={this.props.autoplay ? 'auto' : undefined}
playsInline={true}
loop="loop"
muted="muted"
style={StyleSheet.flatten(this.props.style)}
onCanPlay={this.onLoad}
onPlay={this.firePlayerStateChangeEvent}
onPause={this.firePlayerStateChangeEvent}
aria-label={this.props.accessibilityLabel}
ref={this.videoPlayerRef}
/>
)
}
}
+1 -1
View File
@@ -50,7 +50,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "^0.12.3",
"@atproto/api": "^0.12.5",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1",
+9
View File
@@ -53,6 +53,7 @@ import {
setEmailConfirmationRequested,
shouldRequestEmailConfirmation,
} from './state/shell/reminders'
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
import {DebugModScreen} from './view/screens/DebugMod'
@@ -276,6 +277,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="AccessibilitySettings"
getComponent={() => AccessibilitySettingsScreen}
options={{
title: title(msg`Accessibility Settings`),
requireAuth: true,
}}
/>
<Stack.Screen
name="Hashtag"
getComponent={() => HashtagScreen}
+21 -16
View File
@@ -42,31 +42,36 @@ export function AppLanguageDropdown() {
// We don't have hitSlop here to increase the tap region,
// alternative is negative margins.
{height: 32, marginVertical: -((32 - 14) / 2)},
a.flex_row,
a.gap_sm,
a.align_center,
a.flex_shrink,
]}>
<Text aria-hidden={true} style={t.atoms.text_contrast_medium}>
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
</Text>
<ChevronDown fill={t.atoms.text.color} size="xs" style={a.flex_shrink} />
<View
style={[
a.flex_row,
a.gap_sm,
a.align_center,
a.flex_shrink,
a.h_full,
t.atoms.bg,
]}>
<Text aria-hidden={true} style={t.atoms.text_contrast_medium}>
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
</Text>
<ChevronDown fill={t.atoms.text.color} size="xs" style={a.flex_0} />
</View>
<select
value={sanitizedLang}
onChange={onChangeAppLanguage}
style={{
fontSize: a.text_sm.fontSize,
letterSpacing: a.text_sm.letterSpacing,
cursor: 'pointer',
MozAppearance: 'none',
WebkitAppearance: 'none',
appearance: 'none',
position: 'absolute',
inset: 0,
width: '100%',
color: 'transparent',
background: 'transparent',
border: 0,
padding: 0,
opacity: 0,
color: t.atoms.text.color,
background: t.atoms.bg.backgroundColor,
padding: 4,
maxWidth: '100%',
}}>
{APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => (
<option key={l.code2} value={l.code2}>
+32 -2
View File
@@ -4,6 +4,8 @@ import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import BottomSheet, {
BottomSheetBackdropProps,
BottomSheetFlatList,
BottomSheetFlatListMethods,
BottomSheetScrollView,
BottomSheetScrollViewMethods,
BottomSheetTextInput,
@@ -11,10 +13,10 @@ import BottomSheet, {
useBottomSheet,
WINDOW_HEIGHT,
} from '@discord/bottom-sheet/src'
import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types'
import {logger} from '#/logger'
import {useDialogStateControlContext} from '#/state/dialogs'
import {isNative} from 'platform/detection'
import {atoms as a, flatten, useTheme} from '#/alf'
import {Context} from '#/components/Dialog/context'
import {
@@ -238,7 +240,7 @@ export const ScrollableInner = React.forwardRef<
},
flatten(style),
]}
contentContainerStyle={isNative ? a.pb_4xl : undefined}
contentContainerStyle={a.pb_4xl}
ref={ref}>
{children}
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
@@ -246,6 +248,34 @@ export const ScrollableInner = React.forwardRef<
)
})
export const InnerFlatList = React.forwardRef<
BottomSheetFlatListMethods,
BottomSheetFlatListProps<any>
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
const insets = useSafeAreaInsets()
return (
<BottomSheetFlatList
keyboardShouldPersistTaps="handled"
contentContainerStyle={[a.pb_4xl, flatten(contentContainerStyle)]}
ListFooterComponent={
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
}
ref={ref}
{...props}
style={[
a.flex_1,
a.p_xl,
a.pt_0,
a.h_full,
{
marginTop: 40,
},
flatten(style),
]}
/>
)
})
export function Handle() {
const t = useTheme()
+29 -1
View File
@@ -1,5 +1,10 @@
import React, {useImperativeHandle} from 'react'
import {TouchableWithoutFeedback, View} from 'react-native'
import {
FlatList,
FlatListProps,
TouchableWithoutFeedback,
View,
} from 'react-native'
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -192,6 +197,29 @@ export function Inner({
export const ScrollableInner = Inner
export const InnerFlatList = React.forwardRef<
FlatList,
FlatListProps<any> & {label: string}
>(function InnerFlatList({label, style, ...props}, ref) {
const {gtMobile} = useBreakpoints()
return (
<Inner
label={label}
// @ts-ignore web only -sfn
style={{
paddingHorizontal: 0,
maxHeight: 'calc(-36px + 100vh)',
overflow: 'hidden',
}}>
<FlatList
ref={ref}
style={[gtMobile ? a.px_2xl : a.px_xl, flatten(style)]}
{...props}
/>
</Inner>
)
})
export function Handle() {
return null
}
+12 -3
View File
@@ -16,10 +16,14 @@ export function Error({
title,
message,
onRetry,
onGoBack: onGoBackProp,
sideBorders = true,
}: {
title?: string
message?: string
onRetry?: () => unknown
onGoBack?: () => unknown
sideBorders?: boolean
}) {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
@@ -28,6 +32,10 @@ export function Error({
const canGoBack = navigation.canGoBack()
const onGoBack = React.useCallback(() => {
if (onGoBackProp) {
onGoBackProp()
return
}
if (canGoBack) {
navigation.goBack()
} else {
@@ -41,18 +49,19 @@ export function Error({
navigation.dispatch(StackActions.popToTop())
}
}
}, [navigation, canGoBack])
}, [navigation, canGoBack, onGoBackProp])
return (
<CenteredView
style={[
a.flex_1,
a.align_center,
!gtMobile ? a.justify_between : a.gap_5xl,
a.gap_5xl,
!gtMobile && a.justify_between,
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}
sideBorders>
sideBorders={sideBorders}>
<View style={[a.w_full, a.align_center, a.gap_lg]}>
<Text style={[a.font_bold, a.text_3xl]}>{title}</Text>
<Text
+19 -6
View File
@@ -1,11 +1,11 @@
import React from 'react'
import {View} from 'react-native'
import React, {memo} from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from 'lib/strings/errors'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Error} from '#/components/Error'
import {Loader} from '#/components/Loader'
@@ -16,11 +16,13 @@ export function ListFooter({
error,
onRetry,
height,
style,
}: {
isFetchingNextPage?: boolean
error?: string
onRetry?: () => Promise<unknown>
height?: number
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
@@ -33,6 +35,7 @@ export function ListFooter({
a.pb_lg,
t.atoms.border_contrast_low,
{height: height ?? 180, paddingTop: 30},
flatten(style),
]}>
{isFetchingNextPage ? (
<Loader size="xl" />
@@ -120,7 +123,7 @@ export function ListHeaderDesktop({
)
}
export function ListMaybePlaceholder({
let ListMaybePlaceholder = ({
isLoading,
noEmpty,
isError,
@@ -130,6 +133,8 @@ export function ListMaybePlaceholder({
errorMessage,
emptyType = 'page',
onRetry,
onGoBack,
sideBorders,
}: {
isLoading: boolean
noEmpty?: boolean
@@ -140,7 +145,9 @@ export function ListMaybePlaceholder({
errorMessage?: string
emptyType?: 'page' | 'results'
onRetry?: () => Promise<unknown>
}) {
onGoBack?: () => void
sideBorders?: boolean
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {gtMobile, gtTablet} = useBreakpoints()
@@ -155,7 +162,7 @@ export function ListMaybePlaceholder({
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}
sideBorders={gtMobile}
sideBorders={sideBorders ?? gtMobile}
topBorder={!gtTablet}>
<View style={[a.w_full, a.align_center, {top: 100}]}>
<Loader size="xl" />
@@ -170,6 +177,8 @@ export function ListMaybePlaceholder({
title={errorTitle ?? _(msg`Oops!`)}
message={errorMessage ?? _(`Something went wrong!`)}
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
/>
)
}
@@ -188,9 +197,13 @@ export function ListMaybePlaceholder({
_(msg`We're sorry! We can't find the page you were looking for.`)
}
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
/>
)
}
return null
}
ListMaybePlaceholder = memo(ListMaybePlaceholder)
export {ListMaybePlaceholder}
+13 -3
View File
@@ -2,7 +2,9 @@ import React from 'react'
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {toShortUrl} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf'
@@ -178,8 +180,15 @@ function RichTextTag({
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const navigation = useNavigation<NavigationProp>()
const open = React.useCallback(() => {
const navigateToPage = React.useCallback(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
}, [navigation, tag])
const openDialog = React.useCallback(() => {
control.open()
}, [control])
@@ -195,9 +204,10 @@ function RichTextTag({
selectable={selectable}
{...native({
accessibilityLabel: _(msg`Hashtag: #${tag}`),
accessibilityHint: _(msg`Click here to open tag menu for #${tag}`),
accessibilityHint: _(msg`Long press to open tag menu for #${tag}`),
accessibilityRole: isNative ? 'button' : undefined,
onPress: open,
onPress: navigateToPage,
onLongPress: openDialog,
onPressIn: onPressIn,
onPressOut: onPressOut,
})}
+305
View File
@@ -0,0 +1,305 @@
import React, {useCallback, useMemo, useRef, useState} from 'react'
import {Keyboard, TextInput, View} from 'react-native'
import {Image} from 'expo-image'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection'
import {
Gif,
useFeaturedGifsQuery,
useGifSearchQuery,
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {ListFooter, ListMaybePlaceholder} from '../Lists'
export function GifSelectDialog({
control,
onClose,
onSelectGif: onSelectGifProp,
}: {
control: Dialog.DialogControlProps
onClose: () => void
onSelectGif: (gif: Gif) => void
}) {
const onSelectGif = useCallback(
(gif: Gif) => {
control.close(() => onSelectGifProp(gif))
},
[control, onSelectGifProp],
)
const renderErrorBoundary = useCallback(
(error: any) => <DialogError details={String(error)} />,
[],
)
return (
<Dialog.Outer
control={control}
nativeOptions={{sheet: {snapPoints: ['100%']}}}
onClose={onClose}>
<Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<GifList control={control} onSelectGif={onSelectGif} />
</ErrorBoundary>
</Dialog.Outer>
)
}
function GifList({
control,
onSelectGif,
}: {
control: Dialog.DialogControlProps
onSelectGif: (gif: Gif) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const textInputRef = useRef<TextInput>(null)
const listRef = useRef<BottomSheetFlatListMethods>(null)
const [undeferredSearch, setSearch] = useState('')
const search = useThrottledValue(undeferredSearch, 500)
const isSearching = search.length > 0
const trendingQuery = useFeaturedGifsQuery()
const searchQuery = useGifSearchQuery(search)
const {
data,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
error,
isLoading,
isError,
refetch,
} = isSearching ? searchQuery : trendingQuery
const flattenedData = useMemo(() => {
return data?.pages.flatMap(page => page.results) || []
}, [data])
const renderItem = useCallback(
({item}: {item: Gif}) => {
return <GifPreview gif={item} onSelectGif={onSelectGif} />
},
[onSelectGif],
)
const onEndReached = React.useCallback(() => {
if (isFetchingNextPage || !hasNextPage || error) return
fetchNextPage()
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
const hasData = flattenedData.length > 0
const onGoBack = useCallback(() => {
if (isSearching) {
// clear the input and reset the state
textInputRef.current?.clear()
setSearch('')
} else {
control.close()
}
}, [control, isSearching])
const listHeader = useMemo(() => {
return (
<View
style={[
a.relative,
a.mb_lg,
a.flex_row,
a.align_center,
!gtMobile && isWeb && a.gap_md,
]}>
{/* cover top corners */}
<View
style={[
a.absolute,
a.inset_0,
{
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
},
t.atoms.bg,
]}
/>
{!gtMobile && isWeb && (
<Button
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={() => control.close()}
label={_(msg`Close GIF dialog`)}>
<ButtonIcon icon={Arrow} size="md" />
</Button>
)}
<TextField.Root>
<TextField.Icon icon={Search} />
<TextField.Input
label={_(msg`Search GIFs`)}
placeholder={_(msg`Search Tenor`)}
onChangeText={text => {
setSearch(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
returnKeyType="search"
clearButtonMode="while-editing"
inputRef={textInputRef}
maxLength={50}
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
control.close()
}
}}
/>
</TextField.Root>
</View>
)
}, [gtMobile, t.atoms.bg, _, control])
return (
<>
{gtMobile && <Dialog.Close />}
<Dialog.InnerFlatList
ref={listRef}
key={gtMobile ? '3 cols' : '2 cols'}
data={flattenedData}
renderItem={renderItem}
numColumns={gtMobile ? 3 : 2}
columnWrapperStyle={a.gap_sm}
ListHeaderComponent={
<>
{listHeader}
{!hasData && (
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
onRetry={refetch}
onGoBack={onGoBack}
emptyType="results"
sideBorders={false}
errorTitle={_(msg`Failed to load GIFs`)}
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
emptyMessage={
isSearching
? _(msg`No search results found for "${search}".`)
: _(
msg`No featured GIFs found. There may be an issue with Tenor.`,
)
}
/>
)}
</>
}
stickyHeaderIndices={[0]}
onEndReached={onEndReached}
onEndReachedThreshold={4}
keyExtractor={(item: Gif) => item.id}
// @ts-expect-error web only
style={isWeb && {minHeight: '100vh'}}
onScrollBeginDrag={() => Keyboard.dismiss()}
ListFooterComponent={
hasData ? (
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
style={{borderTopWidth: 0}}
/>
) : null
}
/>
</>
)
}
function GifPreview({
gif,
onSelectGif,
}: {
gif: Gif
onSelectGif: (gif: Gif) => void
}) {
const {gtTablet} = useBreakpoints()
const {_} = useLingui()
const t = useTheme()
const onPress = useCallback(() => {
logEvent('composer:gif:select', {})
onSelectGif(gif)
}, [onSelectGif, gif])
return (
<Button
label={_(msg`Select GIF "${gif.title}"`)}
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
onPress={onPress}>
{({pressed}) => (
<Image
style={[
a.flex_1,
a.mb_sm,
a.rounded_sm,
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
t.atoms.bg_contrast_25,
]}
source={{
uri: gif.media_formats.tinygif.url,
}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
function DialogError({details}: {details?: string}) {
const {_} = useLingui()
const control = Dialog.useDialogContext()
return (
<Dialog.ScrollableInner style={a.gap_md} label={_(msg`An error occured`)}>
<Dialog.Close />
<ErrorScreen
title={_(msg`Oh no!`)}
message={_(
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
)}
details={details}
/>
<Button
label={_(msg`Close dialog`)}
onPress={() => control.close()}
color="primary"
size="medium"
variant="solid">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</Dialog.ScrollableInner>
)
}
+9 -8
View File
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {type SessionAccount, useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {atoms as a} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {AccountList} from '../AccountList'
@@ -21,23 +20,25 @@ export function SwitchAccountDialog({
const {currentAccount} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
const onSelectAccount = useCallback(
(account: SessionAccount) => {
if (account.did === currentAccount?.did) {
control.close()
if (account.did !== currentAccount?.did) {
control.close(() => {
onPressSwitchAccount(account, 'SwitchAccount')
})
} else {
onPressSwitchAccount(account, 'SwitchAccount')
control.close()
}
},
[currentAccount, control, onPressSwitchAccount],
)
const onPressAddAccount = useCallback(() => {
setShowLoggedOut(true)
closeAllActiveElements()
}, [setShowLoggedOut, closeAllActiveElements])
control.close(() => {
setShowLoggedOut(true)
})
}, [setShowLoggedOut, control])
return (
<Dialog.Outer control={control}>
+27
View File
@@ -0,0 +1,27 @@
import {useEffect, useRef, useState} from 'react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
export function useThrottledValue<T>(value: T, time?: number) {
const pendingValueRef = useRef(value)
const [throttledValue, setThrottledValue] = useState(value)
useEffect(() => {
pendingValueRef.current = value
}, [value])
const handleTick = useNonReactiveCallback(() => {
if (pendingValueRef.current !== throttledValue) {
setThrottledValue(pendingValueRef.current)
}
})
useEffect(() => {
const id = setInterval(handleTick, time)
return () => {
clearInterval(id)
}
}, [handleTick, time])
return throttledValue
}
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z',
})
export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
})
+9
View File
@@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Gif_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3Zm1 14V6h16v12H4Zm2-5.713c0 1.54.92 2.463 2.48 2.463 1.434 0 2.353-.807 2.353-2.06v-.166c0-.578-.267-.834-.884-.834h-.806c-.416 0-.632.182-.632.535 0 .357.22.55.632.55h.146v.063c0 .36-.299.609-.735.609-.597 0-.904-.4-.904-1.168v-.52c0-.775.307-1.155.951-1.155.325 0 .538.152.746.3.089.064.176.127.272.177a.82.82 0 0 0 .409.108c.385 0 .656-.263.656-.636 0-.353-.26-.679-.664-.915-.409-.24-.96-.388-1.548-.388C6.955 9.25 6 10.2 6 11.67v.617Zm6.358 2.385c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm3.367-.872c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Z',
})
export const GifSquare_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V5h14v14H5Zm10.725-5.2c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Zm-3.367.872c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm-3.879.078C6.92 14.75 6 13.827 6 12.287v-.617c0-1.47.955-2.42 2.472-2.42.589 0 1.139.147 1.548.388.404.236.664.562.664.915 0 .373-.271.636-.656.636a.82.82 0 0 1-.41-.108 2.34 2.34 0 0 1-.271-.177c-.208-.148-.421-.3-.746-.3-.644 0-.95.38-.95 1.155v.52c0 .768.306 1.168.903 1.168.436 0 .735-.248.735-.61v-.061h-.146c-.412 0-.632-.194-.632-.551 0-.353.216-.535.632-.535h.806c.617 0 .884.256.884.834v.166c0 1.253-.92 2.06-2.354 2.06Z',
})
+7
View File
@@ -109,3 +109,10 @@ export const RECOMMENDED_SAVED_FEEDS: Pick<
pinned: true,
},
]
export const GIF_SERVICE = 'https://gifs.bsky.app'
export const GIF_SEARCH = (params: string) =>
`${GIF_SERVICE}/tenor/v2/search?${params}`
export const GIF_FEATURED = (params: string) =>
`${GIF_SERVICE}/tenor/v2/featured?${params}`
+4 -14
View File
@@ -1,17 +1,15 @@
import {useCallback} from 'react'
import {isWeb} from '#/platform/detection'
import {useAnalytics} from '#/lib/analytics/analytics'
import {useSessionApi, SessionAccount} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {useCloseAllActiveElements} from '#/state/util'
import {isWeb} from '#/platform/detection'
import {SessionAccount, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import * as Toast from '#/view/com/util/Toast'
import {LogEvents} from '../statsig/statsig'
export function useAccountSwitcher() {
const {track} = useAnalytics()
const {selectAccount, clearCurrentAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const onPressSwitchAccount = useCallback(
@@ -23,7 +21,6 @@ export function useAccountSwitcher() {
try {
if (account.accessJwt) {
closeAllActiveElements()
if (isWeb) {
// We're switching accounts, which remounts the entire app.
// On mobile, this gets us Home, but on the web we also need reset the URL.
@@ -37,7 +34,6 @@ export function useAccountSwitcher() {
Toast.show(`Signed in as @${account.handle}`)
}, 100)
} else {
closeAllActiveElements()
requestSwitchToAccount({requestedAccount: account.did})
Toast.show(
`Please sign in as @${account.handle}`,
@@ -49,13 +45,7 @@ export function useAccountSwitcher() {
clearCurrentAccount() // back user out to login
}
},
[
track,
clearCurrentAccount,
selectAccount,
closeAllActiveElements,
requestSwitchToAccount,
],
[track, clearCurrentAccount, selectAccount, requestSwitchToAccount],
)
return {onPressSwitchAccount}
+1 -3
View File
@@ -12,7 +12,6 @@ import {
import {logger} from '#/logger'
import {IS_TESTFLIGHT} from 'lib/app-info'
import {useGate} from 'lib/statsig/statsig'
import {isIOS} from 'platform/detection'
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
@@ -31,8 +30,7 @@ async function setExtraParams() {
}
export function useOTAUpdates() {
const gate = useGate()
const shouldReceiveUpdates = isEnabled && !__DEV__ && gate('receive_updates')
const shouldReceiveUpdates = isEnabled && !__DEV__
const appState = React.useRef<AppStateStatus>('active')
const lastMinimize = React.useRef(0)
+1
View File
@@ -35,6 +35,7 @@ export type CommonNavigatorParams = {
PreferencesFollowingFeed: undefined
PreferencesThreads: undefined
PreferencesExternalEmbeds: undefined
AccessibilitySettings: undefined
Search: {q?: string}
Hashtag: {tag: string; author?: string}
}
+12
View File
@@ -60,6 +60,8 @@ export type LogEvents = {
feedType: string
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
}
'composer:gif:open': {}
'composer:gif:select': {}
// Data events
'account:create:begin': {}
@@ -111,4 +113,14 @@ export type LogEvents = {
| 'ProfileMenu'
| 'ProfileHoverCard'
}
'test:all:always': {}
'test:all:sometimes': {}
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
'test:all:boosted_by_gate2': {reason: 'base' | 'gate2'}
'test:all:boosted_by_both': {reason: 'base' | 'gate1' | 'gate2'}
'test:gate1:always': {}
'test:gate1:sometimes': {}
'test:gate2:always': {}
'test:gate2:sometimes': {}
}
+6
View File
@@ -6,6 +6,7 @@ import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {IS_TESTFLIGHT} from 'lib/app-info'
import {useSession} from '../../state/session'
import {timeout} from '../async/timeout'
@@ -23,6 +24,8 @@ type StatsigUser = {
platform: 'ios' | 'android' | 'web'
refSrc: string
refUrl: string
appLanguage: string
contentLanguages: string[]
}
}
@@ -132,6 +135,7 @@ function toStatsigUser(did: string | undefined): StatsigUser {
if (did) {
userID = sha256(did)
}
const languagePrefs = persisted.get('languagePrefs')
return {
userID,
platform: Platform.OS as 'ios' | 'android' | 'web',
@@ -139,6 +143,8 @@ function toStatsigUser(did: string | undefined): StatsigUser {
refSrc,
refUrl,
platform: Platform.OS as 'ios' | 'android' | 'web',
appLanguage: languagePrefs.appLanguage,
contentLanguages: languagePrefs.contentLanguages,
},
}
}
+41 -14
View File
@@ -1,4 +1,5 @@
import {Dimensions} from 'react-native'
import {Dimensions, Platform} from 'react-native'
import {isWeb} from 'platform/detection'
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
@@ -60,6 +61,10 @@ export interface EmbedPlayerParams {
source: EmbedPlayerSource
metaUri?: string
hideDetails?: boolean
dimensions?: {
height: number
width: number
}
}
const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
@@ -266,7 +271,7 @@ export function parseEmbedPlayerFromUrl(
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
}
}
}
@@ -287,7 +292,7 @@ export function parseEmbedPlayerFromUrl(
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${trackingOrId}`,
playerUri: `https://i.giphy.com/media/${trackingOrId}/giphy.webp`,
playerUri: `https://i.giphy.com/media/${trackingOrId}/200.webp`,
}
} else if (filename && gifFilenameRegex.test(filename)) {
return {
@@ -296,7 +301,7 @@ export function parseEmbedPlayerFromUrl(
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${idOrFilename}`,
playerUri: `https://i.giphy.com/media/${idOrFilename}/giphy.webp`,
playerUri: `https://i.giphy.com/media/${idOrFilename}/200.webp`,
}
}
}
@@ -315,7 +320,7 @@ export function parseEmbedPlayerFromUrl(
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
}
} else if (mediaOrFilename) {
const gifId = mediaOrFilename.split('.')[0]
@@ -327,26 +332,48 @@ export function parseEmbedPlayerFromUrl(
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${
mediaOrFilename.split('.')[0]
}/giphy.webp`,
}/200.webp`,
}
}
}
if (urlp.hostname === 'tenor.com' || urlp.hostname === 'www.tenor.com') {
const [_, pathOrIntl, pathOrFilename, intlFilename] =
urlp.pathname.split('/')
const isIntl = pathOrFilename === 'view'
const filename = isIntl ? intlFilename : pathOrFilename
if (urlp.hostname === 'media.tenor.com') {
let [_, id, filename] = urlp.pathname.split('/')
if ((pathOrIntl === 'view' || pathOrFilename === 'view') && filename) {
const includesExt = filename.split('.').pop() === 'gif'
const h = urlp.searchParams.get('hh')
const w = urlp.searchParams.get('ww')
let dimensions
if (h && w) {
dimensions = {
height: Number(h),
width: Number(w),
}
}
if (id && filename && dimensions && id.includes('AAAAC')) {
if (Platform.OS === 'web') {
const isSafari = /^((?!chrome|android).)*safari/i.test(
navigator.userAgent,
)
if (isSafari) {
id = id.replace('AAAAC', 'AAAP1')
filename = filename.replace('.gif', '.mp4')
} else {
id = id.replace('AAAAC', 'AAAP3')
filename = filename.replace('.gif', '.webm')
}
} else {
id = id.replace('AAAAC', 'AAAAM')
}
return {
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: `${url}${!includesExt ? '.gif' : ''}`,
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
dimensions,
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -29,6 +29,7 @@ export const router = new Router({
PreferencesFollowingFeed: '/settings/following-feed',
PreferencesThreads: '/settings/threads',
PreferencesExternalEmbeds: '/settings/external-embeds',
AccessibilitySettings: '/settings/accessibility',
SavedFeeds: '/settings/saved-feeds',
Support: '/support',
PrivacyPolicy: '/support/privacy',
+144 -64
View File
@@ -1,11 +1,12 @@
import React from 'react'
import {ListRenderItemInfo, Pressable} from 'react-native'
import {ListRenderItemInfo, Pressable, StyleSheet, View} from 'react-native'
import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {usePalette} from '#/lib/hooks/usePalette'
import {HITSLOP_10} from 'lib/constants'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {CommonNavigatorParams} from 'lib/routes/types'
@@ -13,18 +14,17 @@ import {shareUrl} from 'lib/sharing'
import {cleanError} from 'lib/strings/errors'
import {sanitizeHandle} from 'lib/strings/handles'
import {enforceLen} from 'lib/strings/helpers'
import {isNative} from 'platform/detection'
import {isNative, isWeb} from 'platform/detection'
import {useSearchPostsQuery} from 'state/queries/search-posts'
import {useSetMinimalShellMode} from 'state/shell'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from 'state/shell'
import {Pager} from '#/view/com/pager/Pager'
import {TabBar} from '#/view/com/pager/TabBar'
import {CenteredView} from '#/view/com/util/Views'
import {Post} from 'view/com/post/Post'
import {List} from 'view/com/util/List'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox'
import {
ListFooter,
ListHeaderDesktop,
ListMaybePlaceholder,
} from '#/components/Lists'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
const renderItem = ({item}: ListRenderItemInfo<PostView>) => {
return <Post post={item} />
@@ -38,20 +38,13 @@ export default function HashtagScreen({
route,
}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
const {tag, author} = route.params
const setMinimalShellMode = useSetMinimalShellMode()
const {_} = useLingui()
const initialNumToRender = useInitialNumToRender()
const [isPTR, setIsPTR] = React.useState(false)
const pal = usePalette('default')
const fullTag = React.useMemo(() => {
return `#${decodeURIComponent(tag)}`
}, [tag])
const queryParam = React.useMemo(() => {
if (!author) return fullTag
return `${fullTag} from:${sanitizeHandle(author)}`
}, [fullTag, author])
const headerTitle = React.useMemo(() => {
return enforceLen(fullTag.toLowerCase(), 24, true, 'middle')
}, [fullTag])
@@ -61,27 +54,6 @@ export default function HashtagScreen({
return sanitizeHandle(author)
}, [author])
const {
data,
isFetchingNextPage,
isLoading,
isError,
error,
refetch,
fetchNextPage,
hasNextPage,
} = useSearchPostsQuery({query: queryParam})
const posts = React.useMemo(() => {
return data?.pages.flatMap(page => page.posts) || []
}, [data])
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
const onShare = React.useCallback(() => {
const url = new URL('https://bsky.app')
url.pathname = `/hashtag/${decodeURIComponent(tag)}`
@@ -91,6 +63,131 @@ export default function HashtagScreen({
shareUrl(url.toString())
}, [tag, author])
const [activeTab, setActiveTab] = React.useState(0)
const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
setDrawerSwipeDisabled(index > 0)
setActiveTab(index)
},
[setDrawerSwipeDisabled, setMinimalShellMode],
)
const sections = React.useMemo(() => {
return [
{
title: _(msg`Top`),
component: (
<HashtagScreenTab
fullTag={fullTag}
author={author}
sort="top"
active={activeTab === 0}
/>
),
},
{
title: _(msg`Latest`),
component: (
<HashtagScreenTab
fullTag={fullTag}
author={author}
sort="latest"
active={activeTab === 1}
/>
),
},
]
}, [_, fullTag, author, activeTab])
return (
<>
<CenteredView sideBorders style={[pal.border, pal.view]}>
<ViewHeader
showOnDesktop
title={headerTitle}
subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined}
canGoBack
renderButton={
isNative
? () => (
<Pressable
accessibilityRole="button"
onPress={onShare}
hitSlop={HITSLOP_10}>
<ArrowOutOfBox_Stroke2_Corner0_Rounded
size="lg"
onPress={onShare}
/>
</Pressable>
)
: undefined
}
/>
</CenteredView>
<Pager
onPageSelected={onPageSelected}
renderTabBar={props => (
<CenteredView
sideBorders
style={[pal.border, pal.view, styles.tabBarContainer]}>
<TabBar items={sections.map(section => section.title)} {...props} />
</CenteredView>
)}
initialPage={0}>
{sections.map((section, i) => (
<View key={i}>{section.component}</View>
))}
</Pager>
</>
)
}
function HashtagScreenTab({
fullTag,
author,
sort,
active,
}: {
fullTag: string
author: string | undefined
sort: 'top' | 'latest'
active: boolean
}) {
const {_} = useLingui()
const initialNumToRender = useInitialNumToRender()
const [isPTR, setIsPTR] = React.useState(false)
const queryParam = React.useMemo(() => {
if (!author) return fullTag
return `${fullTag} from:${sanitizeHandle(author)}`
}, [fullTag, author])
const {
data,
isFetched,
isFetchingNextPage,
isLoading,
isError,
error,
refetch,
fetchNextPage,
hasNextPage,
} = useSearchPostsQuery({query: queryParam, sort, enabled: active})
const posts = React.useMemo(() => {
return data?.pages.flatMap(page => page.posts) || []
}, [data])
const onRefresh = React.useCallback(async () => {
setIsPTR(true)
await refetch()
@@ -104,29 +201,9 @@ export default function HashtagScreen({
return (
<>
<ViewHeader
title={headerTitle}
subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined}
canGoBack
renderButton={
isNative
? () => (
<Pressable
accessibilityRole="button"
onPress={onShare}
hitSlop={HITSLOP_10}>
<ArrowOutOfBox_Stroke2_Corner0_Rounded
size="lg"
onPress={onShare}
/>
</Pressable>
)
: undefined
}
/>
{posts.length < 1 ? (
<ListMaybePlaceholder
isLoading={isLoading}
isLoading={isLoading || !isFetched}
isError={isError}
onRetry={refetch}
emptyType="results"
@@ -143,12 +220,6 @@ export default function HashtagScreen({
onEndReachedThreshold={4}
// @ts-ignore web only -prf
desktopFixedHeight
ListHeaderComponent={
<ListHeaderDesktop
title={headerTitle}
subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined}
/>
}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
@@ -163,3 +234,12 @@ export default function HashtagScreen({
</>
)
}
const styles = StyleSheet.create({
tabBarContainer: {
// @ts-ignore web only
position: isWeb ? 'sticky' : '',
top: 0,
zIndex: 1,
},
})
+50 -2
View File
@@ -6,7 +6,10 @@ import {
TextInput,
View,
} from 'react-native'
import {ComAtprotoServerDescribeServer} from '@atproto/api'
import {
ComAtprotoServerCreateSession,
ComAtprotoServerDescribeServer,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -23,6 +26,7 @@ import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {FormContainer} from './FormContainer'
@@ -53,8 +57,11 @@ export const LoginForm = ({
const {track} = useAnalytics()
const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
useState<boolean>(false)
const [identifier, setIdentifier] = useState<string>(initialHandle)
const [password, setPassword] = useState<string>('')
const [authFactorToken, setAuthFactorToken] = useState<string>('')
const passwordInputRef = useRef<TextInput>(null)
const {_} = useLingui()
const {login} = useSessionApi()
@@ -100,6 +107,7 @@ export const LoginForm = ({
service: serviceUrl,
identifier: fullIdent,
password,
authFactorToken: authFactorToken.trim(),
},
'LoginForm',
)
@@ -107,7 +115,16 @@ export const LoginForm = ({
const errMsg = e.toString()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setIsProcessing(false)
if (errMsg.includes('Authentication Required')) {
if (
e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
) {
setIsAuthFactorTokenNeeded(true)
} else if (errMsg.includes('Token is invalid')) {
logger.debug('Failed to login due to invalid 2fa token', {
error: errMsg,
})
setError(_(msg`Invalid 2FA confirmation code.`))
} else if (errMsg.includes('Authentication Required')) {
logger.debug('Failed to login due to invalid credentials', {
error: errMsg,
})
@@ -215,6 +232,37 @@ export const LoginForm = ({
</TextField.Root>
</View>
</View>
{isAuthFactorTokenNeeded && (
<View>
<TextField.LabelText>
<Trans>2FA Confirmation</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Ticket} />
<TextField.Input
testID="loginAuthFactorTokenInput"
label={_(msg`Confirmation code`)}
autoCapitalize="none"
autoFocus
autoCorrect={false}
autoComplete="off"
returnKeyType="done"
textContentType="username"
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
value={authFactorToken}
onChangeText={setAuthFactorToken}
onSubmitEditing={onPressNext}
editable={!isProcessing}
accessibilityHint={_(
msg`Input the code which has been emailed to you`,
)}
/>
</TextField.Root>
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.mt_sm]}>
<Trans>Check your email for a login code and enter it here.</Trans>
</Text>
</View>
)}
<FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}>
<Button
@@ -94,7 +94,7 @@ let ProfileHeaderStandard = ({
)}`,
),
)
if (isWeb && gate('autoexpand_suggestions_on_profile_follow')) {
if (isWeb && gate('autoexpand_suggestions_on_profile_follow_v2')) {
setShowSuggestedFollows(true)
}
} catch (e: any) {
+1
View File
@@ -107,6 +107,7 @@ export interface PostLanguagesSettingsModal {
export interface VerifyEmailModal {
name: 'verify-email'
showReminder?: boolean
onSuccess?: () => void
}
export interface ChangeEmailModal {
+3
View File
@@ -11,6 +11,7 @@ const accountSchema = z.object({
handle: z.string(),
email: z.string().optional(),
emailConfirmed: z.boolean().optional(),
emailAuthFactor: z.boolean().optional(),
refreshJwt: z.string().optional(), // optional because it can expire
accessJwt: z.string().optional(), // optional because it can expire
deactivated: z.boolean().optional(),
@@ -60,6 +61,7 @@ export const schema = z.object({
lastSelectedHomeFeed: z.string().optional(),
pdsAddressHistory: z.array(z.string()).optional(),
disableHaptics: z.boolean().optional(),
disableAutoplay: z.boolean().optional(),
})
export type Schema = z.infer<typeof schema>
@@ -96,4 +98,5 @@ export const defaults: Schema = {
lastSelectedHomeFeed: undefined,
pdsAddressHistory: [],
disableHaptics: false,
disableAutoplay: false,
}
+42
View File
@@ -0,0 +1,42 @@
import React from 'react'
import * as persisted from '#/state/persisted'
type StateContext = boolean
type SetContext = (v: boolean) => void
const stateContext = React.createContext<StateContext>(
Boolean(persisted.defaults.disableAutoplay),
)
const setContext = React.createContext<SetContext>((_: boolean) => {})
export function Provider({children}: {children: React.ReactNode}) {
const [state, setState] = React.useState(
Boolean(persisted.get('disableAutoplay')),
)
const setStateWrapped = React.useCallback(
(autoplayDisabled: persisted.Schema['disableAutoplay']) => {
setState(Boolean(autoplayDisabled))
persisted.write('disableAutoplay', autoplayDisabled)
},
[setState],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(Boolean(persisted.get('disableAutoplay')))
})
}, [setStateWrapped])
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setStateWrapped}>
{children}
</setContext.Provider>
</stateContext.Provider>
)
}
export const useAutoplayDisabled = () => React.useContext(stateContext)
export const useSetAutoplayDisabled = () => React.useContext(setContext)
@@ -1,9 +1,13 @@
import React from 'react'
import * as persisted from '#/state/persisted'
import {EmbedPlayerSource} from 'lib/strings/embed-player'
type StateContext = persisted.Schema['externalEmbeds']
type SetContext = (source: EmbedPlayerSource, value: 'show' | 'hide') => void
type SetContext = (
source: EmbedPlayerSource,
value: 'show' | 'hide' | undefined,
) => void
const stateContext = React.createContext<StateContext>(
persisted.defaults.externalEmbeds,
@@ -14,7 +18,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('externalEmbeds'))
const setStateWrapped = React.useCallback(
(source: EmbedPlayerSource, value: 'show' | 'hide') => {
(source: EmbedPlayerSource, value: 'show' | 'hide' | undefined) => {
setState(prev => {
persisted.write('externalEmbeds', {
...prev,
+8 -3
View File
@@ -1,9 +1,10 @@
import React from 'react'
import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
import {Provider as AltTextRequiredProvider} from './alt-text-required'
import {Provider as AutoplayProvider} from './autoplay'
import {Provider as DisableHapticsProvider} from './disable-haptics'
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
import {Provider as HiddenPostsProvider} from './hidden-posts'
import {Provider as InAppBrowserProvider} from './in-app-browser'
import {Provider as LanguagesProvider} from './languages'
@@ -11,6 +12,8 @@ export {
useRequireAltTextEnabled,
useSetRequireAltTextEnabled,
} from './alt-text-required'
export {useAutoplayDisabled, useSetAutoplayDisabled} from './autoplay'
export {useHapticsDisabled, useSetHapticsDisabled} from './disable-haptics'
export {
useExternalEmbedsPrefs,
useSetExternalEmbedPref,
@@ -26,7 +29,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
<ExternalEmbedsProvider>
<HiddenPostsProvider>
<InAppBrowserProvider>
<DisableHapticsProvider>{children}</DisableHapticsProvider>
<DisableHapticsProvider>
<AutoplayProvider>{children}</AutoplayProvider>
</DisableHapticsProvider>
</InAppBrowserProvider>
</HiddenPostsProvider>
</ExternalEmbedsProvider>
+173
View File
@@ -0,0 +1,173 @@
import {Platform} from 'react-native'
import {getLocales} from 'expo-localization'
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
import {GIF_FEATURED, GIF_SEARCH} from '#/lib/constants'
export const RQKEY_ROOT = 'gif-service'
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
const getTrendingGifs = createTenorApi(GIF_FEATURED)
const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH)
export function useFeaturedGifsQuery() {
return useInfiniteQuery({
queryKey: RQKEY_FEATURED,
queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}),
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.next,
})
}
export function useGifSearchQuery(query: string) {
return useInfiniteQuery({
queryKey: RQKEY_SEARCH(query),
queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}),
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.next,
enabled: !!query,
placeholderData: keepPreviousData,
})
}
function createTenorApi<Input extends object>(
urlFn: (params: string) => string,
): (input: Input & {pos?: string}) => Promise<{
next: string
results: Gif[]
}> {
return async input => {
const params = new URLSearchParams()
// set client key based on platform
params.set(
'client_key',
Platform.select({
ios: 'bluesky-ios',
android: 'bluesky-android',
default: 'bluesky-web',
}),
)
// 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used
params.set('limit', '30')
params.set('contentfilter', 'high')
params.set(
'media_filter',
(['preview', 'gif', 'tinygif'] satisfies ContentFormats[]).join(','),
)
const locale = getLocales?.()?.[0]
if (locale) {
params.set('locale', locale.languageTag.replace('-', '_'))
}
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) {
params.set(key, String(value))
}
}
const res = await fetch(urlFn(params.toString()), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!res.ok) {
throw new Error('Failed to fetch Tenor API')
}
return res.json()
}
}
export type Gif = {
/**
* A Unix timestamp that represents when this post was created.
*/
created: number
/**
* Returns true if this post contains audio.
* Note: Only video formats support audio. The GIF image file format can't contain audio information.
*/
hasaudio: boolean
/**
* Tenor result identifier
*/
id: string
/**
* A dictionary with a content format as the key and a Media Object as the value.
*/
media_formats: Record<ContentFormats, MediaObject>
/**
* An array of tags for the post
*/
tags: string[]
/**
* The title of the post
*/
title: string
/**
* A textual description of the content.
* We recommend that you use content_description for user accessibility features.
*/
content_description: string
/**
* The full URL to view the post on tenor.com.
*/
itemurl: string
/**
* Returns true if this post contains captions.
*/
hascaption: boolean
/**
* Comma-separated list to signify whether the content is a sticker or static image, has audio, or is any combination of these. If sticker and static aren't present, then the content is a GIF. A blank flags field signifies a GIF without audio.
*/
flags: string
/**
* The most common background pixel color of the content
*/
bg_color?: string
/**
* A short URL to view the post on tenor.com.
*/
url: string
}
type MediaObject = {
/**
* A URL to the media source
*/
url: string
/**
* Width and height of the media in pixels
*/
dims: [number, number]
/**
* Represents the time in seconds for one loop of the content. If the content is static, the duration is set to 0.
*/
duration: number
/**
* Size of the file in bytes
*/
size: number
}
type ContentFormats =
| 'preview'
| 'gif'
// | 'mediumgif'
| 'tinygif'
// | 'nanogif'
// | 'mp4'
// | 'loopedmp4'
// | 'tinymp4'
// | 'nanomp4'
// | 'webm'
// | 'tinywebm'
// | 'nanowebm'
+13 -3
View File
@@ -59,6 +59,7 @@ export type ApiContext = {
service: string
identifier: string
password: string
authFactorToken?: string | undefined
},
logContext: LogEvents['account:loggedIn']['logContext'],
) => Promise<void>
@@ -87,7 +88,10 @@ export type ApiContext = {
) => Promise<void>
updateCurrentAccount: (
account: Partial<
Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
Pick<
SessionAccount,
'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor'
>
>,
) => void
}
@@ -298,12 +302,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
const login = React.useCallback<ApiContext['login']>(
async ({service, identifier, password}, logContext) => {
async ({service, identifier, password, authFactorToken}, logContext) => {
logger.debug(`session: login`, {}, logger.DebugContext.session)
const agent = new BskyAgent({service})
await agent.login({identifier, password})
await agent.login({identifier, password, authFactorToken})
if (!agent.session) {
throw new Error(`session: login failed to establish a session`)
@@ -319,6 +323,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
handle: agent.session.handle,
email: agent.session.email,
emailConfirmed: agent.session.emailConfirmed || false,
emailAuthFactor: agent.session.emailAuthFactor,
refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt,
deactivated: isSessionDeactivated(agent.session.accessJwt),
@@ -489,6 +494,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
handle: agent.session.handle,
email: agent.session.email,
emailConfirmed: agent.session.emailConfirmed || false,
emailAuthFactor: agent.session.emailAuthFactor || false,
refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt,
deactivated: isSessionDeactivated(agent.session.accessJwt),
@@ -546,6 +552,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
account.emailConfirmed !== undefined
? account.emailConfirmed
: currentAccount.emailConfirmed,
emailAuthFactor:
account.emailAuthFactor !== undefined
? account.emailAuthFactor
: currentAccount.emailAuthFactor,
}
return {
+65 -26
View File
@@ -5,7 +5,6 @@ import {
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
TouchableOpacity,
@@ -19,6 +18,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {observer} from 'mobx-react-lite'
import {LikelyType} from '#/lib/link-meta/link-meta'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {emitPostCreated} from '#/state/events'
@@ -30,6 +30,7 @@ import {
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {getAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
@@ -48,8 +49,11 @@ import {useDialogStateControlContext} from 'state/dialogs'
import {GalleryModel} from 'state/models/media/gallery'
import {ComposerOpts} from 'state/shell/composer'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import * as Prompt from '#/components/Prompt'
import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed'
import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
@@ -58,6 +62,7 @@ import {ExternalEmbed} from './ExternalEmbed'
import {LabelsBtn} from './labels/LabelsBtn'
import {Gallery} from './photos/Gallery'
import {OpenCameraBtn} from './photos/OpenCameraBtn'
import {SelectGifBtn} from './photos/SelectGifBtn'
import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
import {SelectLangBtn} from './select-language/SelectLangBtn'
import {SuggestedLanguage} from './select-language/SuggestedLanguage'
@@ -116,6 +121,7 @@ export const ComposePost = observer(function ComposePost({
initQuote,
)
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
const gallery = useMemo(
@@ -299,13 +305,35 @@ export const ComposePost = observer(function ComposePost({
? _(msg`Write your reply`)
: _(msg`What's up?`)
const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
const canSelectImages = gallery.size < 4 && !extLink
const hasMedia = gallery.size > 0 || Boolean(extLink)
const onEmojiButtonPress = useCallback(() => {
openPicker?.(textInput.current?.getCursorPosition())
}, [openPicker])
const focusTextInput = useCallback(() => {
textInput.current?.focus()
}, [])
const onSelectGif = useCallback(
(gif: Gif) => {
setExtLink({
uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`,
isLoading: true,
meta: {
url: gif.media_formats.gif.url,
image: gif.media_formats.preview.url,
likelyType: LikelyType.HTML,
title: gif.content_description,
description: `ALT: ${gif.content_description}`,
},
})
setExtGif(gif)
},
[setExtLink],
)
return (
<KeyboardAvoidingView
testID="composePostView"
@@ -447,36 +475,47 @@ export const ComposePost = observer(function ComposePost({
{gallery.isEmpty && extLink && (
<ExternalEmbed
link={extLink}
onRemove={() => setExtLink(undefined)}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
)}
{quote ? (
<View style={[s.mt5, isWeb && s.mb10, {pointerEvents: 'none'}]}>
<QuoteEmbed quote={quote} />
<View style={[s.mt5, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : undefined}
</ScrollView>
<SuggestedLanguage text={richtext.text} />
<View style={[pal.border, styles.bottomBar]}>
{canSelectImages ? (
<>
<SelectPhotoBtn gallery={gallery} />
<OpenCameraBtn gallery={gallery} />
</>
) : null}
{!isMobile ? (
<Pressable
onPress={onEmojiButtonPress}
accessibilityRole="button"
accessibilityLabel={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}>
<FontAwesomeIcon
icon={['far', 'face-smile']}
color={pal.colors.link}
size={22}
/>
</Pressable>
) : null}
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
disabled={hasMedia}
/>
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}
variant="ghost"
shape="round"
color="primary">
<EmojiSmile size="lg" />
</Button>
) : null}
</View>
<View style={s.flex1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
@@ -571,7 +610,7 @@ const styles = StyleSheet.create({
},
bottomBar: {
flexDirection: 'row',
paddingVertical: 10,
paddingVertical: 4,
paddingLeft: 15,
paddingRight: 20,
alignItems: 'center',
+46 -24
View File
@@ -1,11 +1,12 @@
import React from 'react'
import {TouchableOpacity, View} from 'react-native'
import {StyleProp, TouchableOpacity, View, ViewStyle} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ExternalEmbedDraft} from 'lib/api/index'
import {s} from 'lib/styles'
import {Gif} from 'state/queries/tenor'
import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
@@ -14,9 +15,11 @@ import {Text} from '#/components/Typography'
export const ExternalEmbed = ({
link,
onRemove,
gif,
}: {
link?: ExternalEmbedDraft
onRemove: () => void
gif?: Gif
}) => {
const t = useTheme()
const {_} = useLingui()
@@ -34,45 +37,38 @@ export const ExternalEmbed = ({
if (!link) return null
const loadingStyle: ViewStyle | undefined = gif
? {
aspectRatio:
gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1],
width: '100%',
}
: undefined
return (
<View
style={[
a.border,
a.rounded_sm,
a.mt_2xl,
a.mb_xl,
a.overflow_hidden,
t.atoms.border_contrast_medium,
]}>
<View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
{link.isLoading ? (
<View
style={[
a.align_center,
a.justify_center,
a.py_5xl,
t.atoms.bg_contrast_25,
]}>
<Container style={loadingStyle}>
<Loader size="xl" />
</View>
</Container>
) : link.meta?.error ? (
<View
style={[a.justify_center, a.p_md, a.gap_xs, t.atoms.bg_contrast_25]}>
<Container style={[a.align_start, a.p_md, a.gap_xs]}>
<Text numberOfLines={1} style={t.atoms.text_contrast_high}>
{link.uri}
</Text>
<Text numberOfLines={2} style={[{color: t.palette.negative_400}]}>
{link.meta.error}
{link.meta?.error}
</Text>
</View>
</Container>
) : linkInfo ? (
<View style={{pointerEvents: 'none'}}>
<View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
<ExternalLinkEmbed link={linkInfo} />
</View>
) : null}
<TouchableOpacity
style={{
position: 'absolute',
top: 10,
top: 16,
right: 10,
height: 36,
width: 36,
@@ -91,3 +87,29 @@ export const ExternalEmbed = ({
</View>
)
}
function Container({
style,
children,
}: {
style?: StyleProp<ViewStyle>
children: React.ReactNode
}) {
const t = useTheme()
return (
<View
style={[
a.mt_sm,
a.rounded_sm,
a.border,
a.align_center,
a.justify_center,
a.py_5xl,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_medium,
style,
]}>
{children}
</View>
)
}
+25 -34
View File
@@ -1,32 +1,31 @@
import React, {useCallback} from 'react'
import {TouchableOpacity, StyleSheet} from 'react-native'
import * as MediaLibrary from 'expo-media-library'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {openCamera} from 'lib/media/picker'
import {useCameraPermission} from 'lib/hooks/usePermissions'
import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants'
import {GalleryModel} from 'state/models/media/gallery'
import {isMobileWeb, isNative} from 'platform/detection'
import {logger} from '#/logger'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {POST_IMG_MAX} from '#/lib/constants'
import {useCameraPermission} from '#/lib/hooks/usePermissions'
import {openCamera} from '#/lib/media/picker'
import {logger} from '#/logger'
import {isMobileWeb, isNative} from '#/platform/detection'
import {GalleryModel} from '#/state/models/media/gallery'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
type Props = {
gallery: GalleryModel
disabled?: boolean
}
export function OpenCameraBtn({gallery}: Props) {
const pal = usePalette('default')
export function OpenCameraBtn({gallery, disabled}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission()
const [mediaPermissionRes, requestMediaPermission] =
MediaLibrary.usePermissions()
const t = useTheme()
const onPressTakePicture = useCallback(async () => {
track('Composer:CameraOpened')
@@ -68,25 +67,17 @@ export function OpenCameraBtn({gallery}: Props) {
}
return (
<TouchableOpacity
<Button
testID="openCameraButton"
onPress={onPressTakePicture}
style={styles.button}
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel={_(msg`Camera`)}
accessibilityHint={_(msg`Opens camera on device`)}>
<FontAwesomeIcon
icon="camera"
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>
label={_(msg`Camera`)}
accessibilityHint={_(msg`Opens camera on device`)}
style={a.p_sm}
variant="ghost"
shape="round"
color="primary"
disabled={disabled}>
<Camera size="lg" style={disabled && t.atoms.text_contrast_low} />
</Button>
)
}
const styles = StyleSheet.create({
button: {
paddingHorizontal: 15,
},
})
@@ -0,0 +1,53 @@
import React, {useCallback} from 'react'
import {Keyboard} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {Gif} from '#/state/queries/tenor'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {GifSelectDialog} from '#/components/dialogs/GifSelect'
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
type Props = {
onClose: () => void
onSelectGif: (gif: Gif) => void
disabled?: boolean
}
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
const {_} = useLingui()
const control = useDialogControl()
const t = useTheme()
const onPressSelectGif = useCallback(async () => {
logEvent('composer:gif:open', {})
Keyboard.dismiss()
control.open()
}, [control])
return (
<>
<Button
testID="openGifBtn"
onPress={onPressSelectGif}
label={_(msg`Select GIF`)}
accessibilityHint={_(msg`Opens GIF select dialog`)}
style={a.p_sm}
variant="ghost"
shape="round"
color="primary"
disabled={disabled}>
<GifIcon size="lg" style={disabled && t.atoms.text_contrast_low} />
</Button>
<GifSelectDialog
control={control}
onClose={onClose}
onSelectGif={onSelectGif}
/>
</>
)
}
+23 -32
View File
@@ -1,27 +1,26 @@
/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
import React, {useCallback} from 'react'
import {TouchableOpacity, StyleSheet} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
import {GalleryModel} from 'state/models/media/gallery'
import {HITSLOP_10} from 'lib/constants'
import {isNative} from 'platform/detection'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
import {isNative} from '#/platform/detection'
import {GalleryModel} from '#/state/models/media/gallery'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
type Props = {
gallery: GalleryModel
disabled?: boolean
}
export function SelectPhotoBtn({gallery}: Props) {
const pal = usePalette('default')
export function SelectPhotoBtn({gallery, disabled}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const t = useTheme()
const onPressSelectPhotos = useCallback(async () => {
track('Composer:GalleryOpened')
@@ -34,25 +33,17 @@ export function SelectPhotoBtn({gallery}: Props) {
}, [track, requestPhotoAccessIfNeeded, gallery])
return (
<TouchableOpacity
<Button
testID="openGalleryBtn"
onPress={onPressSelectPhotos}
style={styles.button}
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel={_(msg`Gallery`)}
accessibilityHint={_(msg`Opens device photo gallery`)}>
<FontAwesomeIcon
icon={['far', 'image']}
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>
label={_(msg`Gallery`)}
accessibilityHint={_(msg`Opens device photo gallery`)}
style={a.p_sm}
variant="ghost"
shape="round"
color="primary"
disabled={disabled}>
<Image size="lg" style={disabled && t.atoms.text_contrast_low} />
</Button>
)
}
const styles = StyleSheet.create({
button: {
paddingHorizontal: 15,
},
})
+8 -14
View File
@@ -107,17 +107,11 @@ export function FeedPage({
})
}, [scrollToTop, feed, queryClient, setHasNew])
let feedPollInterval
if (
feed === // Discover
'feedgen|at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot' &&
// TODO: This gate check is still too early. Move it to where the polling happens.
gate('disable_poll_on_discover')
) {
feedPollInterval = undefined
} else {
feedPollInterval = POLL_FREQ
}
const isDiscoverFeed =
feed ===
'feedgen|at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
const adjustedHasNew =
hasNew && !(isDiscoverFeed && gate('disable_poll_on_discover_v2'))
return (
<View testID={testID} style={s.h100pct}>
@@ -127,7 +121,7 @@ export function FeedPage({
enabled={isPageFocused}
feed={feed}
feedParams={feedParams}
pollInterval={feedPollInterval}
pollInterval={POLL_FREQ}
disablePoll={hasNew}
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
@@ -138,11 +132,11 @@ export function FeedPage({
savedFeedConfig={savedFeedConfig}
/>
</MainScrollProvider>
{(isScrolledDown || hasNew) && (
{(isScrolledDown || adjustedHasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label={_(msg`Load new posts`)}
showIndicator={hasNew}
showIndicator={adjustedHasNew}
/>
)}
+22 -14
View File
@@ -6,23 +6,24 @@ import {
StyleSheet,
View,
} from 'react-native'
import {Svg, Circle, Path} from 'react-native-svg'
import {ScrollView, TextInput} from './util'
import {Circle, Path, Svg} from 'react-native-svg'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {ErrorMessage} from '../util/error/ErrorMessage'
import * as Toast from '../util/Toast'
import {s, colors} from 'lib/styles'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {getAgent, useSession, useSessionApi} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useSession, useSessionApi, getAgent} from '#/state/session'
import {logger} from '#/logger'
import {colors, s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {ScrollView, TextInput} from './util'
export const snapPoints = ['90%']
@@ -32,7 +33,13 @@ enum Stages {
ConfirmCode,
}
export function Component({showReminder}: {showReminder?: boolean}) {
export function Component({
showReminder,
onSuccess,
}: {
showReminder?: boolean
onSuccess?: () => void
}) {
const pal = usePalette('default')
const {currentAccount} = useSession()
const {updateCurrentAccount} = useSessionApi()
@@ -77,6 +84,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
updateCurrentAccount({emailConfirmed: true})
Toast.show(_(msg`Email verified`))
closeModal()
onSuccess?.()
} catch (e) {
setError(cleanError(String(e)))
} finally {
@@ -140,7 +140,7 @@ function PostThreadFollowBtnLoaded({
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
numberOfLines={1}>
{!isFollowing ? (
isFollowedBy && gate('show_follow_back_label') ? (
isFollowedBy && gate('show_follow_back_label_v2') ? (
<Trans>Follow Back</Trans>
) : (
<Trans>Follow</Trans>
+7 -2
View File
@@ -27,6 +27,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {countLines, pluralize} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf'
@@ -478,7 +479,12 @@ let PostThreadItemLoaded = ({
avatarSize={28}
displayNameType="md-bold"
displayNameStyle={isThreadedChild && s.ml2}
style={isThreadedChild && s.mb2}
style={
isThreadedChild && {
alignItems: 'center',
paddingBottom: isWeb ? 5 : 2,
}
}
/>
<LabelsOnMyPost post={post} />
<PostAlerts
@@ -669,7 +675,6 @@ const styles = StyleSheet.create({
},
layoutContentThreaded: {
flex: 1,
paddingRight: 10,
},
meta: {
flexDirection: 'row',
+9 -3
View File
@@ -1,12 +1,14 @@
import React, {Component, ErrorInfo, ReactNode} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {ErrorScreen} from './error/ErrorScreen'
import {CenteredView} from './Views'
import {msg} from '@lingui/macro'
import {logger} from '#/logger'
import {useLingui} from '@lingui/react'
interface Props {
children?: ReactNode
renderError?: (error: any) => ReactNode
}
interface State {
@@ -30,6 +32,10 @@ export class ErrorBoundary extends Component<Props, State> {
public render() {
if (this.state.hasError) {
if (this.props.renderError) {
return this.props.renderError(this.state.error)
}
return (
<CenteredView style={{height: '100%', flex: 1}}>
<TranslatedErrorScreen details={this.state.error.toString()} />
+4 -2
View File
@@ -54,10 +54,12 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
/>
</View>
)}
<Text numberOfLines={1} style={[styles.maxWidth, pal.textLight]}>
<Text
numberOfLines={1}
style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}>
<TextLinkOnWebOnly
type={opts.displayNameType || 'lg-bold'}
style={[pal.text, opts.displayNameStyle]}
style={[pal.text]}
lineHeight={1.2}
disableMismatchWarning
text={
+40 -15
View File
@@ -1,19 +1,20 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {CenteredView} from './Views'
import {Text} from './text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useAnalytics} from 'lib/analytics/analytics'
import {NavigationProp} from 'lib/routes/types'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import Animated from 'react-native-reanimated'
import {useSetDrawerOpen} from '#/state/shell'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useSetDrawerOpen} from '#/state/shell'
import {useAnalytics} from 'lib/analytics/analytics'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {useTheme} from '#/alf'
import {Text} from './text/Text'
import {CenteredView} from './Views'
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
@@ -62,6 +63,7 @@ export function ViewHeader({
return (
<DesktopWebHeader
title={title}
subtitle={subtitle}
renderButton={renderButton}
showBorder={showBorder}
/>
@@ -136,14 +138,17 @@ export function ViewHeader({
function DesktopWebHeader({
title,
subtitle,
renderButton,
showBorder = true,
}: {
title: string
subtitle?: string
renderButton?: () => JSX.Element
showBorder?: boolean
}) {
const pal = usePalette('default')
const t = useTheme()
return (
<CenteredView
style={[
@@ -153,13 +158,30 @@ function DesktopWebHeader({
{
borderBottomWidth: showBorder ? 1 : 0,
},
{display: 'flex', flexDirection: 'column'},
]}>
<View style={styles.titleContainer} pointerEvents="none">
<Text type="title-lg" style={[pal.text, styles.title]}>
{title}
</Text>
<View>
<View style={styles.titleContainer} pointerEvents="none">
<Text type="title-lg" style={[pal.text, styles.title]}>
{title}
</Text>
</View>
{renderButton?.()}
</View>
{renderButton?.()}
{subtitle ? (
<View>
<View style={[styles.titleContainer]} pointerEvents="none">
<Text
style={[
pal.text,
styles.subtitleDesktop,
t.atoms.text_contrast_medium,
]}>
{subtitle}
</Text>
</View>
</View>
) : null}
</CenteredView>
)
}
@@ -236,6 +258,9 @@ const styles = StyleSheet.create({
subtitle: {
fontSize: 13,
},
subtitleDesktop: {
fontSize: 15,
},
backBtn: {
width: 30,
height: 30,
@@ -1,20 +1,28 @@
import React from 'react'
import React, {useCallback} from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {Text} from '../text/Text'
import {StyleSheet, View} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {AppBskyEmbedExternal} from '@atproto/api'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {shareUrl} from 'lib/sharing'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {isNative} from 'platform/detection'
import {useExternalEmbedsPrefs} from 'state/preferences'
import {Link} from 'view/com/util/Link'
import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
import {GifEmbed} from 'view/com/util/post-embeds/GifEmbed'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '../text/Text'
export const ExternalLinkEmbed = ({
link,
style,
}: {
link: AppBskyEmbedExternal.ViewExternal
style?: StyleProp<ViewStyle>
}) => {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
@@ -28,62 +36,95 @@ export const ExternalLinkEmbed = ({
}
}, [link.uri, externalEmbedPrefs])
if (embedPlayerParams?.source === 'tenor') {
return <GifEmbed params={embedPlayerParams} link={link} />
}
return (
<View style={styles.container}>
{link.thumb && !embedPlayerParams ? (
<Image
style={{aspectRatio: 1.91}}
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
/>
) : undefined}
{(embedPlayerParams?.isGif && (
<ExternalGifEmbed link={link} params={embedPlayerParams} />
)) ||
(embedPlayerParams && (
<ExternalPlayer link={link} params={embedPlayerParams} />
))}
<View style={[styles.info, {paddingHorizontal: isMobile ? 10 : 14}]}>
<Text
type="sm"
numberOfLines={1}
style={[pal.textLight, styles.extUri]}>
{toNiceDomain(link.uri)}
</Text>
{!embedPlayerParams?.isGif && (
<Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
{link.title || link.uri}
</Text>
)}
{link.description && !embedPlayerParams?.hideDetails ? (
<Text
type="md"
numberOfLines={link.thumb ? 2 : 4}
style={[pal.text, styles.extDescription]}>
{link.description}
</Text>
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
<LinkWrapper link={link} style={style}>
{link.thumb && !embedPlayerParams ? (
<Image
style={{
aspectRatio: 1.91,
borderTopRightRadius: 6,
borderTopLeftRadius: 6,
}}
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
/>
) : undefined}
</View>
{embedPlayerParams?.isGif ? (
<ExternalGifEmbed link={link} params={embedPlayerParams} />
) : embedPlayerParams ? (
<ExternalPlayer link={link} params={embedPlayerParams} />
) : undefined}
<View
style={[
a.flex_1,
a.py_sm,
{
paddingHorizontal: isMobile ? 10 : 14,
},
]}>
<Text
type="sm"
numberOfLines={1}
style={[pal.textLight, {marginVertical: 2}]}>
{toNiceDomain(link.uri)}
</Text>
{!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
<Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
{link.title || link.uri}
</Text>
)}
{link.description ? (
<Text
type="md"
numberOfLines={link.thumb ? 2 : 4}
style={[pal.text, a.mt_xs]}>
{link.description}
</Text>
) : undefined}
</View>
</LinkWrapper>
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'column',
borderRadius: 6,
overflow: 'hidden',
},
info: {
width: '100%',
bottom: 0,
paddingTop: 8,
paddingBottom: 10,
},
extUri: {
marginTop: 2,
},
extDescription: {
marginTop: 4,
},
})
function LinkWrapper({
link,
style,
children,
}: {
link: AppBskyEmbedExternal.ViewExternal
style?: StyleProp<ViewStyle>
children: React.ReactNode
}) {
const t = useTheme()
const onShareExternal = useCallback(() => {
if (link.uri && isNative) {
shareUrl(link.uri)
}
}, [link.uri])
return (
<Link
asAnchor
anchorNoUnderline
href={link.uri}
style={[
a.flex_1,
a.border,
a.rounded_sm,
t.atoms.border_contrast_medium,
style,
]}
hoverStyle={t.atoms.border_contrast_high}
onLongPress={onShareExternal}>
{children}
</Link>
)
}
+141
View File
@@ -0,0 +1,141 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {EmbedPlayerParams} from 'lib/strings/embed-player'
import {useAutoplayDisabled} from 'state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
function PlaybackControls({
onPress,
isPlaying,
isLoaded,
}: {
onPress: () => void
isPlaying: boolean
isLoaded: boolean
}) {
const {_} = useLingui()
const t = useTheme()
return (
<Pressable
accessibilityRole="button"
accessibilityHint={_(msg`Play or pause the GIF`)}
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
style={[
a.absolute,
a.align_center,
a.justify_center,
!isLoaded && a.border,
t.atoms.border_contrast_medium,
a.inset_0,
a.w_full,
a.h_full,
a.rounded_sm,
{
zIndex: 2,
backgroundColor: !isLoaded
? t.atoms.bg_contrast_25.backgroundColor
: !isPlaying
? 'rgba(0, 0, 0, 0.3)'
: undefined,
},
]}
onPress={onPress}>
{!isLoaded ? (
<View>
<View style={[a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
</View>
) : !isPlaying ? (
<View
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{
backgroundColor: t.palette.primary_500,
width: 60,
height: 60,
},
]}>
<FontAwesomeIcon
icon="play"
size={42}
color="white"
style={{marginLeft: 8}}
/>
</View>
) : undefined}
</Pressable>
)
}
export function GifEmbed({
params,
link,
}: {
params: EmbedPlayerParams
link: AppBskyEmbedExternal.ViewExternal
}) {
const {_} = useLingui()
const autoplayDisabled = useAutoplayDisabled()
const playerRef = React.useRef<GifView>(null)
const [playerState, setPlayerState] = React.useState<{
isPlaying: boolean
isLoaded: boolean
}>({
isPlaying: !autoplayDisabled,
isLoaded: false,
})
const onPlayerStateChange = React.useCallback(
(e: GifViewStateChangeEvent) => {
setPlayerState(e.nativeEvent)
},
[],
)
const onPress = React.useCallback(() => {
playerRef.current?.toggleAsync()
}, [])
return (
<View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
<View
style={[
a.rounded_sm,
a.overflow_hidden,
{
aspectRatio: params.dimensions!.width / params.dimensions!.height,
},
]}>
<PlaybackControls
onPress={onPress}
isPlaying={playerState.isPlaying}
isLoaded={playerState.isLoaded}
/>
<GifView
source={params.playerUri}
placeholderSource={link.thumb}
style={[a.flex_1, a.rounded_sm]}
autoplay={!autoplayDisabled}
onPlayerStateChange={onPlayerStateChange}
ref={playerRef}
accessibilityHint={_(msg`Animated GIF`)}
accessibilityLabel={link.description.replace('ALT: ', '')}
/>
</View>
</View>
)
}
+39 -2
View File
@@ -1,5 +1,11 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {
AppBskyEmbedExternal,
AppBskyEmbedImages,
@@ -12,9 +18,13 @@ import {
RichText as RichTextAPI,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {HITSLOP_20} from '#/lib/constants'
import {s} from '#/lib/styles'
import {useModerationOpts} from '#/state/queries/preferences'
import {RQKEY as RQKEY_URI} from '#/state/queries/resolve-uri'
import {usePalette} from 'lib/hooks/usePalette'
@@ -177,6 +187,33 @@ export function QuoteEmbed({
)
}
export function QuoteX({onRemove}: {onRemove: () => void}) {
const {_} = useLingui()
return (
<TouchableOpacity
style={[
a.absolute,
a.p_xs,
a.rounded_full,
a.align_center,
a.justify_center,
{
top: 16,
right: 10,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
},
]}
onPress={onRemove}
accessibilityRole="button"
accessibilityLabel={_(msg`Remove quote`)}
accessibilityHint={_(msg`Removes quoted post`)}
onAccessibilityEscape={onRemove}
hitSlop={HITSLOP_20}>
<FontAwesomeIcon size={12} icon="xmark" style={s.white} />
</TouchableOpacity>
)
}
function viewRecordToPostView(
viewRecord: AppBskyEmbedRecord.ViewRecord,
): AppBskyFeedDefs.PostView {
+13 -39
View File
@@ -1,34 +1,32 @@
import React, {useCallback} from 'react'
import React from 'react'
import {
StyleSheet,
InteractionManager,
StyleProp,
StyleSheet,
Text,
View,
ViewStyle,
Text,
InteractionManager,
} from 'react-native'
import {Image} from 'expo-image'
import {
AppBskyEmbedImages,
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyGraphDefs,
ModerationDecision,
} from '@atproto/api'
import {Link} from '../Link'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
import {usePalette} from 'lib/hooks/usePalette'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ListEmbed} from './ListEmbed'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {ContentHider} from '../../../../components/moderation/ContentHider'
import {isNative} from '#/platform/detection'
import {shareUrl} from '#/lib/sharing'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {ListEmbed} from './ListEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
type Embed =
| AppBskyEmbedRecord.View
@@ -49,16 +47,6 @@ export function PostEmbeds({
const pal = usePalette('default')
const {openLightbox} = useLightboxControls()
const externalUri = AppBskyEmbedExternal.isView(embed)
? embed.external.uri
: null
const onShareExternal = useCallback(() => {
if (externalUri && isNative) {
shareUrl(externalUri)
}
}, [externalUri])
// quote post with media
// =
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
@@ -161,18 +149,9 @@ export function PostEmbeds({
// =
if (AppBskyEmbedExternal.isView(embed)) {
const link = embed.external
return (
<ContentHider modui={moderation?.ui('contentMedia')}>
<Link
asAnchor
anchorNoUnderline
href={link.uri}
style={[styles.extOuter, pal.view, pal.borderDark, style]}
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
onLongPress={onShareExternal}>
<ExternalLinkEmbed link={link} />
</Link>
<ExternalLinkEmbed link={link} style={style} />
</ContentHider>
)
}
@@ -187,11 +166,6 @@ const styles = StyleSheet.create({
singleImage: {
borderRadius: 8,
},
extOuter: {
borderWidth: 1,
borderRadius: 8,
marginTop: 4,
},
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
+38 -37
View File
@@ -1,63 +1,72 @@
import {library} from '@fortawesome/fontawesome-svg-core'
import {faAddressCard} from '@fortawesome/free-regular-svg-icons'
import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell'
import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faBookmark'
import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar'
import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle'
import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
import {faCirclePlay} from '@fortawesome/free-regular-svg-icons/faCirclePlay'
import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser'
import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
import {faComments} from '@fortawesome/free-regular-svg-icons/faComments'
import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile'
import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk'
import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand'
import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart'
import {faImage as farImage} from '@fortawesome/free-regular-svg-icons/faImage'
import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage'
import {faPaste} from '@fortawesome/free-regular-svg-icons/faPaste'
import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare'
import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck'
import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
import {faUser} from '@fortawesome/free-regular-svg-icons/faUser'
import {faFlask} from '@fortawesome/free-solid-svg-icons'
import {faUniversalAccess} from '@fortawesome/free-solid-svg-icons'
import {faAngleDown} from '@fortawesome/free-solid-svg-icons/faAngleDown'
import {faAngleLeft} from '@fortawesome/free-solid-svg-icons/faAngleLeft'
import {faAngleRight} from '@fortawesome/free-solid-svg-icons/faAngleRight'
import {faAngleUp} from '@fortawesome/free-solid-svg-icons/faAngleUp'
import {faArrowDown} from '@fortawesome/free-solid-svg-icons/faArrowDown'
import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft'
import {faArrowRight} from '@fortawesome/free-solid-svg-icons/faArrowRight'
import {faArrowUp} from '@fortawesome/free-solid-svg-icons/faArrowUp'
import {faArrowDown} from '@fortawesome/free-solid-svg-icons/faArrowDown'
import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowRightFromBracket'
import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotateLeft'
import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
import {faArrowTrendUp} from '@fortawesome/free-solid-svg-icons/faArrowTrendUp'
import {faArrowUp} from '@fortawesome/free-solid-svg-icons/faArrowUp'
import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotateLeft'
import {faArrowTrendUp} from '@fortawesome/free-solid-svg-icons/faArrowTrendUp'
import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
import {faAt} from '@fortawesome/free-solid-svg-icons/faAt'
import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
import {faBan} from '@fortawesome/free-solid-svg-icons/faBan'
import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
import {faBell} from '@fortawesome/free-solid-svg-icons/faBell'
import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell'
import {faBookmark} from '@fortawesome/free-solid-svg-icons/faBookmark'
import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faBookmark'
import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar'
import {faCamera} from '@fortawesome/free-solid-svg-icons/faCamera'
import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck'
import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'
import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle'
import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck'
import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot'
import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation'
import {faCirclePlay} from '@fortawesome/free-regular-svg-icons/faCirclePlay'
import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser'
import {faClone} from '@fortawesome/free-solid-svg-icons/faClone'
import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash'
import {faComments} from '@fortawesome/free-regular-svg-icons/faComments'
import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
import {faDownload} from '@fortawesome/free-solid-svg-icons/faDownload'
import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope'
import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation'
import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile'
import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter'
import {faFire} from '@fortawesome/free-solid-svg-icons/faFire'
import {faFlask} from '@fortawesome/free-solid-svg-icons'
import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk'
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
import {faHand} from '@fortawesome/free-solid-svg-icons/faHand'
import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand'
import {faHashtag} from '@fortawesome/free-solid-svg-icons/faHashtag'
import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart'
import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart'
import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse'
import {faImage as farImage} from '@fortawesome/free-regular-svg-icons/faImage'
import {faImage} from '@fortawesome/free-solid-svg-icons/faImage'
import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo'
import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage'
@@ -66,10 +75,8 @@ import {faList} from '@fortawesome/free-solid-svg-icons/faList'
import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl'
import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage'
import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky'
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
import {faPaste} from '@fortawesome/free-regular-svg-icons/faPaste'
import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare'
@@ -87,23 +94,16 @@ import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSq
import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal'
import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders'
import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare'
import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck'
import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
import {faUser} from '@fortawesome/free-regular-svg-icons/faUser'
import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers'
import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck'
import {faUserSlash} from '@fortawesome/free-solid-svg-icons/faUserSlash'
import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus'
import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers'
import {faUserSlash} from '@fortawesome/free-solid-svg-icons/faUserSlash'
import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'
import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter'
library.add(
faAddressCard,
@@ -196,6 +196,7 @@ library.add(
faSquare,
faSquareCheck,
faSquarePlus,
faUniversalAccess,
faUser,
faUsers,
faUserCheck,
+132
View File
@@ -0,0 +1,132 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {isNative} from '#/platform/detection'
import {useSetMinimalShellMode} from '#/state/shell'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {s} from 'lib/styles'
import {
useAutoplayDisabled,
useHapticsDisabled,
useRequireAltTextEnabled,
useSetAutoplayDisabled,
useSetHapticsDisabled,
useSetRequireAltTextEnabled,
} from 'state/preferences'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
import {Text} from '../com/util/text/Text'
import {ScrollView} from '../com/util/Views'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'AccessibilitySettings'
>
export function AccessibilitySettingsScreen({}: Props) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {screen} = useAnalytics()
const {isMobile} = useWebMediaQueries()
const {_} = useLingui()
const requireAltTextEnabled = useRequireAltTextEnabled()
const setRequireAltTextEnabled = useSetRequireAltTextEnabled()
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
const hapticsDisabled = useHapticsDisabled()
const setHapticsDisabled = useSetHapticsDisabled()
useFocusEffect(
React.useCallback(() => {
screen('PreferencesExternalEmbeds')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
return (
<View style={s.hContentRegion} testID="accessibilitySettingsScreen">
<SimpleViewHeader
showBackButton={isMobile}
style={[
pal.border,
{borderBottomWidth: 1},
!isMobile && {borderLeftWidth: 1, borderRightWidth: 1},
]}>
<View style={{flex: 1}}>
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
<Trans>Accessibility Settings</Trans>
</Text>
</View>
</SimpleViewHeader>
<ScrollView
// @ts-ignore web only -prf
dataSet={{'stable-gutters': 1}}
style={s.flex1}
contentContainerStyle={[
s.flex1,
{paddingBottom: 200},
isMobile && pal.viewLight,
]}>
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Alt text</Trans>
</Text>
<View style={[pal.view, styles.toggleCard]}>
<ToggleButton
type="default-light"
label={_(msg`Require alt text before posting`)}
labelType="lg"
isSelected={requireAltTextEnabled}
onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)}
/>
</View>
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Media</Trans>
</Text>
<View style={[pal.view, styles.toggleCard]}>
<ToggleButton
type="default-light"
label={_(msg`Disable autoplay for GIFs`)}
labelType="lg"
isSelected={autoplayDisabled}
onPress={() => setAutoplayDisabled(!autoplayDisabled)}
/>
</View>
{isNative && (
<>
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Haptics</Trans>
</Text>
<View style={[pal.view, styles.toggleCard]}>
<ToggleButton
type="default-light"
label={_(msg`Disable haptic feedback`)}
labelType="lg"
isSelected={hapticsDisabled}
onPress={() => setHapticsDisabled(!hapticsDisabled)}
/>
</View>
</>
)}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
heading: {
paddingHorizontal: 18,
paddingTop: 14,
paddingBottom: 6,
},
toggleCard: {
paddingVertical: 8,
paddingHorizontal: 6,
marginBottom: 1,
},
})
+15 -6
View File
@@ -5,6 +5,7 @@ import {useFocusEffect} from '@react-navigation/native'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {logEvent, LogEvents, useGate} from '#/lib/statsig/statsig'
import {emitSoftReset} from '#/state/events'
import {FeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
@@ -12,7 +13,11 @@ import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useSession} from '#/state/session'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {
useMinimalShellMode,
useSetDrawerSwipeDisabled,
useSetMinimalShellMode,
} from '#/state/shell'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
import {HomeTabNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
@@ -54,8 +59,6 @@ function HomeScreenReady({
pinnedFeedInfos: FeedSourceInfo[]
}) {
useOTAUpdates()
const gate = useGate()
const allFeeds = React.useMemo(() => {
const feeds: FeedDescriptor[] = []
for (const {uri} of pinnedFeedInfos) {
@@ -122,11 +125,17 @@ function HomeScreenReady({
}),
)
const gate = useGate()
const mode = useMinimalShellMode()
const {isMobile} = useWebMediaQueries()
React.useEffect(() => {
const listener = AppState.addEventListener('change', nextAppState => {
if (nextAppState === 'active') {
// TODO: Check if minimal shell is on before logging an exposure.
if (gate('disable_min_shell_on_foregrounding')) {
if (
isMobile &&
mode.value === 1 &&
gate('disable_min_shell_on_foregrounding_v2')
) {
setMinimalShellMode(false)
}
}
@@ -134,7 +143,7 @@ function HomeScreenReady({
return () => {
listener.remove()
}
}, [setMinimalShellMode, gate])
}, [setMinimalShellMode, mode, isMobile, gate])
const onPageSelected = React.useCallback(
(index: number) => {
+19 -15
View File
@@ -1,25 +1,26 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {Trans} from '@lingui/macro'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {s} from 'lib/styles'
import {Text} from '../com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {
EmbedPlayerSource,
externalEmbedLabels,
} from '#/lib/strings/embed-player'
import {useSetMinimalShellMode} from '#/state/shell'
import {Trans} from '@lingui/macro'
import {ScrollView} from '../com/util/Views'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {s} from 'lib/styles'
import {
useExternalEmbedsPrefs,
useSetExternalEmbedPref,
} from 'state/preferences'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
import {Text} from '../com/util/text/Text'
import {ScrollView} from '../com/util/Views'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
@@ -74,13 +75,16 @@ export function PreferencesExternalEmbeds({}: Props) {
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Enable media players for</Trans>
</Text>
{Object.entries(externalEmbedLabels).map(([key, label]) => (
<PrefSelector
source={key as EmbedPlayerSource}
label={label}
key={key}
/>
))}
{Object.entries(externalEmbedLabels)
// TODO: Remove special case when we disable the old integration.
.filter(([key]) => key !== 'tenor')
.map(([key, label]) => (
<PrefSelector
source={key as EmbedPlayerSource}
label={label}
key={key}
/>
))}
</ScrollView>
</View>
)
+81 -21
View File
@@ -1,4 +1,4 @@
import React, {useMemo} from 'react'
import React, {useEffect, useMemo} from 'react'
import {StyleSheet} from 'react-native'
import {
AppBskyActorDefs,
@@ -11,6 +11,7 @@ import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useLabelerInfoQuery} from '#/state/queries/labeler'
@@ -25,7 +26,6 @@ import {useAnalytics} from 'lib/analytics/analytics'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {ComposeIcon2} from 'lib/icons'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {useGate} from 'lib/statsig/statsig'
import {combinedDisplayName} from 'lib/strings/display-names'
import {isInvalidHandle} from 'lib/strings/handles'
import {colors, s} from 'lib/styles'
@@ -143,7 +143,6 @@ function ProfileScreenLoaded({
const setMinimalShellMode = useSetMinimalShellMode()
const {openComposer} = useComposerControls()
const {screen, track} = useAnalytics()
const gate = useGate()
const {
data: labelerInfo,
error: labelerError,
@@ -317,21 +316,8 @@ function ProfileScreenLoaded({
// =
const renderHeader = React.useCallback(() => {
if (gate('new_profile_scroll_component')) {
return (
<ExpoScrollForwarderView scrollViewTag={scrollViewTag}>
<ProfileHeader
profile={profile}
labeler={labelerInfo}
descriptionRT={hasDescription ? descriptionRT : null}
moderationOpts={moderationOpts}
hideBackButton={hideBackButton}
isPlaceholderProfile={showPlaceholder}
/>
</ExpoScrollForwarderView>
)
} else {
return (
return (
<ExpoScrollForwarderView scrollViewTag={scrollViewTag}>
<ProfileHeader
profile={profile}
labeler={labelerInfo}
@@ -340,10 +326,9 @@ function ProfileScreenLoaded({
hideBackButton={hideBackButton}
isPlaceholderProfile={showPlaceholder}
/>
)
}
</ExpoScrollForwarderView>
)
}, [
gate,
scrollViewTag,
profile,
labelerInfo,
@@ -481,6 +466,7 @@ function ProfileScreenLoaded({
accessibilityHint=""
/>
)}
<TestGates />
</ScreenHider>
)
}
@@ -538,3 +524,77 @@ const styles = StyleSheet.create({
textAlign: 'center',
},
})
const shouldExposeToGate2 = Math.random() < 0.2
// --- Temporary: we're testing our Statsig setup ---
let TestGates = React.memo(function TestGates() {
const gate = useGate()
useEffect(() => {
logEvent('test:all:always', {})
if (Math.random() < 0.2) {
logEvent('test:all:sometimes', {})
}
if (Math.random() < 0.1) {
logEvent('test:all:boosted_by_gate1', {
reason: 'base',
})
}
if (Math.random() < 0.1) {
logEvent('test:all:boosted_by_gate2', {
reason: 'base',
})
}
if (Math.random() < 0.1) {
logEvent('test:all:boosted_by_both', {
reason: 'base',
})
}
}, [])
return [
gate('test_gate_1') ? <TestGate1 /> : null,
shouldExposeToGate2 && gate('test_gate_2') ? <TestGate2 /> : null,
]
})
function TestGate1() {
useEffect(() => {
logEvent('test:gate1:always', {})
if (Math.random() < 0.2) {
logEvent('test:gate1:sometimes', {})
}
if (Math.random() < 0.5) {
logEvent('test:all:boosted_by_gate1', {
reason: 'gate1',
})
}
if (Math.random() < 0.5) {
logEvent('test:all:boosted_by_both', {
reason: 'gate1',
})
}
}, [])
return null
}
function TestGate2() {
useEffect(() => {
logEvent('test:gate2:always', {})
if (Math.random() < 0.2) {
logEvent('test:gate2:sometimes', {})
}
if (Math.random() < 0.5) {
logEvent('test:all:boosted_by_gate2', {
reason: 'gate2',
})
}
if (Math.random() < 0.5) {
logEvent('test:all:boosted_by_both', {
reason: 'gate2',
})
}
}, [])
return null
}
+52 -121
View File
@@ -393,13 +393,7 @@ function SearchScreenUserResults({
)
}
export function SearchScreenInner({
query,
primarySearch,
}: {
query?: string
primarySearch?: boolean
}) {
export function SearchScreenInner({query}: {query?: string}) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
@@ -407,7 +401,6 @@ export function SearchScreenInner({
const {isDesktop} = useWebMediaQueries()
const [activeTab, setActiveTab] = React.useState(0)
const {_} = useLingui()
const gate = useGate()
const onPageSelected = React.useCallback(
(index: number) => {
@@ -420,114 +413,35 @@ export function SearchScreenInner({
const sections = React.useMemo(() => {
if (!query) return []
if (gate('new_search')) {
if (hasSession) {
return [
{
title: _(msg`Top`),
component: (
<SearchScreenPostResults
query={query}
sort="top"
active={activeTab === 0}
/>
),
},
{
title: _(msg`Latest`),
component: (
<SearchScreenPostResults
query={query}
sort="latest"
active={activeTab === 1}
/>
),
},
{
title: _(msg`People`),
component: (
<SearchScreenUserResults query={query} active={activeTab === 2} />
),
},
]
} else {
return [
{
title: _(msg`People`),
component: (
<SearchScreenUserResults query={query} active={activeTab === 0} />
),
},
]
}
} else {
if (hasSession) {
return [
{
title: _(msg`Posts`),
component: (
<SearchScreenPostResults query={query} active={activeTab === 0} />
),
},
{
title: _(msg`Users`),
component: (
<SearchScreenUserResults query={query} active={activeTab === 1} />
),
},
]
} else {
return [
{
title: _(msg`Users`),
component: (
<SearchScreenUserResults query={query} active={activeTab === 0} />
),
},
]
}
}
}, [hasSession, gate, _, query, activeTab])
if (hasSession) {
return query ? (
<Pager
onPageSelected={onPageSelected}
renderTabBar={props => (
<CenteredView
sideBorders
style={[pal.border, pal.view, styles.tabBarContainer]}>
<TabBar items={sections.map(section => section.title)} {...props} />
</CenteredView>
)}
initialPage={0}>
{sections.map((section, i) => (
<View key={i}>{section.component}</View>
))}
</Pager>
) : (
<View>
<CenteredView sideBorders style={pal.border}>
<Text
type="title"
style={[
pal.text,
pal.border,
{
display: 'flex',
paddingVertical: 12,
paddingHorizontal: 18,
fontWeight: 'bold',
},
]}>
<Trans>Suggested Follows</Trans>
</Text>
</CenteredView>
<SearchScreenSuggestedFollows />
</View>
)
}
return [
{
title: _(msg`Top`),
component: (
<SearchScreenPostResults
query={query}
sort="top"
active={activeTab === 0}
/>
),
},
{
title: _(msg`Latest`),
component: (
<SearchScreenPostResults
query={query}
sort="latest"
active={activeTab === 1}
/>
),
},
{
title: _(msg`People`),
component: (
<SearchScreenUserResults query={query} active={activeTab === 2} />
),
},
]
}, [_, query, activeTab])
return query ? (
<Pager
@@ -544,6 +458,27 @@ export function SearchScreenInner({
<View key={i}>{section.component}</View>
))}
</Pager>
) : hasSession ? (
<View>
<CenteredView sideBorders style={pal.border}>
<Text
type="title"
style={[
pal.text,
pal.border,
{
display: 'flex',
paddingVertical: 12,
paddingHorizontal: 18,
fontWeight: 'bold',
},
]}>
<Trans>Suggested Follows</Trans>
</Text>
</CenteredView>
<SearchScreenSuggestedFollows />
</View>
) : (
<CenteredView sideBorders style={pal.border}>
<View
@@ -583,11 +518,7 @@ export function SearchScreenInner({
style={pal.textLight}
/>
<Text type="xl" style={[pal.textLight, {paddingHorizontal: 18}]}>
{isDesktop && !primarySearch ? (
<Trans>Find users with the search tool on the right</Trans>
) : (
<Trans>Find users on Bluesky</Trans>
)}
<Trans>Find posts and users on Bluesky</Trans>
</Text>
</View>
</View>
@@ -0,0 +1,196 @@
import React, {useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {isNative} from '#/platform/detection'
import {getAgent, useSession, useSessionApi} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
enum Stages {
Email,
ConfirmCode,
}
export function DisableEmail2FADialog({
control,
}: {
control: Dialog.DialogOuterProps['control']
}) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession()
const {updateCurrentAccount} = useSessionApi()
const [stage, setStage] = useState<Stages>(Stages.Email)
const [confirmationCode, setConfirmationCode] = useState<string>('')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const onSendEmail = async () => {
setError('')
setIsProcessing(true)
try {
await getAgent().com.atproto.server.requestEmailUpdate()
setStage(Stages.ConfirmCode)
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
const onConfirmDisable = async () => {
setError('')
setIsProcessing(true)
try {
if (currentAccount?.email) {
await getAgent().com.atproto.server.updateEmail({
email: currentAccount!.email,
token: confirmationCode.trim(),
emailAuthFactor: false,
})
updateCurrentAccount({emailAuthFactor: false})
Toast.show(_(msg`Email 2FA disabled`))
}
control.close()
} catch (e) {
const errMsg = String(e)
if (errMsg.includes('Token is invalid')) {
setError(_(msg`Invalid 2FA confirmation code.`))
} else {
setError(cleanError(errMsg))
}
} finally {
setIsProcessing(false)
}
}
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text
nativeID="dialog-title"
style={[a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>Disable Email 2FA</Trans>
</Text>
<P
nativeID="dialog-description"
style={[a.text_sm, t.atoms.text, a.leading_snug]}>
{stage === Stages.ConfirmCode ? (
<Trans>
An email has been sent to{' '}
{currentAccount?.email || '(no email)'}. It includes a
confirmation code which you can enter below.
</Trans>
) : (
<Trans>
To disable the email 2FA method, please verify your access to
the email address.
</Trans>
)}
</P>
{error ? <ErrorMessage message={error} /> : undefined}
{stage === Stages.Email ? (
<View style={gtMobile && [a.flex_row, a.justify_end, a.gap_md]}>
<Button
testID="sendEmailButton"
variant="solid"
color="primary"
size={gtMobile ? 'small' : 'large'}
onPress={onSendEmail}
label={_(msg`Send verification email`)}
disabled={isProcessing}>
<ButtonText>
<Trans>Send verification email</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
<Button
testID="haveCodeButton"
variant="ghost"
color="primary"
size={gtMobile ? 'small' : 'large'}
onPress={() => setStage(Stages.ConfirmCode)}
label={_(msg`I have a code`)}
disabled={isProcessing}>
<ButtonText>
<Trans>I have a code</Trans>
</ButtonText>
</Button>
</View>
) : stage === Stages.ConfirmCode ? (
<View>
<View style={[a.mb_md]}>
<TextField.LabelText>
<Trans>Confirmation code</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Lock} />
<Dialog.Input
testID="confirmationCode"
label={_(msg`Confirmation code`)}
autoCapitalize="none"
autoFocus
autoCorrect={false}
autoComplete="off"
value={confirmationCode}
onChangeText={setConfirmationCode}
onSubmitEditing={onConfirmDisable}
editable={!isProcessing}
/>
</TextField.Root>
</View>
<View style={gtMobile && [a.flex_row, a.justify_end]}>
<Button
testID="resendCodeBtn"
variant="ghost"
color="primary"
size={gtMobile ? 'small' : 'large'}
onPress={onSendEmail}
label={_(msg`Resend email`)}
disabled={isProcessing}>
<ButtonText>
<Trans>Resend email</Trans>
</ButtonText>
</Button>
<Button
testID="confirmBtn"
variant="solid"
color="primary"
size={gtMobile ? 'small' : 'large'}
onPress={onConfirmDisable}
label={_(msg`Confirm`)}
disabled={isProcessing}>
<ButtonText>
<Trans>Confirm</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
</View>
</View>
) : undefined}
{!gtMobile && isNative && <View style={{height: 40}} />}
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
@@ -0,0 +1,60 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {getAgent, useSession, useSessionApi} from '#/state/session'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {useDialogControl} from '#/components/Dialog'
import {DisableEmail2FADialog} from './DisableEmail2FADialog'
export function Email2FAToggle() {
const {_} = useLingui()
const {currentAccount} = useSession()
const {updateCurrentAccount} = useSessionApi()
const {openModal} = useModalControls()
const disableDialogCtrl = useDialogControl()
const enableEmailAuthFactor = React.useCallback(async () => {
if (currentAccount?.email) {
await getAgent().com.atproto.server.updateEmail({
email: currentAccount.email,
emailAuthFactor: true,
})
updateCurrentAccount({
emailAuthFactor: true,
})
}
}, [currentAccount, updateCurrentAccount])
const onToggle = React.useCallback(() => {
if (!currentAccount) {
return
}
if (currentAccount.emailAuthFactor) {
disableDialogCtrl.open()
} else {
if (!currentAccount.emailConfirmed) {
openModal({
name: 'verify-email',
onSuccess: enableEmailAuthFactor,
})
return
}
enableEmailAuthFactor()
}
}, [currentAccount, enableEmailAuthFactor, openModal, disableDialogCtrl])
return (
<>
<DisableEmail2FADialog control={disableDialogCtrl} />
<ToggleButton
type="default-light"
label={_(msg`Require email code to log into your account`)}
labelType="lg"
isSelected={!!currentAccount?.emailAuthFactor}
onPress={onToggle}
/>
</>
)
}
+37 -42
View File
@@ -20,14 +20,10 @@ import {useLingui} from '@lingui/react'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {isIOS, isNative} from '#/platform/detection'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {clearLegacyStorage} from '#/state/persisted/legacy'
import {clear as clearStorage} from '#/state/persisted/store'
import {
useRequireAltTextEnabled,
useSetRequireAltTextEnabled,
} from '#/state/preferences'
import {
useInAppBrowser,
useSetInAppBrowser,
@@ -56,10 +52,6 @@ import {makeProfileLink} from 'lib/routes/links'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {NavigationProp} from 'lib/routes/types'
import {colors, s} from 'lib/styles'
import {
useHapticsDisabled,
useSetHapticsDisabled,
} from 'state/preferences/disable-haptics'
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
@@ -72,6 +64,7 @@ import {ScrollView} from 'view/com/util/Views'
import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {navigate, resetToTab} from '#/Navigation'
import {Email2FAToggle} from './Email2FAToggle'
import {ExportCarDialog} from './ExportCarDialog'
function SettingsAccountCard({account}: {account: SessionAccount}) {
@@ -162,12 +155,8 @@ export function SettingsScreen({}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const requireAltTextEnabled = useRequireAltTextEnabled()
const setRequireAltTextEnabled = useSetRequireAltTextEnabled()
const inAppBrowserPref = useInAppBrowser()
const setUseInAppBrowser = useSetInAppBrowser()
const isHapticsDisabled = useHapticsDisabled()
const setHapticsDisabled = useSetHapticsDisabled()
const onboardingDispatch = useOnboardingDispatch()
const navigation = useNavigation<NavigationProp>()
const {isMobile} = useWebMediaQueries()
@@ -282,6 +271,10 @@ export function SettingsScreen({}: Props) {
navigation.navigate('SavedFeeds')
}, [navigation])
const onPressAccessibilitySettings = React.useCallback(() => {
navigation.navigate('AccessibilitySettings')
}, [navigation])
const onPressStatusPage = React.useCallback(() => {
Linking.openURL(STATUS_PAGE_URL)
}, [])
@@ -318,7 +311,7 @@ export function SettingsScreen({}: Props) {
</View>
</SimpleViewHeader>
<ScrollView
style={[s.hContentRegion]}
style={s.hContentRegion}
contentContainerStyle={isMobile && pal.viewLight}
scrollIndicatorInsets={{right: 1}}
// @ts-ignore web only -prf
@@ -417,21 +410,6 @@ export function SettingsScreen({}: Props) {
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Accessibility</Trans>
</Text>
<View style={[pal.view, styles.toggleCard]}>
<ToggleButton
type="default-light"
label={_(msg`Require alt text before posting`)}
labelType="lg"
isSelected={requireAltTextEnabled}
onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)}
/>
</View>
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Appearance</Trans>
</Text>
@@ -492,6 +470,29 @@ export function SettingsScreen({}: Props) {
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Basics</Trans>
</Text>
<TouchableOpacity
testID="accessibilitySettingsBtn"
style={[
styles.linkCard,
pal.view,
isSwitchingAccounts && styles.dimmed,
]}
onPress={
isSwitchingAccounts ? undefined : onPressAccessibilitySettings
}
accessibilityRole="button"
accessibilityLabel={_(msg`Accessibility settings`)}
accessibilityHint={_(msg`Opens accessibility settings`)}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="universal-access"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Accessibility</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="preferencesHomeFeedButton"
style={[
@@ -689,19 +690,13 @@ export function SettingsScreen({}: Props) {
/>
</View>
)}
{isNative && (
<View style={[pal.view, styles.toggleCard]}>
<ToggleButton
type="default-light"
label={
isIOS ? _(msg`Disable haptics`) : _(msg`Disable vibrations`)
}
labelType="lg"
isSelected={isHapticsDisabled}
onPress={() => setHapticsDisabled(!isHapticsDisabled)}
/>
</View>
)}
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Two-factor authentication</Trans>
</Text>
<View style={[pal.view, styles.toggleCard]}>
<Email2FAToggle />
</View>
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Account</Trans>
+1 -1
View File
@@ -9,7 +9,7 @@ import {
ButtonText,
ButtonVariant,
} from '#/components/Button'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/Arrow'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {H1} from '#/components/Typography'
+3 -3
View File
@@ -2,11 +2,11 @@ import React from 'react'
import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {H1} from '#/components/Typography'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/Arrow'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {Loader} from '#/components/Loader'
import {H1} from '#/components/Typography'
export function Icons() {
const t = useTheme()
+12
View File
@@ -46,6 +46,18 @@
multiformats "^9.9.0"
tlds "^1.234.0"
"@atproto/api@^0.12.5":
version "0.12.5"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.5.tgz#3ed70990b27c468d9663ca71306039cab663ca96"
integrity sha512-xqdl/KrAK2kW6hN8+eSmKTWHgMNaPnDAEvZzo08Xbk/5jdRzjoEPS+p7k/wQ+ZefwOHL3QUbVPO4hMfmVxzO/Q==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0"
"@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.5.0"
multiformats "^9.9.0"
tlds "^1.234.0"
"@atproto/aws@^0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.0.tgz#17f3faf744824457cabd62f87be8bf08cacf8029"