Compare commits

..

7 Commits

Author SHA1 Message Date
Eric Bailey 3fd3959772 Sloppy feeds 2024-06-27 19:56:37 -05:00
Eric Bailey 2bc24347a8 Mostly there with follows 2024-06-27 19:23:32 -05:00
Eric Bailey 24d65b3ffb Nvm don't need it, add to Home screen 2024-06-27 15:05:00 -05:00
Eric Bailey 5a56557eb6 Use shared hook 2024-06-27 15:00:02 -05:00
Eric Bailey 7c320dd192 Improve mobile 2024-06-27 14:50:59 -05:00
Eric Bailey 010b7cef48 Add components for empty following feed 2024-06-27 14:50:51 -05:00
Eric Bailey 2b9fb2cf4c WIP suggested follows section for feeds 2024-06-27 14:50:41 -05:00
279 changed files with 17646 additions and 25698 deletions
-2
View File
@@ -1,2 +0,0 @@
[*.{kt,kts}]
indent_size=2
+4 -4
View File
@@ -10,7 +10,7 @@ Get the app itself:
## Development Resources
This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also open source, but in [a different git repository](https://github.com/bluesky-social/atproto).
This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also on open source, but in [a different git repository](https://github.com/bluesky-social/atproto).
There is a small amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application.
@@ -42,10 +42,10 @@ The Bluesky Social application encompasses a set of schemas and APIs built in th
- Open an issue and give some time for discussion before submitting a PR.
- Stay away from PRs like...
- Changing "Post" to "Skeet."
- Refactoring the codebase, e.g., to replace MobX with Redux or something.
- Refactoring the codebase, eg to replace mobx with redux or something.
- Adding entirely new features without prior discussion.
Remember, we serve a wide community of users. Our day-to-day involves us constantly asking "which top priority is our top priority." If you submit well-written PRs that solve problems concisely, that's an awesome contribution. Otherwise, as much as we'd love to accept your ideas and contributions, we really don't have the bandwidth. That's what forking is for!
Remember, we serve a wide community of users. Our day to day involves us constantly asking "which top priority is our top priority." If you submit well-written PRs that solve problems concisely, that's an awesome contribution. Otherwise, as much as we'd love to accept your ideas and contributions, we really don't have the bandwidth. That's what forking is for!
## Forking guidelines
@@ -63,7 +63,7 @@ If you discover any security issues, please send an email to security@bsky.app.
## Are you a developer interested in building on atproto?
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party integration can be as seamless as first-party through custom feeds, federated services, clients, and more.
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party can be as seamless as first-party through custom feeds, federated services, clients, and more.
## License (MIT)
+2 -3
View File
@@ -132,8 +132,7 @@ appId: xyz.blueskyweb.app
id: "feedItem-by-bob.test"
- tapOn:
id: "e2eGotoFeeds"
- tapOn:
id: "saved-feed-Good Ppl"
- tapOn: "Good Ppl"
- assertVisible:
id: "feedItem-by-bob.test"
- tapOn:
@@ -169,7 +168,7 @@ appId: xyz.blueskyweb.app
id: "profilePager-selector"
direction: LEFT
- tapOn:
id: "profilePager-selector-6"
id: "profilePager-selector-5"
- tapOn: "Good Ppl"
- tapOn:
-31
View File
@@ -1,31 +0,0 @@
appId: xyz.blueskyweb.app
---
- runScript:
file: ../setupServer.js
env:
SERVER_PATH: "?users&posts&feeds"
- runFlow:
file: ../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn: "/sys/debug"
- tapOn:
id: "sharedPrefsTestOpenBtn"
- tapOn:
id: "setStringBtn"
- assertVisible: "Hello"
- tapOn:
id: "removeStringBtn"
- assertVisible: "null"
- tapOn:
id: "setBoolBtn"
- assertVisible: "true"
- tapOn:
id: "setNumberBtn"
- assertVisible: "123"
- tapOn:
id: "addToSetBtn"
- assertVisible: "true"
- tapOn:
id: "removeFromSetBtn"
- assertVisible: "false"
-3
View File
@@ -211,8 +211,6 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
},
],
'expo-video',
'react-native-compressor',
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',
@@ -221,7 +219,6 @@ module.exports = function (config) {
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
'./plugins/shareExtension/withShareExtensions.js',
'./plugins/notificationsExtension/withNotificationsExtension.js',
'./plugins/withAppDelegateReferrer.js',
].filter(Boolean),
extra: {
eas: {
@@ -1 +0,0 @@
<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 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 370 B

-1
View File
@@ -197,7 +197,6 @@ func serve(cctx *cli.Context) error {
e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric)
+9 -9
View File
@@ -13,43 +13,43 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(request.content)
return
}
if reason == "chat-message" {
mutateWithChatMessage(bestAttempt)
} else {
mutateWithBadge(bestAttempt)
}
contentHandler(bestAttempt)
}
override func serviceExtensionTimeWillExpire() {
// If for some reason the alloted time expires, we don't actually want to display a notification
}
func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? {
return content.mutableCopy() as? UNMutableNotificationContent
}
func mutateWithBadge(_ content: UNMutableNotificationContent) {
var count = prefs?.integer(forKey: "badgeCount") ?? 0
count += 1
// Set the new badge number for the notification, then store that value for using later
content.badge = NSNumber(value: count)
prefs?.setValue(count, forKey: "badgeCount")
}
func mutateWithChatMessage(_ content: UNMutableNotificationContent) {
if self.prefs?.bool(forKey: "playSoundChat") == true {
mutateWithDmSound(content)
}
}
func mutateWithDefaultSound(_ content: UNMutableNotificationContent) {
content.sound = UNNotificationSound.default
}
func mutateWithDmSound(_ content: UNMutableNotificationContent) {
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff"))
}
@@ -30,11 +30,12 @@ class ShareViewController: UIViewController {
}
}
private func handleText(item: NSItemProvider) async {
private func handleText(item: NSItemProvider) async -> Void {
do {
if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String {
if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") {
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)")
{
_ = self.openURL(url)
}
}
@@ -44,11 +45,12 @@ class ShareViewController: UIViewController {
}
}
private func handleUrl(item: NSItemProvider) async {
private func handleUrl(item: NSItemProvider) async -> Void {
do {
if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL {
if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") {
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)")
{
_ = self.openURL(url)
}
}
@@ -58,7 +60,7 @@ class ShareViewController: UIViewController {
}
}
private func handleImages(items: [NSItemProvider]) async {
private func handleImages(items: [NSItemProvider]) async -> Void {
let firstFourItems: [NSItemProvider]
if items.count < 4 {
firstFourItems = items
@@ -70,7 +72,7 @@ class ShareViewController: UIViewController {
var imageUris = ""
for (index, item) in firstFourItems.enumerated() {
var imageUriInfo: String?
var imageUriInfo: String? = nil
do {
if let dataUri = try await item.loadItem(forTypeIdentifier: "public.image") as? URL {
@@ -98,7 +100,8 @@ class ShareViewController: UIViewController {
if valid,
let encoded = imageUris.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
let url = URL(string: "\(self.appScheme)://intent/compose?imageUris=\(encoded)") {
let url = URL(string: "\(self.appScheme)://intent/compose?imageUris=\(encoded)")
{
_ = self.openURL(url)
}
@@ -116,11 +119,13 @@ class ShareViewController: UIViewController {
// extension does.
if let dir = FileManager()
.containerURL(
forSecurityApplicationGroupIdentifier: "group.app.bsky") {
forSecurityApplicationGroupIdentifier: "group.app.bsky")
{
let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg"
if let newUri = URL(string: filePath),
let jpegData = image.jpegData(compressionQuality: 1) {
let jpegData = image.jpegData(compressionQuality: 1)
{
try jpegData.write(to: newUri)
return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)"
}
@@ -131,7 +136,7 @@ class ShareViewController: UIViewController {
}
}
private func completeRequest() {
private func completeRequest() -> Void {
self.extensionContext?.completeRequest(returningItems: nil)
}
@@ -5,7 +5,7 @@ import com.google.firebase.messaging.RemoteMessage
class BackgroundNotificationHandler(
private val context: Context,
private val notifInterface: BackgroundNotificationHandlerInterface,
private val notifInterface: BackgroundNotificationHandlerInterface
) {
fun handleMessage(remoteMessage: RemoteMessage) {
if (ExpoBackgroundNotificationHandlerModule.isForegrounded) {
@@ -8,68 +8,67 @@ class ExpoBackgroundNotificationHandlerModule : Module() {
var isForegrounded = false
}
override fun definition() =
ModuleDefinition {
Name("ExpoBackgroundNotificationHandler")
override fun definition() = ModuleDefinition {
Name("ExpoBackgroundNotificationHandler")
OnCreate {
NotificationPrefs(appContext.reactContext).initialize()
}
OnActivityEntersForeground {
isForegrounded = true
}
OnActivityEntersBackground {
isForegrounded = false
}
AsyncFunction("getAllPrefsAsync") {
return@AsyncFunction NotificationPrefs(appContext.reactContext).getAllPrefs()
}
AsyncFunction("getBoolAsync") { forKey: String ->
return@AsyncFunction NotificationPrefs(appContext.reactContext).getBoolean(forKey)
}
AsyncFunction("getStringAsync") { forKey: String ->
return@AsyncFunction NotificationPrefs(appContext.reactContext).getString(forKey)
}
AsyncFunction("getStringArrayAsync") { forKey: String ->
return@AsyncFunction NotificationPrefs(appContext.reactContext).getStringArray(forKey)
}
AsyncFunction("setBoolAsync") { forKey: String, value: Boolean ->
NotificationPrefs(appContext.reactContext).setBoolean(forKey, value)
}
AsyncFunction("setStringAsync") { forKey: String, value: String ->
NotificationPrefs(appContext.reactContext).setString(forKey, value)
}
AsyncFunction("setStringArrayAsync") { forKey: String, value: Array<String> ->
NotificationPrefs(appContext.reactContext).setStringArray(forKey, value)
}
AsyncFunction("addToStringArrayAsync") { forKey: String, string: String ->
NotificationPrefs(appContext.reactContext).addToStringArray(forKey, string)
}
AsyncFunction("removeFromStringArrayAsync") { forKey: String, string: String ->
NotificationPrefs(appContext.reactContext).removeFromStringArray(forKey, string)
}
AsyncFunction("addManyToStringArrayAsync") { forKey: String, strings: Array<String> ->
NotificationPrefs(appContext.reactContext).addManyToStringArray(forKey, strings)
}
AsyncFunction("removeManyFromStringArrayAsync") { forKey: String, strings: Array<String> ->
NotificationPrefs(appContext.reactContext).removeManyFromStringArray(forKey, strings)
}
AsyncFunction("setBadgeCountAsync") { _: Int ->
// This does nothing on Android
}
OnCreate {
NotificationPrefs(appContext.reactContext).initialize()
}
OnActivityEntersForeground {
isForegrounded = true
}
OnActivityEntersBackground {
isForegrounded = false
}
AsyncFunction("getAllPrefsAsync") {
return@AsyncFunction NotificationPrefs(appContext.reactContext).getAllPrefs()
}
AsyncFunction("getBoolAsync") { forKey: String ->
return@AsyncFunction NotificationPrefs(appContext.reactContext).getBoolean(forKey)
}
AsyncFunction("getStringAsync") { forKey: String ->
return@AsyncFunction NotificationPrefs(appContext.reactContext).getString(forKey)
}
AsyncFunction("getStringArrayAsync") { forKey: String ->
return@AsyncFunction NotificationPrefs(appContext.reactContext).getStringArray(forKey)
}
AsyncFunction("setBoolAsync") { forKey: String, value: Boolean ->
NotificationPrefs(appContext.reactContext).setBoolean(forKey, value)
}
AsyncFunction("setStringAsync") { forKey: String, value: String ->
NotificationPrefs(appContext.reactContext).setString(forKey, value)
}
AsyncFunction("setStringArrayAsync") { forKey: String, value: Array<String> ->
NotificationPrefs(appContext.reactContext).setStringArray(forKey, value)
}
AsyncFunction("addToStringArrayAsync") { forKey: String, string: String ->
NotificationPrefs(appContext.reactContext).addToStringArray(forKey, string)
}
AsyncFunction("removeFromStringArrayAsync") { forKey: String, string: String ->
NotificationPrefs(appContext.reactContext).removeFromStringArray(forKey, string)
}
AsyncFunction("addManyToStringArrayAsync") { forKey: String, strings: Array<String> ->
NotificationPrefs(appContext.reactContext).addManyToStringArray(forKey, strings)
}
AsyncFunction("removeManyFromStringArrayAsync") { forKey: String, strings: Array<String> ->
NotificationPrefs(appContext.reactContext).removeManyFromStringArray(forKey, strings)
}
AsyncFunction("setBadgeCountAsync") { _: Int ->
// This does nothing on Android
}
}
}
@@ -2,24 +2,20 @@ package expo.modules.backgroundnotificationhandler
import android.content.Context
val DEFAULTS =
mapOf<String, Any>(
"playSoundChat" to true,
"playSoundFollow" to false,
"playSoundLike" to false,
"playSoundMention" to false,
"playSoundQuote" to false,
"playSoundReply" to false,
"playSoundRepost" to false,
"mutedThreads" to mapOf<String, List<String>>(),
)
val DEFAULTS = mapOf<String, Any>(
"playSoundChat" to true,
"playSoundFollow" to false,
"playSoundLike" to false,
"playSoundMention" to false,
"playSoundQuote" to false,
"playSoundReply" to false,
"playSoundRepost" to false,
"mutedThreads" to mapOf<String, List<String>>()
)
class NotificationPrefs(
private val context: Context?,
) {
private val prefs =
context?.getSharedPreferences("xyz.blueskyweb.app", Context.MODE_PRIVATE)
?: throw Error("Context is null")
class NotificationPrefs (private val context: Context?) {
private val prefs = context?.getSharedPreferences("xyz.blueskyweb.app", Context.MODE_PRIVATE)
?: throw Error("Context is null")
fun initialize() {
prefs
@@ -45,99 +41,94 @@ class NotificationPrefs(
}
}
}
}.apply()
}
.apply()
}
fun getAllPrefs(): MutableMap<String, *> = prefs.all
fun getAllPrefs(): MutableMap<String, *> {
return prefs.all
}
fun getBoolean(key: String): Boolean = prefs.getBoolean(key, false)
fun getBoolean(key: String): Boolean {
return prefs.getBoolean(key, false)
}
fun getString(key: String): String? = prefs.getString(key, null)
fun getString(key: String): String? {
return prefs.getString(key, null)
}
fun getStringArray(key: String): Array<String>? = prefs.getStringSet(key, null)?.toTypedArray()
fun getStringArray(key: String): Array<String>? {
return prefs.getStringSet(key, null)?.toTypedArray()
}
fun setBoolean(
key: String,
value: Boolean,
) {
fun setBoolean(key: String, value: Boolean) {
prefs
.edit()
.apply {
putBoolean(key, value)
}.apply()
}
.apply()
}
fun setString(
key: String,
value: String,
) {
fun setString(key: String, value: String) {
prefs
.edit()
.apply {
putString(key, value)
}.apply()
}
.apply()
}
fun setStringArray(
key: String,
value: Array<String>,
) {
fun setStringArray(key: String, value: Array<String>) {
prefs
.edit()
.apply {
putStringSet(key, value.toSet())
}.apply()
}
.apply()
}
fun addToStringArray(
key: String,
string: String,
) {
fun addToStringArray(key: String, string: String) {
prefs
.edit()
.apply {
val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
set.add(string)
putStringSet(key, set)
}.apply()
}
.apply()
}
fun removeFromStringArray(
key: String,
string: String,
) {
fun removeFromStringArray(key: String, string: String) {
prefs
.edit()
.apply {
val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
set.remove(string)
putStringSet(key, set)
}.apply()
}
.apply()
}
fun addManyToStringArray(
key: String,
strings: Array<String>,
) {
fun addManyToStringArray(key: String, strings: Array<String>) {
prefs
.edit()
.apply {
val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
set.addAll(strings.toSet())
putStringSet(key, set)
}.apply()
}
.apply()
}
fun removeManyFromStringArray(
key: String,
strings: Array<String>,
) {
fun removeManyFromStringArray(key: String, strings: Array<String>) {
prefs
.edit()
.apply {
val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
set.removeAll(strings.toSet())
putStringSet(key, set)
}.apply()
}
.apply()
}
}
}
@@ -2,16 +2,16 @@ import ExpoModulesCore
let APP_GROUP = "group.app.bsky"
let DEFAULTS: [String: Any] = [
"playSoundChat": true,
let DEFAULTS: [String:Any] = [
"playSoundChat" : true,
"playSoundFollow": false,
"playSoundLike": false,
"playSoundMention": false,
"playSoundQuote": false,
"playSoundReply": false,
"playSoundRepost": false,
"mutedThreads": [:] as! [String: [String]],
"badgeCount": 0
"mutedThreads": [:] as! [String:[String]],
"badgeCount": 0,
]
/*
@@ -23,10 +23,10 @@ let DEFAULTS: [String: Any] = [
*/
public class ExpoBackgroundNotificationHandlerModule: Module {
let userDefaults = UserDefaults(suiteName: APP_GROUP)
public func definition() -> ModuleDefinition {
Name("ExpoBackgroundNotificationHandler")
OnCreate {
DEFAULTS.forEach { p in
if userDefaults?.value(forKey: p.key) == nil {
@@ -34,56 +34,57 @@ public class ExpoBackgroundNotificationHandlerModule: Module {
}
}
}
AsyncFunction("getAllPrefsAsync") { () -> [String: Any]? in
AsyncFunction("getAllPrefsAsync") { () -> [String:Any]? in
var keys: [String] = []
DEFAULTS.forEach { p in
keys.append(p.key)
}
return userDefaults?.dictionaryWithValues(forKeys: keys)
}
AsyncFunction("getBoolAsync") { (forKey: String) -> Bool in
if let pref = userDefaults?.bool(forKey: forKey) {
return pref
}
return false
}
AsyncFunction("getStringAsync") { (forKey: String) -> String? in
if let pref = userDefaults?.string(forKey: forKey) {
return pref
}
return nil
}
AsyncFunction("getStringArrayAsync") { (forKey: String) -> [String]? in
if let pref = userDefaults?.stringArray(forKey: forKey) {
return pref
}
return nil
}
AsyncFunction("setBoolAsync") { (forKey: String, value: Bool) in
AsyncFunction("setBoolAsync") { (forKey: String, value: Bool) -> Void in
userDefaults?.setValue(value, forKey: forKey)
}
AsyncFunction("setStringAsync") { (forKey: String, value: String) in
AsyncFunction("setStringAsync") { (forKey: String, value: String) -> Void in
userDefaults?.setValue(value, forKey: forKey)
}
AsyncFunction("setStringArrayAsync") { (forKey: String, value: [String]) in
AsyncFunction("setStringArrayAsync") { (forKey: String, value: [String]) -> Void in
userDefaults?.setValue(value, forKey: forKey)
}
AsyncFunction("addToStringArrayAsync") { (forKey: String, string: String) in
if var curr = userDefaults?.stringArray(forKey: forKey),
!curr.contains(string) {
!curr.contains(string)
{
curr.append(string)
userDefaults?.setValue(curr, forKey: forKey)
}
}
AsyncFunction("removeFromStringArrayAsync") { (forKey: String, string: String) in
if var curr = userDefaults?.stringArray(forKey: forKey) {
curr.removeAll { s in
@@ -92,7 +93,7 @@ public class ExpoBackgroundNotificationHandlerModule: Module {
userDefaults?.setValue(curr, forKey: forKey)
}
}
AsyncFunction("addManyToStringArrayAsync") { (forKey: String, strings: [String]) in
if var curr = userDefaults?.stringArray(forKey: forKey) {
strings.forEach { s in
@@ -103,7 +104,7 @@ public class ExpoBackgroundNotificationHandlerModule: Module {
userDefaults?.setValue(curr, forKey: forKey)
}
}
AsyncFunction("removeManyFromStringArrayAsync") { (forKey: String, strings: [String]) in
if var curr = userDefaults?.stringArray(forKey: forKey) {
strings.forEach { s in
@@ -112,7 +113,7 @@ public class ExpoBackgroundNotificationHandlerModule: Module {
userDefaults?.setValue(curr, forKey: forKey)
}
}
AsyncFunction("setBadgeCountAsync") { (count: Int) in
userDefaults?.setValue(count, forKey: "badgeCount")
}
@@ -5,10 +5,7 @@ import android.graphics.Canvas
import android.graphics.drawable.Animatable
import androidx.appcompat.widget.AppCompatImageView
class AppCompatImageViewExtended(
context: Context,
private val parent: GifView,
) : AppCompatImageView(context) {
class AppCompatImageViewExtended(context: Context, private val parent: GifView): AppCompatImageView(context) {
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
@@ -37,4 +34,4 @@ class AppCompatImageViewExtended(
drawable.start()
}
}
}
}
@@ -6,50 +6,49 @@ import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoBlueskyGifViewModule : Module() {
override fun definition() =
ModuleDefinition {
Name("ExpoBlueskyGifView")
override fun definition() = ModuleDefinition {
Name("ExpoBlueskyGifView")
AsyncFunction("prefetchAsync") { sources: List<String> ->
val activity = appContext.currentActivity ?: return@AsyncFunction
val glide = Glide.with(activity)
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()
}
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()
}
}
}
}
@@ -1,5 +1,6 @@
package expo.modules.blueskygifview
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.Animatable
@@ -14,10 +15,7 @@ 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) {
class GifView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
// Events
private val onPlayerStateChange by EventDispatcher()
@@ -46,7 +44,8 @@ class GifView(
}
}
// <editor-fold desc="Lifecycle">
//<editor-fold desc="Lifecycle">
init {
this.setBackgroundColor(Color.TRANSPARENT)
@@ -71,82 +70,80 @@ class GifView(
super.onDetachedFromWindow()
}
// </editor-fold>
//</editor-fold>
// <editor-fold desc="Loading">
//<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
}
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 = true
},
).into(this.imageView)
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
}
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 = true
},
).submit()
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
return true
}
})
.submit()
}
}
// </editor-fold>
//</editor-fold>
// <editor-fold desc="Controls">
//<editor-fold desc="Controls">
fun play() {
this.imageView.play()
@@ -168,18 +165,16 @@ class GifView(
}
}
// </editor-fold>
//</editor-fold>
// <editor-fold desc="Util">
//<editor-fold desc="Util">
fun firePlayerStateChange() {
onPlayerStateChange(
mapOf(
"isPlaying" to this.isPlaying,
"isLoaded" to this.isLoaded,
),
)
onPlayerStateChange(mapOf(
"isPlaying" to this.isPlaying,
"isLoaded" to this.isLoaded,
))
}
// </editor-fold>
//</editor-fold>
}
@@ -5,11 +5,11 @@ 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)
}
@@ -18,27 +18,27 @@ public class ExpoBlueskyGifViewModule: Module {
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()
}
@@ -16,14 +16,14 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
)
private var isPlaying = true
private var isLoaded = false
// Requests
private var webpOperation: SDWebImageCombinedOperation?
private var placeholderOperation: SDWebImageCombinedOperation?
// Props
var source: String?
var placeholderSource: String?
var source: String? = nil
var placeholderSource: String? = nil
var autoplay = true {
didSet {
if !autoplay {
@@ -78,7 +78,8 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
// 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) {
let url = URL(string: placeholderSource)
{
self.placeholderOperation = imageManager.loadImage(
with: url,
options: [.retryFailed],
@@ -131,7 +132,8 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
if let placeholderSource = self.placeholderSource,
imageUrl?.absoluteString == placeholderSource,
self.imageView.image == nil,
let image = image {
let image = image
{
self.setImage(image)
return
}
@@ -140,7 +142,8 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
imageUrl?.absoluteString == source,
// UIImage perf suckssss if the image is animated
let data = data,
let animatedImage = SDAnimatedImage(data: data) {
let animatedImage = SDAnimatedImage(data: data)
{
self.placeholderOperation?.cancel()
self.isPlaying = self.autoplay
self.isLoaded = true
@@ -0,0 +1,10 @@
package expo.modules.blueskyswissarmy.deviceprefs
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoBlueskyDevicePrefsModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoBlueskyDevicePrefs")
}
}
@@ -1,103 +1,54 @@
package expo.modules.blueskyswissarmy.referrer
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.Promise
class ExpoBlueskyReferrerModule : Module() {
private var intent: Intent? = null
private var activityReferrer: Uri? = null
override fun definition() = ModuleDefinition {
Name("ExpoBlueskyReferrer")
override fun definition() =
ModuleDefinition {
Name("ExpoBlueskyReferrer")
AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise ->
val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build()
referrerClient.startConnection(object : InstallReferrerStateListener {
override fun onInstallReferrerSetupFinished(responseCode: Int) {
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
Log.d("ExpoGooglePlayReferrer", "Successfully retrieved referrer info.")
OnNewIntent {
intent = it
activityReferrer = appContext.currentActivity?.referrer
}
val response = referrerClient.installReferrer
Log.d("ExpoGooglePlayReferrer", "Install referrer: ${response.installReferrer}")
Function("getReferrerInfo") {
val intentReferrer =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(Intent.EXTRA_REFERRER, Uri::class.java)
} else {
intent?.getParcelableExtra(Intent.EXTRA_REFERRER)
}
// Some apps explicitly set a referrer, like Chrome. In these cases, we prefer this since
// it's the actual website that the user came from rather than the app.
if (intentReferrer is Uri) {
val res =
mapOf(
"referrer" to intentReferrer.toString(),
"hostname" to intentReferrer.host,
)
intent = null
return@Function res
}
// In all other cases, we'll just record the app that sent the intent.
if (activityReferrer != null) {
// referrer could become null here. `.toString()` though can be called on null
val res =
mapOf(
"referrer" to activityReferrer.toString(),
"hostname" to (activityReferrer?.host ?: ""),
)
activityReferrer = null
return@Function res
}
return@Function null
}
AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise ->
val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build()
referrerClient.startConnection(
object : InstallReferrerStateListener {
override fun onInstallReferrerSetupFinished(responseCode: Int) {
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
Log.d("ExpoGooglePlayReferrer", "Successfully retrieved referrer info.")
val response = referrerClient.installReferrer
Log.d("ExpoGooglePlayReferrer", "Install referrer: ${response.installReferrer}")
promise.resolve(
mapOf(
"installReferrer" to response.installReferrer,
"clickTimestamp" to response.referrerClickTimestampSeconds,
"installTimestamp" to response.installBeginTimestampSeconds,
),
)
} else {
Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Unknown error.")
promise.reject(
"ERR_GOOGLE_PLAY_REFERRER_UNKNOWN",
"Failed to get referrer info",
Exception("Failed to get referrer info"),
)
}
referrerClient.endConnection()
}
override fun onInstallReferrerServiceDisconnected() {
Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Service disconnected.")
referrerClient.endConnection()
promise.reject(
"ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED",
"Failed to get referrer info",
Exception("Failed to get referrer info"),
promise.resolve(
mapOf(
"installReferrer" to response.installReferrer,
"clickTimestamp" to response.referrerClickTimestampSeconds,
"installTimestamp" to response.installBeginTimestampSeconds
)
}
},
)
}
)
} else {
Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Unknown error.")
promise.reject(
"ERR_GOOGLE_PLAY_REFERRER_UNKNOWN",
"Failed to get referrer info",
Exception("Failed to get referrer info")
)
}
referrerClient.endConnection()
}
override fun onInstallReferrerServiceDisconnected() {
Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Service disconnected.")
referrerClient.endConnection()
promise.reject(
"ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED",
"Failed to get referrer info",
Exception("Failed to get referrer info")
)
}
})
}
}
}
}
@@ -1,69 +0,0 @@
package expo.modules.blueskyswissarmy.sharedprefs
import android.content.Context
import android.util.Log
import expo.modules.kotlin.jni.JavaScriptValue
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoBlueskySharedPrefsModule : Module() {
private fun getContext(): Context {
val context = appContext.reactContext ?: throw Error("Context is null")
return context
}
override fun definition() =
ModuleDefinition {
Name("ExpoBlueskySharedPrefs")
Function("setString") { key: String, value: String ->
return@Function SharedPrefs(getContext()).setValue(key, value)
}
Function("setValue") { key: String, value: JavaScriptValue ->
val context = getContext()
Log.d("ExpoBlueskySharedPrefs", "Setting value for key: $key")
try {
if (value.isNumber()) {
SharedPrefs(context).setValue(key, value.getFloat())
} else if (value.isBool()) {
SharedPrefs(context).setValue(key, value.getBool())
} else if (value.isNull() || value.isUndefined()) {
SharedPrefs(context).removeValue(key)
} else {
Log.d(NAME, "Unsupported type: ${value.kind()}")
}
} catch (e: Error) {
Log.d(NAME, "Error setting value: $e")
}
}
Function("removeValue") { key: String ->
return@Function SharedPrefs(getContext()).removeValue(key)
}
Function("getString") { key: String ->
return@Function SharedPrefs(getContext()).getString(key)
}
Function("getNumber") { key: String ->
return@Function SharedPrefs(getContext()).getFloat(key)
}
Function("getBool") { key: String ->
return@Function SharedPrefs(getContext()).getBoolean(key)
}
Function("addToSet") { key: String, value: String ->
return@Function SharedPrefs(getContext()).addToSet(key, value)
}
Function("removeFromSet") { key: String, value: String ->
return@Function SharedPrefs(getContext()).removeFromSet(key, value)
}
Function("setContains") { key: String, value: String ->
return@Function SharedPrefs(getContext()).setContains(key, value)
}
}
}
@@ -1,241 +0,0 @@
package expo.modules.blueskyswissarmy.sharedprefs
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
val DEFAULTS =
mapOf<String, Any>(
"playSoundChat" to true,
"playSoundFollow" to false,
"playSoundLike" to false,
"playSoundMention" to false,
"playSoundQuote" to false,
"playSoundReply" to false,
"playSoundRepost" to false,
"badgeCount" to 0,
)
const val NAME = "SharedPrefs"
class SharedPrefs(
private val context: Context,
) {
companion object {
private var hasInitialized = false
private var instance: SharedPreferences? = null
fun getInstance(
context: Context,
info: String? = "(no info)",
): SharedPreferences {
if (instance == null) {
Log.d(NAME, "No preferences instance found, creating one.")
instance = context.getSharedPreferences("xyz.blueskyweb.app", Context.MODE_PRIVATE)
}
val safeInstance = instance ?: throw Error("Preferences is null: $info")
if (!hasInitialized) {
Log.d(NAME, "Preferences instance has not been initialized yet.")
initialize(safeInstance)
hasInitialized = true
Log.d(NAME, "Preferences instance has been initialized.")
}
return safeInstance
}
private fun initialize(instance: SharedPreferences) {
instance
.edit()
.apply {
DEFAULTS.forEach { (key, value) ->
if (instance.contains(key)) {
return@forEach
}
when (value) {
is Boolean -> {
putBoolean(key, value)
}
is String -> {
putString(key, value)
}
is Array<*> -> {
putStringSet(key, value.map { it.toString() }.toSet())
}
is Map<*, *> -> {
putStringSet(key, value.map { it.toString() }.toSet())
}
}
}
}.apply()
}
}
fun setValue(
key: String,
value: String,
) {
val safeInstance = getInstance(context)
safeInstance
.edit()
.apply {
putString(key, value)
}.apply()
}
fun setValue(
key: String,
value: Float,
) {
val safeInstance = getInstance(context)
safeInstance
.edit()
.apply {
putFloat(key, value)
}.apply()
}
fun setValue(
key: String,
value: Boolean,
) {
val safeInstance = getInstance(context)
safeInstance
.edit()
.apply {
putBoolean(key, value)
}.apply()
}
fun setValue(
key: String,
value: Set<String>,
) {
val safeInstance = getInstance(context)
safeInstance
.edit()
.apply {
putStringSet(key, value)
}.apply()
}
fun removeValue(key: String) {
val safeInstance = getInstance(context)
safeInstance
.edit()
.apply {
remove(key)
}.apply()
}
fun getString(key: String): String? {
val safeInstance = getInstance(context)
return safeInstance.getString(key, null)
}
fun getFloat(key: String): Float? {
val safeInstance = getInstance(context)
if (!safeInstance.contains(key)) {
return null
}
return safeInstance.getFloat(key, 0.0f)
}
@Suppress("ktlint:standard:function-naming")
fun _setAnyValue(
key: String,
value: Any,
) {
val safeInstance = getInstance(context)
safeInstance
.edit()
.apply {
when (value) {
is String -> putString(key, value)
is Float -> putFloat(key, value)
is Boolean -> putBoolean(key, value)
is Set<*> -> putStringSet(key, value.map { it.toString() }.toSet())
else -> throw Error("Unsupported type: ${value::class.java}")
}
}.apply()
}
fun getBoolean(key: String): Boolean? {
val safeInstance = getInstance(context)
if (!safeInstance.contains(key)) {
return null
}
Log.d(NAME, "Getting boolean for key: $key")
val res = safeInstance.getBoolean(key, false)
Log.d(NAME, "Got boolean for key: $key, value: $res")
return res
}
fun addToSet(
key: String,
value: String,
) {
val safeInstance = getInstance(context)
val set = safeInstance.getStringSet(key, setOf()) ?: setOf()
val newSet =
set.toMutableSet().apply {
add(value)
}
safeInstance
.edit()
.apply {
putStringSet(key, newSet)
}.apply()
}
fun removeFromSet(
key: String,
value: String,
) {
val safeInstance = getInstance(context)
val set = safeInstance.getStringSet(key, setOf()) ?: setOf()
val newSet =
set.toMutableSet().apply {
remove(value)
}
safeInstance
.edit()
.apply {
putStringSet(key, newSet)
}.apply()
}
fun setContains(
key: String,
value: String,
): Boolean {
val safeInstance = getInstance(context)
val set = safeInstance.getStringSet(key, setOf()) ?: setOf()
return set.contains(value)
}
fun hasValue(key: String): Boolean {
val safeInstance = getInstance(context)
return safeInstance.contains(key)
}
fun getValues(keys: Set<String>): Map<String, Any?> {
val safeInstance = getInstance(context)
return keys.associateWith { key ->
when (val value = safeInstance.all[key]) {
is String -> value
is Float -> value
is Boolean -> value
is Set<*> -> value
else -> null
}
}
}
}
@@ -1,11 +1,11 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"]
"modules": ["ExpoBlueskyDevicePrefsModule", "ExpoBlueskyReferrerModule"]
},
"android": {
"modules": [
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
"expo.modules.blueskyswissarmy.deviceprefs.ExpoBlueskyDevicePrefsModule",
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule"
]
}
+2 -2
View File
@@ -1,4 +1,4 @@
import * as DevicePrefs from './src/DevicePrefs'
import * as Referrer from './src/Referrer'
import * as SharedPrefs from './src/SharedPrefs'
export {Referrer, SharedPrefs}
export {DevicePrefs, Referrer}
@@ -0,0 +1,23 @@
import ExpoModulesCore
public class ExpoBlueskyDevicePrefsModule: Module {
func getDefaults(_ useAppGroup: Bool) -> UserDefaults? {
if useAppGroup {
return UserDefaults(suiteName: "group.app.bsky")
} else {
return UserDefaults.standard
}
}
public func definition() -> ModuleDefinition {
Name("ExpoBlueskyDevicePrefs")
AsyncFunction("getStringValueAsync") { (key: String, useAppGroup: Bool) in
return self.getDefaults(useAppGroup)?.string(forKey: key)
}
AsyncFunction("setStringValueAsync") { (key: String, value: String?, useAppGroup: Bool) in
self.getDefaults(useAppGroup)?.setValue(value, forKey: key)
}
}
}
@@ -1,62 +0,0 @@
import Foundation
import ExpoModulesCore
public class ExpoBlueskySharedPrefsModule: Module {
let defaults = UserDefaults(suiteName: "group.app.bsky")
func getDefaults(_ info: String = "(no info)") -> UserDefaults? {
guard let defaults = self.defaults else {
NSLog("Failed to get defaults for app group: \(info)")
return nil
}
return defaults
}
public func definition() -> ModuleDefinition {
Name("ExpoBlueskySharedPrefs")
// JavaScripValue causes a crash when trying to check `isString()`. Let's
// explicitly define setString instead.
Function("setString") { (key: String, value: String?) in
SharedPrefs.shared.setValue(key, value)
}
Function("setValue") { (key: String, value: JavaScriptValue) in
if value.isNumber() {
SharedPrefs.shared.setValue(key, value.getDouble())
} else if value.isBool() {
SharedPrefs.shared.setValue(key, value.getBool())
} else if value.isNull() || value.isUndefined() {
SharedPrefs.shared.removeValue(key)
}
}
Function("removeValue") { (key: String) in
SharedPrefs.shared.removeValue(key)
}
Function("getString") { (key: String) in
return SharedPrefs.shared.getString(key)
}
Function("getBool") { (key: String) in
return SharedPrefs.shared.getBool(key)
}
Function("getNumber") { (key: String) in
return SharedPrefs.shared.getNumber(key)
}
Function("addToSet") { (key: String, value: String) in
SharedPrefs.shared.addToSet(key, value)
}
Function("removeFromSet") { (key: String, value: String) in
SharedPrefs.shared.removeFromSet(key, value)
}
Function("setContains") { (key: String, value: String) in
return SharedPrefs.shared.setContains(key, value)
}
}
}
@@ -1,89 +0,0 @@
import Foundation
public class SharedPrefs {
public static let shared = SharedPrefs()
private let defaults = UserDefaults(suiteName: "group.app.bsky")
init() {
if defaults == nil {
NSLog("Failed to get user defaults for app group.")
}
}
private func getDefaults(_ info: String = "(no info)") -> UserDefaults? {
guard let defaults = self.defaults else {
NSLog("Failed to get defaults for app group: \(info)")
return nil
}
return defaults
}
public func setValue(_ key: String, _ value: String?) {
getDefaults(key)?.setValue(value, forKey: key)
}
public func setValue(_ key: String, _ value: Double?) {
getDefaults(key)?.setValue(value, forKey: key)
}
public func setValue(_ key: String, _ value: Bool?) {
getDefaults(key)?.setValue(value, forKey: key)
}
public func _setAnyValue(_ key: String, _ value: Any?) {
getDefaults(key)?.setValue(value, forKey: key)
}
public func removeValue(_ key: String) {
getDefaults(key)?.removeObject(forKey: key)
}
public func getString(_ key: String) -> String? {
return getDefaults(key)?.string(forKey: key)
}
public func getNumber(_ key: String) -> Double? {
return getDefaults(key)?.double(forKey: key)
}
public func getBool(_ key: String) -> Bool? {
return getDefaults(key)?.bool(forKey: key)
}
public func addToSet(_ key: String, _ value: String) {
var dict: [String: Bool]?
if var currDict = getDefaults(key)?.dictionary(forKey: key) as? [String: Bool] {
currDict[value] = true
dict = currDict
} else {
dict = [
value: true
]
}
getDefaults(key)?.setValue(dict, forKey: key)
}
public func removeFromSet(_ key: String, _ value: String) {
guard var dict = getDefaults(key)?.dictionary(forKey: key) as? [String: Bool] else {
return
}
dict.removeValue(forKey: value)
getDefaults(key)?.setValue(dict, forKey: key)
}
public func setContains(_ key: String, _ value: String) -> Bool {
guard let dict = getDefaults(key)?.dictionary(forKey: key) as? [String: Bool] else {
return false
}
return dict[value] == true
}
public func hasValue(_ key: String) -> Bool {
return getDefaults(key)?.value(forKey: key) != nil
}
public func getValues(_ keys: [String]) -> [String: Any?]? {
return getDefaults("keys:\(keys)")?.dictionaryWithValues(forKeys: keys)
}
}
@@ -0,0 +1,18 @@
import {requireNativeModule} from 'expo-modules-core'
const NativeModule = requireNativeModule('ExpoBlueskyDevicePrefs')
export function getStringValueAsync(
key: string,
useAppGroup?: boolean,
): Promise<string | null> {
return NativeModule.getStringValueAsync(key, useAppGroup)
}
export function setStringValueAsync(
key: string,
value: string | null,
useAppGroup?: boolean,
): Promise<void> {
return NativeModule.setStringValueAsync(key, value, useAppGroup)
}
@@ -0,0 +1,16 @@
import {NotImplementedError} from '../NotImplemented'
export function getStringValueAsync(
key: string,
useAppGroup?: boolean,
): Promise<string | null> {
throw new NotImplementedError({key, useAppGroup})
}
export function setStringValueAsync(
key: string,
value: string | null,
useAppGroup?: boolean,
): Promise<string | null> {
throw new NotImplementedError({key, value, useAppGroup})
}
@@ -1,13 +1,9 @@
import {requireNativeModule} from 'expo'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
import {GooglePlayReferrerInfo} from './types'
export const NativeModule = requireNativeModule('ExpoBlueskyReferrer')
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo | null> {
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
return NativeModule.getGooglePlayReferrerInfoAsync()
}
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
return NativeModule.getReferrerInfo()
}
@@ -1,37 +0,0 @@
import {SharedPrefs} from '../../index'
import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError()
}
export function getReferrerInfo(): ReferrerInfo | null {
const referrer = SharedPrefs.getString('referrer')
if (referrer) {
SharedPrefs.removeValue('referrer')
try {
const url = new URL(referrer)
return {
referrer,
hostname: url.hostname,
}
} catch (e) {
return {
referrer,
hostname: referrer,
}
}
}
const referrerApp = SharedPrefs.getString('referrerApp')
if (referrerApp) {
SharedPrefs.removeValue('referrerApp')
return {
referrer: referrerApp,
hostname: referrerApp,
}
}
return null
}
@@ -1,10 +1,7 @@
import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
import {GooglePlayReferrerInfo} from './types'
// @ts-ignore throws
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError()
}
export function getReferrerInfo(): ReferrerInfo | null {
throw new NotImplementedError()
}
@@ -1,33 +0,0 @@
import {Platform} from 'react-native'
import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError()
}
export function getReferrerInfo(): ReferrerInfo | null {
if (
Platform.OS === 'web' &&
// for ssr
typeof document !== 'undefined' &&
document != null &&
document.referrer
) {
try {
const url = new URL(document.referrer)
if (url.hostname !== 'bsky.app') {
return {
referrer: url.href,
hostname: url.hostname,
}
}
} catch {
// If something happens to the URL parsing, we don't want to actually cause any problems for the user. Just
// log the error so we might catch it
console.error('Failed to parse referrer URL')
}
}
return null
}
@@ -1,10 +1,7 @@
export type GooglePlayReferrerInfo = {
installReferrer?: string
clickTimestamp?: number
installTimestamp?: number
}
export type ReferrerInfo = {
referrer: string
hostname: string
}
export type GooglePlayReferrerInfo =
| {
installReferrer?: string
clickTimestamp?: number
installTimestamp?: number
}
| undefined
@@ -1,51 +0,0 @@
import {requireNativeModule} from 'expo-modules-core'
const NativeModule = requireNativeModule('ExpoBlueskySharedPrefs')
export function setValue(
key: string,
value: string | number | boolean | null | undefined,
): void {
// A bug on Android causes `JavaScripValue.isString()` to cause a crash on some occasions, seemingly because of a
// memory violation. Instead, we will use a specific function to set strings on this platform.
if (typeof value === 'string') {
return NativeModule.setString(key, value)
}
return NativeModule.setValue(key, value)
}
export function removeValue(key: string): void {
return NativeModule.removeValue(key)
}
export function getString(key: string): string | undefined {
return nullToUndefined(NativeModule.getString(key))
}
export function getNumber(key: string): number | undefined {
return nullToUndefined(NativeModule.getNumber(key))
}
export function getBool(key: string): boolean | undefined {
return nullToUndefined(NativeModule.getBool(key))
}
export function addToSet(key: string, value: string): void {
return NativeModule.addToSet(key, value)
}
export function removeFromSet(key: string, value: string): void {
return NativeModule.removeFromSet(key, value)
}
export function setContains(key: string, value: string): boolean {
return NativeModule.setContains(key, value)
}
// iOS returns `null` if a value does not exist, and Android returns `undefined. Normalize these here for JS types
function nullToUndefined(value: any) {
if (value == null) {
return undefined
}
return value
}
@@ -1,36 +0,0 @@
import {NotImplementedError} from '../NotImplemented'
export function setValue(
key: string,
value: string | number | boolean | null | undefined,
): void {
throw new NotImplementedError({key, value})
}
export function removeValue(key: string): void {
throw new NotImplementedError({key})
}
export function getString(key: string): string | null {
throw new NotImplementedError({key})
}
export function getNumber(key: string): number | null {
throw new NotImplementedError({key})
}
export function getBool(key: string): boolean | null {
throw new NotImplementedError({key})
}
export function addToSet(key: string, value: string): void {
throw new NotImplementedError({key, value})
}
export function removeFromSet(key: string, value: string): void {
throw new NotImplementedError({key, value})
}
export function setContains(key: string, value: string): boolean {
throw new NotImplementedError({key, value})
}
@@ -13,17 +13,16 @@ import java.io.FileOutputStream
import java.net.URLEncoder
class ExpoReceiveAndroidIntentsModule : Module() {
override fun definition() =
ModuleDefinition {
Name("ExpoReceiveAndroidIntents")
override fun definition() = ModuleDefinition {
Name("ExpoReceiveAndroidIntents")
OnNewIntent {
handleIntent(it)
}
OnNewIntent {
handleIntent(it)
}
}
private fun handleIntent(intent: Intent?) {
if (appContext.currentActivity == null || intent == null) return
if(appContext.currentActivity == null || intent == null) return
if (intent.action == Intent.ACTION_SEND) {
if (intent.type == "text/plain") {
@@ -41,7 +40,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleTextIntent(intent: Intent) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
val encoded = URLEncoder.encode(it, "UTF-8")
"bluesky://intent/compose?text=$encoded".toUri().let { uri ->
"bluesky://intent/compose?text=${encoded}".toUri().let { uri ->
val newIntent = Intent(Intent.ACTION_VIEW, uri)
appContext.currentActivity?.startActivity(newIntent)
}
@@ -49,12 +48,11 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
private fun handleImageIntent(intent: Intent) {
val uri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
if (uri == null) return
handleImageIntents(listOf(uri))
@@ -78,16 +76,16 @@ class ExpoReceiveAndroidIntentsModule : Module() {
uris.forEachIndexed { index, uri ->
val info = getImageInfo(uri)
val params = buildUriData(info)
allParams = "${allParams}$params"
allParams = "${allParams}${params}"
if (index < uris.count() - 1) {
allParams = "$allParams,"
allParams = "${allParams},"
}
}
val encoded = URLEncoder.encode(allParams, "UTF-8")
"bluesky://intent/compose?imageUris=$encoded".toUri().let {
"bluesky://intent/compose?imageUris=${encoded}".toUri().let {
val newIntent = Intent(Intent.ACTION_VIEW, it)
appContext.currentActivity?.startActivity(newIntent)
}
@@ -106,7 +104,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
return mapOf(
"width" to bitmap.width,
"height" to bitmap.height,
"path" to file.path.toString(),
"path" to file.path.toString()
)
}
@@ -116,6 +114,6 @@ class ExpoReceiveAndroidIntentsModule : Module() {
val path = info.getValue("path")
val width = info.getValue("width")
val height = info.getValue("height")
return "file://$path|$width|$height"
return "file://${path}|${width}|${height}"
}
}
@@ -3,7 +3,7 @@ import ExpoModulesCore
public class ExpoScrollForwarderModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoScrollForwarder")
View(ExpoScrollForwarderView.self) {
Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
view.scrollViewTag = prop
@@ -8,17 +8,17 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
self.tryFindScrollView()
}
}
private var rctScrollView: RCTScrollView?
private var rctRefreshCtrl: RCTRefreshControl?
private var cancelGestureRecognizers: [UIGestureRecognizer]?
private var animTimer: Timer?
private var initialOffset: CGFloat = 0.0
private var didImpact: Bool = false
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
pg.delegate = self
self.addGestureRecognizer(pg)
@@ -34,27 +34,28 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
self.cancelGestureRecognizers = [lpg, tg]
}
// We don't want to recognize the scroll pan gesture and the swipe back gesture together
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
return false
}
return true
}
// We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
// interfere with the native swipe back gesture.
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
return true
}
let velocity = gestureRecognizer.velocity(in: self)
return abs(velocity.y) > abs(velocity.x)
}
// This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another
// recognizer for this one.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
@@ -63,32 +64,32 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
// This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
// the scroll view gesture's delegate, so we add an additional recognizer to detect this.
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) {
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
self.stopTimer()
}
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) {
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) -> Void {
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
return
}
let translation = sender.translation(in: self).y
if sender.state == .began {
if sv.contentOffset.y < 0 {
sv.contentOffset.y = 0
}
self.initialOffset = sv.contentOffset.y
}
if sender.state == .changed {
sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
if sv.contentOffset.y <= -130, !didImpact {
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
self.didImpact = true
}
}
@@ -96,7 +97,7 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
if sender.state == .ended {
let velocity = sender.velocity(in: self).y
self.didImpact = false
if sv.contentOffset.y <= -130 {
self.rctRefreshCtrl?.forwarderBeginRefreshing()
return
@@ -107,40 +108,40 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
if abs(velocity) < 250, sv.contentOffset.y >= 0 {
return
}
self.startDecayAnimation(translation, velocity)
}
}
func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
guard let sv = self.rctScrollView?.scrollView else {
return
}
var velocity = velocity
self.enableCancelGestureRecognizers()
if velocity > 0 {
velocity = min(velocity, 5000)
} else {
velocity = max(velocity, -5000)
}
var animTranslation = -translation
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { _ in
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { timer in
velocity *= 0.9875
animTranslation = (-velocity / 120) + animTranslation
let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
if nextOffset <= 0 {
if self.initialOffset <= 1 {
self.scrollToOffset(0)
} else {
sv.contentOffset.y = 0
}
self.stopTimer()
return
} else {
@@ -152,60 +153,61 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
}
}
}
func dampenOffset(_ offset: CGFloat) -> CGFloat {
if offset < 0 {
return offset - (offset * 0.55)
}
return offset
}
func tryFindScrollView() {
guard let scrollViewTag = scrollViewTag else {
return
}
// Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer.
// Otherwise we might end up with duplicates when we switch back to that scrollview.
self.removeCancelGestureRecognizers()
self.rctScrollView = self.appContext?
.findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
self.addCancelGestureRecognizers()
}
func addCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.addGestureRecognizer(r)
}
}
func removeCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
}
}
func enableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = true
}
}
func disableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = false
}
}
func scrollToOffset(_ offset: Int, animated: Bool = true) {
func scrollToOffset(_ offset: Int, animated: Bool = true) -> Void {
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
}
func stopTimer() {
func stopTimer() -> Void {
self.disableCancelGestureRecognizers()
self.animTimer?.invalidate()
self.animTimer = nil
+2 -8
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.90.0",
"version": "1.87.0",
"private": true,
"engines": {
"node": ">=18"
@@ -29,12 +29,9 @@
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json",
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
"e2e:metro": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:metro-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:run": "maestro test __e2e__",
"perf:test": "NODE_ENV=test maestro test",
"perf:test:run": "NODE_ENV=test maestro test __e2e__/perf-test.yml",
@@ -52,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "0.12.25",
"@atproto/api": "^0.12.22",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -139,7 +136,6 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
"expo-video": "^1.1.10",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
@@ -170,7 +166,6 @@
"react-dom": "^18.2.0",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.74.1",
"react-native-compressor": "^1.8.24",
"react-native-date-picker": "^4.4.2",
"react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-fs": "^2.20.0",
@@ -198,7 +193,6 @@
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
"rn-fetch-blob": "^0.12.0",
"rn-tourguide": "bluesky-social/rn-tourguide",
"sentry-expo": "~7.0.1",
"statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7",
-41
View File
@@ -1,41 +0,0 @@
const {withAppDelegate} = require('@expo/config-plugins')
const {mergeContents} = require('@expo/config-plugins/build/utils/generateCode')
const path = require('path')
const fs = require('fs')
module.exports = config => {
// eslint-disable-next-line no-shadow
return withAppDelegate(config, async config => {
const delegatePath = path.join(
config.modRequest.platformProjectRoot,
'AppDelegate.mm',
)
let newContents = config.modResults.contents
newContents = mergeContents({
src: newContents,
anchor: '// Linking API',
newSrc: `
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
[defaults setObject:options[UIApplicationOpenURLOptionsSourceApplicationKey] forKey:@"referrerApp"];\n`,
offset: 2,
tag: 'referrer info - deep links',
comment: '//',
}).contents
newContents = mergeContents({
src: newContents,
anchor: '// Universal Links',
newSrc: `
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
[defaults setURL:userActivity.referrerURL forKey:@"referrer"];\n`,
offset: 2,
tag: 'referrer info - universal links',
comment: '//',
}).contents
config.modResults.contents = newContents
return config
})
}
+5 -15
View File
@@ -45,7 +45,6 @@ import {
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
@@ -56,7 +55,6 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
@@ -95,10 +93,7 @@ function InnerApp() {
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(
_(msg`Sorry! Your session expired. Please log in again.`),
'info',
)
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
}, [_])
@@ -122,15 +117,10 @@ function InnerApp() {
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<TourProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
+2 -11
View File
@@ -34,7 +34,6 @@ import {
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import * as Toast from '#/view/com/util/Toast'
@@ -44,7 +43,6 @@ import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
@@ -77,10 +75,7 @@ function InnerApp() {
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(
_(msg`Sorry! Your session expired. Please log in again.`),
'info',
)
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
}, [_])
@@ -107,11 +102,7 @@ function InnerApp() {
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<TourProvider>
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</TourProvider>
<Shell />
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
+3 -35
View File
@@ -31,7 +31,7 @@ import {
} from 'lib/routes/types'
import {RouteParams, State} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings'
import {isAndroid, isNative, isWeb} from 'platform/detection'
import {isAndroid, isNative} from 'platform/detection'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
@@ -39,17 +39,12 @@ import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag'
import {ModerationScreen} from '#/screens/Moderation'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {
StarterPackScreen,
StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen'
import {StarterPackScreen} from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
@@ -76,7 +71,6 @@ import {LogScreen} from './view/screens/Log'
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
import {NotFoundScreen} from './view/screens/NotFound'
import {NotificationsScreen} from './view/screens/Notifications'
import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {PostThreadScreen} from './view/screens/PostThread'
@@ -236,11 +230,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => DebugModScreen}
options={{title: title(msg`Moderation states`), requireAuth: true}}
/>
<Stack.Screen
name="SharedPreferencesTester"
getComponent={() => SharedPreferencesTesterScreen}
options={{title: title(msg`Shared Preferences Tester`)}}
/>
<Stack.Screen
name="Log"
getComponent={() => LogScreen}
@@ -325,11 +314,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Chat settings`), requireAuth: true}}
/>
<Stack.Screen
name="NotificationsSettings"
getComponent={() => NotificationsSettingsScreen}
options={{title: title(msg`Notification settings`), requireAuth: true}}
/>
<Stack.Screen
name="Feeds"
getComponent={() => FeedsScreen}
@@ -338,12 +322,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="StarterPack"
getComponent={() => StarterPackScreen}
options={{title: title(msg`Starter Pack`)}}
/>
<Stack.Screen
name="StarterPackShort"
getComponent={() => StarterPackScreenShort}
options={{title: title(msg`Starter Pack`)}}
options={{title: title(msg`Starter Pack`), requireAuth: true}}
/>
<Stack.Screen
name="StarterPackWizard"
@@ -776,17 +755,6 @@ function logModuleInitTime() {
initMs,
})
if (isWeb) {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
logEvent('deepLink:referrerReceived', {
to: window.location.href,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
}
if (__DEV__) {
// This log is noisy, so keep false committed
const shouldLog = false
-3
View File
@@ -145,9 +145,6 @@ export const atoms = {
flex_shrink: {
flexShrink: 1,
},
flex_shrink_0: {
flexShrink: 0,
},
justify_start: {
justifyContent: 'flex-start',
},
+5 -16
View File
@@ -1,9 +1,7 @@
import React from 'react'
import {Dimensions} from 'react-native'
import {createThemes, defaultTheme} from '#/alf/themes'
import {Theme, ThemeName} from '#/alf/types'
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
import * as themes from '#/alf/themes'
export {atoms} from '#/alf/atoms'
export * as tokens from '#/alf/tokens'
@@ -41,8 +39,8 @@ function getActiveBreakpoints({width}: {width: number}) {
* Context
*/
export const Context = React.createContext<{
themeName: ThemeName
theme: Theme
themeName: themes.ThemeName
theme: themes.Theme
breakpoints: {
active: BreakpointName | undefined
gtPhone: boolean
@@ -51,7 +49,7 @@ export const Context = React.createContext<{
}
}>({
themeName: 'light',
theme: defaultTheme,
theme: themes.light,
breakpoints: {
active: undefined,
gtPhone: false,
@@ -63,16 +61,7 @@ export const Context = React.createContext<{
export function ThemeProvider({
children,
theme: themeName,
}: React.PropsWithChildren<{theme: ThemeName}>) {
const themes = React.useMemo(() => {
return createThemes({
hues: {
primary: BLUE_HUE,
negative: RED_HUE,
positive: GREEN_HUE,
},
})
}, [])
}: React.PropsWithChildren<{theme: themes.ThemeName}>) {
const theme = themes[themeName]
const [breakpoints, setBreakpoints] = React.useState(() =>
getActiveBreakpoints({width: Dimensions.get('window').width}),
+452 -574
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -1,6 +1,77 @@
import {
BLUE_HUE,
generateScale,
GREEN_HUE,
RED_HUE,
} from '#/alf/util/colorGeneration'
export const scale = generateScale(6, 100)
// dim shifted 6% lighter
export const dimScale = generateScale(12, 100)
export const color = {
trueBlack: '#000000',
temp_purple: 'rgb(105 0 255)',
temp_purple_dark: 'rgb(83 0 202)',
gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
gray_100: `hsl(${BLUE_HUE}, 20%, ${scale[11]}%)`,
gray_200: `hsl(${BLUE_HUE}, 20%, ${scale[10]}%)`,
gray_300: `hsl(${BLUE_HUE}, 20%, ${scale[9]}%)`,
gray_400: `hsl(${BLUE_HUE}, 20%, ${scale[8]}%)`,
gray_500: `hsl(${BLUE_HUE}, 20%, ${scale[7]}%)`,
gray_600: `hsl(${BLUE_HUE}, 24%, ${scale[6]}%)`,
gray_700: `hsl(${BLUE_HUE}, 24%, ${scale[5]}%)`,
gray_800: `hsl(${BLUE_HUE}, 28%, ${scale[4]}%)`,
gray_900: `hsl(${BLUE_HUE}, 28%, ${scale[3]}%)`,
gray_950: `hsl(${BLUE_HUE}, 28%, ${scale[2]}%)`,
gray_975: `hsl(${BLUE_HUE}, 28%, ${scale[1]}%)`,
gray_1000: `hsl(${BLUE_HUE}, 28%, ${scale[0]}%)`,
blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
blue_100: `hsl(${BLUE_HUE}, 99%, 90%)`,
blue_200: `hsl(${BLUE_HUE}, 99%, 80%)`,
blue_300: `hsl(${BLUE_HUE}, 99%, 70%)`,
blue_400: `hsl(${BLUE_HUE}, 99%, 60%)`,
blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
blue_600: `hsl(${BLUE_HUE}, 99%, 42%)`,
blue_700: `hsl(${BLUE_HUE}, 99%, 34%)`,
blue_800: `hsl(${BLUE_HUE}, 99%, 26%)`,
blue_900: `hsl(${BLUE_HUE}, 99%, 18%)`,
blue_950: `hsl(${BLUE_HUE}, 99%, 10%)`,
blue_975: `hsl(${BLUE_HUE}, 99%, 7%)`,
green_25: `hsl(${GREEN_HUE}, 82%, 97%)`,
green_50: `hsl(${GREEN_HUE}, 82%, 95%)`,
green_100: `hsl(${GREEN_HUE}, 82%, 90%)`,
green_200: `hsl(${GREEN_HUE}, 82%, 80%)`,
green_300: `hsl(${GREEN_HUE}, 82%, 70%)`,
green_400: `hsl(${GREEN_HUE}, 82%, 60%)`,
green_500: `hsl(${GREEN_HUE}, 82%, 50%)`,
green_600: `hsl(${GREEN_HUE}, 82%, 42%)`,
green_700: `hsl(${GREEN_HUE}, 82%, 34%)`,
green_800: `hsl(${GREEN_HUE}, 82%, 26%)`,
green_900: `hsl(${GREEN_HUE}, 82%, 18%)`,
green_950: `hsl(${GREEN_HUE}, 82%, 10%)`,
green_975: `hsl(${GREEN_HUE}, 82%, 7%)`,
red_25: `hsl(${RED_HUE}, 91%, 97%)`,
red_50: `hsl(${RED_HUE}, 91%, 95%)`,
red_100: `hsl(${RED_HUE}, 91%, 90%)`,
red_200: `hsl(${RED_HUE}, 91%, 80%)`,
red_300: `hsl(${RED_HUE}, 91%, 70%)`,
red_400: `hsl(${RED_HUE}, 91%, 60%)`,
red_500: `hsl(${RED_HUE}, 91%, 50%)`,
red_600: `hsl(${RED_HUE}, 91%, 42%)`,
red_700: `hsl(${RED_HUE}, 91%, 34%)`,
red_800: `hsl(${RED_HUE}, 91%, 26%)`,
red_900: `hsl(${RED_HUE}, 91%, 18%)`,
red_950: `hsl(${RED_HUE}, 91%, 10%)`,
red_975: `hsl(${RED_HUE}, 91%, 7%)`,
} as const
export const space = {
@@ -107,3 +178,10 @@ export const gradients = {
hover_value: '#755B62',
},
} as const
export type Color = keyof typeof color
export type Space = keyof typeof space
export type FontSize = keyof typeof fontSize
export type LineHeight = keyof typeof lineHeight
export type BorderRadius = keyof typeof borderRadius
export type FontWeight = keyof typeof fontWeight
+18 -154
View File
@@ -1,4 +1,21 @@
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
import {StyleProp, ViewStyle, TextStyle} from 'react-native'
type LiteralToCommon<T extends PropertyKey> = T extends number
? number
: T extends string
? string
: T extends symbol
? symbol
: never
/**
* @see https://stackoverflow.com/questions/68249999/use-as-const-in-typescript-without-adding-readonly-modifiers
*/
export type Mutable<T> = {
-readonly [K in keyof T]: T[K] extends PropertyKey
? LiteralToCommon<T[K]>
: Mutable<T[K]>
}
export type TextStyleProp = {
style?: StyleProp<TextStyle>
@@ -7,156 +24,3 @@ export type TextStyleProp = {
export type ViewStyleProp = {
style?: StyleProp<ViewStyle>
}
export type ThemeName = 'light' | 'dim' | 'dark'
export type Palette = {
white: string
black: string
contrast_25: string
contrast_50: string
contrast_100: string
contrast_200: string
contrast_300: string
contrast_400: string
contrast_500: string
contrast_600: string
contrast_700: string
contrast_800: string
contrast_900: string
contrast_950: string
contrast_975: string
primary_25: string
primary_50: string
primary_100: string
primary_200: string
primary_300: string
primary_400: string
primary_500: string
primary_600: string
primary_700: string
primary_800: string
primary_900: string
primary_950: string
primary_975: string
positive_25: string
positive_50: string
positive_100: string
positive_200: string
positive_300: string
positive_400: string
positive_500: string
positive_600: string
positive_700: string
positive_800: string
positive_900: string
positive_950: string
positive_975: string
negative_25: string
negative_50: string
negative_100: string
negative_200: string
negative_300: string
negative_400: string
negative_500: string
negative_600: string
negative_700: string
negative_800: string
negative_900: string
negative_950: string
negative_975: string
}
export type ThemedAtoms = {
text: {
color: string
}
text_contrast_low: {
color: string
}
text_contrast_medium: {
color: string
}
text_contrast_high: {
color: string
}
text_inverted: {
color: string
}
bg: {
backgroundColor: string
}
bg_contrast_25: {
backgroundColor: string
}
bg_contrast_50: {
backgroundColor: string
}
bg_contrast_100: {
backgroundColor: string
}
bg_contrast_200: {
backgroundColor: string
}
bg_contrast_300: {
backgroundColor: string
}
bg_contrast_400: {
backgroundColor: string
}
bg_contrast_500: {
backgroundColor: string
}
bg_contrast_600: {
backgroundColor: string
}
bg_contrast_700: {
backgroundColor: string
}
bg_contrast_800: {
backgroundColor: string
}
bg_contrast_900: {
backgroundColor: string
}
bg_contrast_950: {
backgroundColor: string
}
bg_contrast_975: {
backgroundColor: string
}
border_contrast_low: {
borderColor: string
}
border_contrast_medium: {
borderColor: string
}
border_contrast_high: {
borderColor: string
}
shadow_sm: {
shadowRadius: number
shadowOpacity: number
elevation: number
shadowColor: string
}
shadow_md: {
shadowRadius: number
shadowOpacity: number
elevation: number
shadowColor: string
}
shadow_lg: {
shadowRadius: number
shadowOpacity: number
elevation: number
shadowColor: string
}
}
export type Theme = {
name: ThemeName
palette: Palette
atoms: ThemedAtoms
}
-4
View File
@@ -15,7 +15,3 @@ export function generateScale(start: number, end: number) {
return start + range * stop
})
}
export const defaultScale = generateScale(6, 100)
// dim shifted 6% lighter
export const dimScale = generateScale(12, 100)
+1 -1
View File
@@ -1,4 +1,4 @@
import {ThemeName} from '#/alf/types'
import {ThemeName} from '#/alf/themes'
export function select<T>(name: ThemeName, options: Record<ThemeName, T>) {
switch (name) {
+1 -2
View File
@@ -4,8 +4,7 @@ import * as SystemUI from 'expo-system-ui'
import {isWeb} from 'platform/detection'
import {useThemePrefs} from 'state/shell'
import {dark, dim, light} from '#/alf/themes'
import {ThemeName} from '#/alf/types'
import {dark, dim, light, ThemeName} from '#/alf/themes'
export function useColorModeTheme(): ThemeName {
const theme = useThemeName()
+26 -103
View File
@@ -13,7 +13,7 @@ import {
} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {android, atoms as a, flatten, select, tokens, useTheme} from '#/alf'
import {android, atoms as a, flatten, tokens, useTheme} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
import {normalizeTextStyles} from '#/components/Typography'
@@ -21,7 +21,6 @@ export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
export type ButtonColor =
| 'primary'
| 'secondary'
| 'secondary_inverted'
| 'negative'
| 'gradient_sky'
| 'gradient_midnight'
@@ -152,6 +151,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
@@ -164,11 +164,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
})
} else {
baseStyles.push({
backgroundColor: select(t.name, {
light: t.palette.primary_700,
dim: t.palette.primary_300,
dark: t.palette.primary_300,
}),
backgroundColor: t.palette.primary_700,
})
}
} else if (variant === 'outline') {
@@ -181,18 +177,24 @@ export const Button = React.forwardRef<View, ButtonProps>(
borderColor: t.palette.primary_500,
})
hoverStyles.push(a.border, {
backgroundColor: t.palette.primary_50,
backgroundColor: light
? t.palette.primary_50
: t.palette.primary_950,
})
} else {
baseStyles.push(a.border, {
borderColor: t.palette.primary_200,
borderColor: light
? t.palette.primary_200
: t.palette.primary_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: t.palette.primary_100,
backgroundColor: light
? t.palette.primary_100
: t.palette.primary_900,
})
}
}
@@ -200,63 +202,14 @@ export const Button = React.forwardRef<View, ButtonProps>(
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: select(t.name, {
light: t.palette.contrast_25,
dim: t.palette.contrast_100,
dark: t.palette.contrast_100,
}),
})
hoverStyles.push({
backgroundColor: select(t.name, {
light: t.palette.contrast_50,
dim: t.palette.contrast_200,
dark: t.palette.contrast_200,
}),
})
} else {
baseStyles.push({
backgroundColor: select(t.name, {
light: t.palette.contrast_100,
dim: t.palette.contrast_25,
dark: t.palette.contrast_25,
}),
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.contrast_300,
})
hoverStyles.push(t.atoms.bg_contrast_50)
} else {
baseStyles.push(a.border, {
borderColor: t.palette.contrast_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: t.palette.contrast_25,
})
}
}
} else if (color === 'secondary_inverted') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.contrast_900,
})
hoverStyles.push({
backgroundColor: t.palette.contrast_950,
backgroundColor: t.palette.contrast_50,
})
} else {
baseStyles.push({
backgroundColor: t.palette.contrast_600,
backgroundColor: t.palette.contrast_100,
})
}
} else if (variant === 'outline') {
@@ -293,11 +246,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
})
} else {
baseStyles.push({
backgroundColor: select(t.name, {
light: t.palette.negative_700,
dim: t.palette.negative_300,
dark: t.palette.negative_300,
}),
backgroundColor: t.palette.negative_700,
})
}
} else if (variant === 'outline') {
@@ -310,18 +259,24 @@ export const Button = React.forwardRef<View, ButtonProps>(
borderColor: t.palette.negative_500,
})
hoverStyles.push(a.border, {
backgroundColor: t.palette.negative_50,
backgroundColor: light
? t.palette.negative_50
: t.palette.negative_975,
})
} else {
baseStyles.push(a.border, {
borderColor: t.palette.negative_200,
borderColor: light
? t.palette.negative_200
: t.palette.negative_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: t.palette.negative_100,
backgroundColor: light
? t.palette.negative_100
: t.palette.negative_975,
})
}
}
@@ -389,7 +344,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
const gradient = {
primary: tokens.gradients.sky,
secondary: tokens.gradients.sky,
secondary_inverted: tokens.gradients.sky,
negative: tokens.gradients.sky,
gradient_sky: tokens.gradients.sky,
gradient_midnight: tokens.gradients.midnight,
@@ -489,6 +443,7 @@ export function useSharedButtonTextStyles() {
const {color, variant, disabled, size} = useButtonContext()
return React.useMemo(() => {
const baseStyles: TextStyle[] = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
@@ -500,7 +455,7 @@ export function useSharedButtonTextStyles() {
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: t.palette.primary_600,
color: light ? t.palette.primary_600 : t.palette.primary_500,
})
} else {
baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
@@ -544,38 +499,6 @@ export function useSharedButtonTextStyles() {
})
}
}
} else if (color === 'secondary_inverted') {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({
color: t.palette.contrast_100,
})
} else {
baseStyles.push({
color: t.palette.contrast_400,
})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: t.palette.contrast_600,
})
} else {
baseStyles.push({
color: t.palette.contrast_300,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({
color: t.palette.contrast_600,
})
} else {
baseStyles.push({
color: t.palette.contrast_300,
})
}
}
} else if (color === 'negative') {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
+75 -34
View File
@@ -18,7 +18,7 @@ import {
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {sanitizeHandle} from 'lib/strings/handles'
import {precacheFeedFromGeneratorView} from 'state/queries/feed'
import {precacheFeedFromGeneratorView, precacheList} from 'state/queries/feed'
import {useSession} from 'state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast'
@@ -33,31 +33,45 @@ import * as Prompt from '#/components/Prompt'
import {RichText, RichTextProps} from '#/components/RichText'
import {Text} from '#/components/Typography'
type Props = {
view: AppBskyFeedDefs.GeneratorView
}
type Props =
| {
type: 'feed'
view: AppBskyFeedDefs.GeneratorView
}
| {
type: 'list'
view: AppBskyGraphDefs.ListView
}
export function Default(props: Props) {
const {view} = props
const {type, view} = props
const displayName = type === 'feed' ? view.displayName : view.name
const purpose = type === 'list' ? view.purpose : undefined
return (
<Link label={view.displayName} {...props}>
<Link label={displayName} {...props}>
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline title={view.displayName} creator={view.creator} />
<SaveButton view={view} pin />
<TitleAndByline
title={displayName}
creator={view.creator}
type={type}
purpose={purpose}
/>
<Action uri={view.uri} pin type={type} purpose={purpose} />
</Header>
<Description description={view.description} />
<Likes count={view.likeCount || 0} />
{type === 'feed' && <Likes count={view.likeCount || 0} />}
</Outer>
</Link>
)
}
export function Link({
type,
view,
label,
children,
...props
}: Props & Omit<LinkProps, 'to'>) {
const queryClient = useQueryClient()
@@ -65,19 +79,25 @@ export function Link({
return createProfileFeedHref({feed: view})
}, [view])
React.useEffect(() => {
precacheFeedFromGeneratorView(queryClient, view)
}, [view, queryClient])
return (
<InternalLink to={href} style={[a.flex_col]} {...props}>
<InternalLink
to={href}
label={label}
onPress={() => {
if (type === 'feed') {
precacheFeedFromGeneratorView(queryClient, view)
} else {
precacheList(queryClient, view)
}
}}
style={[a.flex_col]}>
{children}
</InternalLink>
)
}
export function Outer({children}: {children: React.ReactNode}) {
return <View style={[a.w_full, a.gap_md]}>{children}</View>
return <View style={[a.flex_1, a.gap_md]}>{children}</View>
}
export function Header({children}: {children: React.ReactNode}) {
@@ -109,9 +129,13 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
export function TitleAndByline({
title,
creator,
type,
purpose,
}: {
title: string
creator?: AppBskyActorDefs.ProfileViewBasic
type: 'feed' | 'list'
purpose?: AppBskyGraphDefs.ListView['purpose']
}) {
const t = useTheme()
@@ -124,7 +148,15 @@ export function TitleAndByline({
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
{type === 'list' && purpose === 'app.bsky.graph.defs#curatelist' ? (
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
) : type === 'list' && purpose === 'app.bsky.graph.defs#modlist' ? (
<Trans>
Moderation list by {sanitizeHandle(creator.handle, '@')}
</Trans>
) : (
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
)}
</Text>
)}
</View>
@@ -211,24 +243,34 @@ export function Likes({count}: {count: number}) {
)
}
export function SaveButton({
view,
export function Action({
uri,
pin,
type,
purpose,
}: {
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
uri: string
pin?: boolean
type: 'feed' | 'list'
purpose?: AppBskyGraphDefs.ListView['purpose']
}) {
const {hasSession} = useSession()
if (!hasSession) return null
return <SaveButtonInner view={view} pin={pin} />
if (
!hasSession ||
(type === 'list' && purpose !== 'app.bsky.graph.defs#curatelist')
)
return null
return <ActionInner uri={uri} pin={pin} type={type} />
}
function SaveButtonInner({
view,
function ActionInner({
uri,
pin,
type,
}: {
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
uri: string
pin?: boolean
type: 'feed' | 'list'
}) {
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
@@ -236,10 +278,6 @@ function SaveButtonInner({
useAddSavedFeedsMutation()
const {isPending: isRemovePending, mutateAsync: removeFeed} =
useRemoveFeedMutation()
const uri = view.uri
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
const savedFeedConfig = React.useMemo(() => {
return preferences?.savedFeeds?.find(feed => feed.value === uri)
}, [preferences?.savedFeeds, uri])
@@ -264,9 +302,9 @@ function SaveButtonInner({
])
}
Toast.show(_(msg`Feeds updated!`))
} catch (err: any) {
logger.error(err, {context: `FeedCard: failed to update feeds`, pin})
Toast.show(_(msg`Failed to update feeds`), 'xmark')
} catch (e: any) {
logger.error(e, {context: `FeedCard: failed to update feeds`, pin})
Toast.show(_(msg`Failed to update feeds`))
}
},
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
@@ -316,9 +354,12 @@ function SaveButtonInner({
export function createProfileFeedHref({
feed,
}: {
feed: AppBskyFeedDefs.GeneratorView
feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
}) {
const urip = new AtUri(feed.uri)
const type = urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'list'
const handleOrDid = feed.creator.handle || feed.creator.did
return `/profile/${handleOrDid}/feed/${urip.rkey}`
return `/profile/${handleOrDid}/${type === 'feed' ? 'feed' : 'lists'}/${
urip.rkey
}`
}
-460
View File
@@ -1,460 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide'
import * as userActionHistory from '#/state/userActionHistory'
import {SeenPost} from '#/state/userActionHistory'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {ProgressGuideList} from './ProgressGuide/List'
const MOBILE_CARD_WIDTH = 300
function CardOuter({
children,
style,
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<View
style={[
a.w_full,
a.p_lg,
a.rounded_md,
a.border,
t.atoms.bg,
t.atoms.border_contrast_low,
!gtMobile && {
width: MOBILE_CARD_WIDTH,
},
style,
]}>
{children}
</View>
)
}
export function SuggestedFollowPlaceholder() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder />
</ProfileCard.Header>
<View style={[a.py_xs]}>
<ProfileCard.NameAndHandlePlaceholder />
</View>
<ProfileCard.DescriptionPlaceholder />
</CardOuter>
)
}
export function SuggestedFeedsCardPlaceholder() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<FeedCard.Header>
<FeedCard.AvatarPlaceholder />
<FeedCard.TitleAndBylinePlaceholder creator />
</FeedCard.Header>
<FeedCard.DescriptionPlaceholder />
</CardOuter>
)
}
function getRank(seenPost: SeenPost): string {
let tier: string
if (seenPost.feedContext === 'popfriends') {
tier = 'a'
} else if (seenPost.feedContext?.startsWith('cluster')) {
tier = 'b'
} else if (seenPost.feedContext?.startsWith('ntpc')) {
tier = 'c'
} else if (seenPost.feedContext?.startsWith('t-')) {
tier = 'd'
} else if (seenPost.feedContext === 'nettop') {
tier = 'e'
} else {
tier = 'f'
}
let score = Math.round(
Math.log(
1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
),
)
if (seenPost.isFollowedBy || Math.random() > 0.9) {
score *= 2
}
const rank = 100 - score
return `${tier}-${rank}`
}
function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
const rankA = getRank(postA)
const rankB = getRank(postB)
// Yes, we're comparing strings here.
// The "larger" string means a worse rank.
if (rankA > rankB) {
return 1
} else if (rankA < rankB) {
return -1
} else {
return 0
}
}
function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => {
const {likes, follows, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
.map(uri => uri.host)
.filter(did => !follows.includes(did))
const seenDids = seen
.sort(sortSeenPosts)
.map(l => new AtUri(l.uri))
.map(uri => uri.host)
return [...new Set([...likeDids, ...seenDids])].filter(
did => did !== currentAccount?.did,
)
}, [userActionSnapshot, currentAccount])
const {data, isLoading, error} = useProfilesQuery({
handles: dids.slice(0, 16),
})
const profiles = data
? data.profiles.filter(profile => {
return !profile.viewer?.following
})
: []
return {
isLoading,
error,
profiles: profiles.slice(0, 6),
}
}
export function SuggestedFollows() {
const t = useTheme()
const {_} = useLingui()
const {
isLoading: isSuggestionsLoading,
profiles,
error,
} = useExperimentalSuggestedUsersQuery()
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const isLoading = isSuggestionsLoading || !moderationOpts
const maxLength = gtMobile ? 4 : 6
const content = isLoading ? (
Array(maxLength)
.fill(0)
.map((_, i) => (
<View
key={i}
style={[gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}])]}>
<SuggestedFollowPlaceholder />
</View>
))
) : error || !profiles.length ? null : (
<>
{profiles.slice(0, maxLength).map(profile => (
<ProfileCard.Link
key={profile.did}
did={profile.handle}
onPress={() => {
logEvent('feed:interstitial:profileCard:press', {})
}}
style={[
a.flex_1,
gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}]),
]}>
{({hovered, pressed}) => (
<CardOuter
style={[
a.flex_1,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
color="secondary_inverted"
shape="round"
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} />
</ProfileCard.Outer>
</CardOuter>
)}
</ProfileCard.Link>
))}
</>
)
if (error || (!isLoading && profiles.length < 4)) {
logger.debug(`Not enough profiles to show suggested follows`)
return null
}
return (
<View
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
<Text
style={[
a.flex_1,
a.text_lg,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Suggested for you</Trans>
</Text>
<Person fill={t.atoms.text_contrast_low.color} />
</View>
{gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
</View>
<View
style={[
a.flex_row,
a.justify_end,
a.align_center,
a.pt_xs,
a.gap_md,
]}>
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
</InlineLinkText>
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more accounts on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on the Explore page</Trans>
</Text>
<Arrow size="xl" />
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
)}
</View>
)
}
export function SuggestedFeeds() {
const numFeedsToDisplay = 3
const t = useTheme()
const {_} = useLingui()
const {data, isLoading, error} = useGetPopularFeedsQuery({
limit: numFeedsToDisplay,
})
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const feeds = React.useMemo(() => {
const items: AppBskyFeedDefs.GeneratorView[] = []
if (!data) return items
for (const page of data.pages) {
for (const feed of page.feeds) {
items.push(feed)
}
}
return items
}, [data])
const content = isLoading ? (
Array(numFeedsToDisplay)
.fill(0)
.map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
) : error || !feeds ? null : (
<>
{feeds.slice(0, numFeedsToDisplay).map(feed => (
<FeedCard.Link
key={feed.uri}
view={feed}
onPress={() => {
logEvent('feed:interstitial:feedCard:press', {})
}}>
{({hovered, pressed}) => (
<CardOuter
style={[
a.flex_1,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<FeedCard.Outer>
<FeedCard.Header>
<FeedCard.Avatar src={feed.avatar} />
<FeedCard.TitleAndByline
title={feed.displayName}
creator={feed.creator}
/>
</FeedCard.Header>
<FeedCard.Description
description={feed.description}
numberOfLines={3}
/>
</FeedCard.Outer>
</CardOuter>
)}
</FeedCard.Link>
))}
</>
)
return error ? null : (
<View
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
<Text
style={[
a.flex_1,
a.text_lg,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Some other feeds you might like</Trans>
</Text>
<Hashtag fill={t.atoms.text_contrast_low.color} />
</View>
{gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
{content}
<View
style={[
a.flex_row,
a.justify_end,
a.align_center,
a.pt_xs,
a.gap_md,
]}>
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
</InlineLinkText>
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more feeds on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}
style={[a.flex_col]}>
<CardOuter style={[a.flex_1]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on the Explore page</Trans>
</Text>
<Arrow size="xl" />
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
)}
</View>
)
}
export function ProgressGuide() {
const t = useTheme()
const {isDesktop} = useWebMediaQueries()
const guide = useProgressGuide('like-10-and-follow-7')
if (isDesktop) {
return null
}
return guide ? (
<View
style={[
a.border_t,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
a.pb_lg,
]}>
<ProgressGuideList />
</View>
) : null
}
@@ -0,0 +1,198 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Refresh} from '#/components/icons/ArrowRotateCounterClockwise'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
function CardOuter({
children,
style,
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<View
style={[
a.w_full,
a.p_lg,
a.rounded_md,
a.border,
t.atoms.bg_contrast_25,
!gtMobile && {
width: 300,
},
{
borderColor: t.atoms.bg_contrast_25.backgroundColor,
},
style,
]}>
{children}
</View>
)
}
export function SuggestedFollowCardSkeleton() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder />
<ProfileCard.NameAndHandlePlaceholder />
</ProfileCard.Header>
<ProfileCard.DescriptionPlaceholder />
</CardOuter>
)
}
export function ErrorState({retry}: {retry: () => void}) {
const t = useTheme()
return (
<>
<CardOuter>
<CircleInfo size="lg" fill={t.palette.negative_400} />
<Text style={[a.font_bold]}>Whoops, something went wrong :(</Text>
<Button
label={'Retry'}
size="small"
variant="ghost"
color="secondary"
onPress={retry}>
<ButtonText>Retry</ButtonText>
<ButtonIcon icon={Refresh} position="right" />
</Button>
</CardOuter>
<SuggestedFollowCardSkeleton />
<SuggestedFollowCardSkeleton />
</>
)
}
export function FeedSuggestedFollowsCards() {
const t = useTheme()
const {_} = useLingui()
const {
isLoading: isSuggestionsLoading,
data: suggestions,
error,
refetch,
} = useSuggestedFollowsQuery({limit: 6})
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const isLoading = isSuggestionsLoading || !moderationOpts
const maxLength = gtMobile ? 3 : 6
const profiles: AppBskyActorDefs.ProfileViewBasic[] = []
if (suggestions) {
// Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set()
for (const page of suggestions.pages) {
for (const actor of page.actors) {
if (!seen.has(actor.did)) {
seen.add(actor.did)
profiles.push(actor)
}
}
}
}
const content = isLoading ? (
Array(3)
.fill(0)
.map((_, i) => <SuggestedFollowCardSkeleton key={i} />)
) : error || !profiles.length ? (
<ErrorState retry={refetch} />
) : (
<>
{profiles.slice(0, maxLength).map(profile => (
<ProfileCard.Link key={profile.did} did={profile.handle}>
<CardOuter style={[a.flex_1]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
logContext="FeedSuggestedFollowsCard"
shape={gtMobile ? undefined : 'round'}
variant="outline"
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} />
</ProfileCard.Outer>
</CardOuter>
</ProfileCard.Link>
))}
</>
)
return gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
{content}
</View>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more accounts on our explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter
style={[a.flex_1, t.atoms.bg_contrast_25, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on our explore page</Trans>
</Text>
<Arrow size="xl" />
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
)
}
export function FeedSuggestedFollowsInterstitial() {
const t = useTheme()
return (
<View
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
<View style={[a.pt_xl, a.px_lg, a.flex_row, a.gap_md]}>
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
Suggested for you
</Text>
</View>
<FeedSuggestedFollowsCards />
</View>
)
}
+6 -5
View File
@@ -2,14 +2,14 @@ import React from 'react'
import {View} from 'react-native'
import {
atoms as a,
flatten,
TextStyleProp,
useTheme,
atoms as a,
ViewStyleProp,
TextStyleProp,
flatten,
} from '#/alf'
import {Props} from '#/components/icons/common'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
import {Props} from '#/components/icons/common'
export function IconCircle({
icon: Icon,
@@ -32,7 +32,8 @@ export function IconCircle({
{
width: size === 'lg' ? 52 : 64,
height: size === 'lg' ? 52 : 64,
backgroundColor: t.palette.primary_50,
backgroundColor:
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
},
flatten(style),
]}>
+6 -14
View File
@@ -12,7 +12,6 @@ import {Link, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
const AVI_SIZE = 30
const AVI_SIZE_SMALL = 20
const AVI_BORDER = 1
/**
@@ -31,12 +30,10 @@ export function KnownFollowers({
profile,
moderationOpts,
onLinkPress,
minimal,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
onLinkPress?: LinkProps['onPress']
minimal?: boolean
}) {
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
new Map(),
@@ -62,7 +59,6 @@ export function KnownFollowers({
cachedKnownFollowers={cachedKnownFollowers}
moderationOpts={moderationOpts}
onLinkPress={onLinkPress}
minimal={minimal}
/>
)
}
@@ -75,13 +71,11 @@ function KnownFollowersInner({
moderationOpts,
cachedKnownFollowers,
onLinkPress,
minimal,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
onLinkPress?: LinkProps['onPress']
minimal?: boolean
}) {
const t = useTheme()
const {_} = useLingui()
@@ -116,8 +110,6 @@ function KnownFollowersInner({
*/
if (slice.length === 0) return null
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
return (
<Link
label={_(
@@ -128,7 +120,7 @@ function KnownFollowersInner({
style={[
a.flex_1,
a.flex_row,
minimal ? a.gap_sm : a.gap_md,
a.gap_md,
a.align_center,
{marginLeft: -AVI_BORDER},
]}>
@@ -137,8 +129,8 @@ function KnownFollowersInner({
<View
style={[
{
height: SIZE,
width: SIZE + (slice.length - 1) * a.gap_md.gap,
height: AVI_SIZE,
width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap,
},
pressed && {
opacity: 0.5,
@@ -153,14 +145,14 @@ function KnownFollowersInner({
{
borderWidth: AVI_BORDER,
borderColor: t.atoms.bg.backgroundColor,
width: SIZE + AVI_BORDER * 2,
height: SIZE + AVI_BORDER * 2,
width: AVI_SIZE + AVI_BORDER * 2,
height: AVI_SIZE + AVI_BORDER * 2,
left: i * a.gap_md.gap,
zIndex: AVI_BORDER - i,
},
]}>
<UserAvatar
size={SIZE}
size={AVI_SIZE}
avatar={prof.avatar}
moderation={moderation.ui('avatar')}
/>
+1 -48
View File
@@ -1,10 +1,5 @@
import React from 'react'
import {
GestureResponderEvent,
Pressable,
StyleProp,
ViewStyle,
} from 'react-native'
import {GestureResponderEvent} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions, useLinkProps} from '@react-navigation/native'
@@ -328,45 +323,3 @@ export function InlineLinkText({
</Text>
)
}
/**
* A Pressable that uses useLink to handle navigation. It is unstyled, so can be used in cases where the Button styles
* in Link are not desired.
* @param displayText
* @param style
* @param children
* @param rest
* @constructor
*/
export function BaseLink({
displayText,
onPress: onPressOuter,
style,
children,
...rest
}: {
style?: StyleProp<ViewStyle>
children: React.ReactNode
to: string
action: 'push' | 'replace' | 'navigate'
onPress?: () => false | void
shareOnLongPress?: boolean
label: string
displayText?: string
}) {
const {onPress, ...btnProps} = useLink({
displayText: displayText ?? rest.to,
...rest,
})
return (
<Pressable
style={style}
onPress={e => {
onPressOuter?.()
onPress(e)
}}
{...btnProps}>
{children}
</Pressable>
)
}
-129
View File
@@ -1,129 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
import {precacheList} from 'state/queries/feed'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {
Avatar,
Description,
Header,
Outer,
SaveButton,
} from '#/components/FeedCard'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
/*
* This component is based on `FeedCard` and is tightly coupled with that
* component. Please refer to `FeedCard` for more context.
*/
export {
Avatar,
AvatarPlaceholder,
Description,
Header,
Outer,
SaveButton,
TitleAndBylinePlaceholder,
} from '#/components/FeedCard'
const CURATELIST = 'app.bsky.graph.defs#curatelist'
const MODLIST = 'app.bsky.graph.defs#modlist'
type Props = {
view: AppBskyGraphDefs.ListView
showPinButton?: boolean
}
export function Default(props: Props) {
const {view, showPinButton} = props
return (
<Link label={view.name} {...props}>
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline
title={view.name}
creator={view.creator}
purpose={view.purpose}
/>
{showPinButton && view.purpose === CURATELIST && (
<SaveButton view={view} pin />
)}
</Header>
<Description description={view.description} />
</Outer>
</Link>
)
}
export function Link({
view,
children,
...props
}: Props & Omit<LinkProps, 'to'>) {
const queryClient = useQueryClient()
const href = React.useMemo(() => {
return createProfileListHref({list: view})
}, [view])
React.useEffect(() => {
precacheList(queryClient, view)
}, [view, queryClient])
return (
<InternalLink to={href} {...props}>
{children}
</InternalLink>
)
}
export function TitleAndByline({
title,
creator,
purpose = CURATELIST,
}: {
title: string
creator?: AppBskyActorDefs.ProfileViewBasic
purpose?: AppBskyGraphDefs.ListView['purpose']
}) {
const t = useTheme()
return (
<View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
{title}
</Text>
{creator && (
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{purpose === MODLIST ? (
<Trans>
Moderation list by {sanitizeHandle(creator.handle, '@')}
</Trans>
) : (
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
)}
</Text>
)}
</View>
)
}
export function createProfileListHref({
list,
}: {
list: AppBskyGraphDefs.ListView
}) {
const urip = new AtUri(list.uri)
const handleOrDid = list.creator.handle || list.creator.did
return `/profile/${handleOrDid}/lists/${urip.rkey}`
}
+1 -1
View File
@@ -189,7 +189,7 @@ let ListMaybePlaceholder = ({
return (
<Error
title={errorTitle ?? _(msg`Oops!`)}
message={errorMessage ?? _(msg`Something went wrong!`)}
message={errorMessage ?? _(`Something went wrong!`)}
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
-169
View File
@@ -1,169 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {BSKY_LABELER_DID, ModerationCause} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import {Button} from '#/components/Button'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export type CommonProps = {
size?: 'sm' | 'lg'
}
export function Row({
children,
style,
size = 'sm',
}: {children: React.ReactNode | React.ReactNode[]} & CommonProps &
ViewStyleProp) {
const styles = React.useMemo(() => {
switch (size) {
case 'lg':
return [{gap: 5}]
case 'sm':
default:
return [{gap: 3}]
}
}, [size])
return (
<View style={[a.flex_row, a.flex_wrap, a.gap_xs, styles, style]}>
{children}
</View>
)
}
export type LabelProps = {
cause: ModerationCause
disableDetailsDialog?: boolean
noBg?: boolean
} & CommonProps
export function Label({
cause,
size = 'sm',
disableDetailsDialog,
noBg,
}: LabelProps) {
const t = useTheme()
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
const isLabeler = Boolean(desc.sourceType && desc.sourceDid)
const isBlueskyLabel =
desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID
const {outer, avi, text} = React.useMemo(() => {
switch (size) {
case 'lg': {
return {
outer: [
t.atoms.bg_contrast_25,
{
gap: 5,
paddingHorizontal: 5,
paddingVertical: 5,
},
],
avi: 16,
text: [a.text_sm],
}
}
case 'sm':
default: {
return {
outer: [
!noBg && t.atoms.bg_contrast_25,
{
gap: 3,
paddingHorizontal: 3,
paddingVertical: 3,
},
],
avi: 12,
text: [a.text_xs],
}
}
}
}, [t, size, noBg])
return (
<>
<Button
disabled={disableDetailsDialog}
label={desc.name}
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.rounded_full,
outer,
(hovered || pressed) && t.atoms.bg_contrast_50,
]}>
{isBlueskyLabel || !isLabeler ? (
<desc.icon
width={avi}
fill={t.atoms.text_contrast_medium.color}
/>
) : (
<UserAvatar avatar={desc.sourceAvi} size={avi} />
)}
<Text
style={[
text,
a.font_semibold,
a.leading_tight,
t.atoms.text_contrast_medium,
{paddingRight: 3},
]}>
{desc.name}
</Text>
</View>
)}
</Button>
{!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
)}
</>
)
}
export function FollowsYou({size = 'sm'}: CommonProps) {
const t = useTheme()
const variantStyles = React.useMemo(() => {
switch (size) {
case 'sm':
case 'lg':
default:
return [
{
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
},
]
}
}, [size])
return (
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_25]}>
<Text style={[a.text_xs, a.leading_tight]}>
<Trans>Follows You</Trans>
</Text>
</View>
)
}
+11 -42
View File
@@ -62,11 +62,7 @@ export function Card({
<Header>
<Avatar profile={profile} moderationOpts={moderationOpts} />
<NameAndHandle profile={profile} moderationOpts={moderationOpts} />
<FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext={logContext}
/>
<FollowButton profile={profile} logContext={logContext} />
</Header>
<ProfileCardPills
@@ -84,7 +80,7 @@ export function Outer({
}: {
children: React.ReactElement | React.ReactElement[]
}) {
return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
return <View style={[a.flex_1, a.gap_xs]}>{children}</View>
}
export function Header({
@@ -95,20 +91,14 @@ export function Header({
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
}
export function Link({
did,
children,
style,
...rest
}: {did: string} & Omit<LinkProps, 'to'>) {
export function Link({did, children}: {did: string} & Omit<LinkProps, 'to'>) {
return (
<InternalLink
to={{
screen: 'Profile',
params: {name: did},
}}
style={[a.flex_col, style]}
{...rest}>
style={[a.flex_col]}>
{children}
</InternalLink>
)
@@ -166,9 +156,7 @@ export function NameAndHandle({
return (
<View style={[a.flex_1]}>
<Text
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
{name}
</Text>
<Text
@@ -267,7 +255,6 @@ export function DescriptionPlaceholder() {
export type FollowButtonProps = {
profile: AppBskyActorDefs.ProfileViewBasic
moderationOpts: ModerationOpts
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
} & Partial<ButtonProps>
@@ -280,13 +267,11 @@ export function FollowButton(props: FollowButtonProps) {
export function FollowButtonInner({
profile: profileUnshadowed,
moderationOpts,
logContext,
...rest
}: FollowButtonProps) {
const {_} = useLingui()
const profile = useProfileShadow(profileUnshadowed)
const moderation = moderateProfile(profile, moderationOpts)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
logContext,
@@ -298,17 +283,9 @@ export function FollowButtonInner({
e.stopPropagation()
try {
await queueFollow()
Toast.show(
_(
msg`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)}`,
),
)
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
} catch (e: any) {
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
}
@@ -318,17 +295,9 @@ export function FollowButtonInner({
e.stopPropagation()
try {
await queueUnfollow()
Toast.show(
_(
msg`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)}`,
),
)
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
} catch (e: any) {
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
}
@@ -29,10 +29,10 @@ import {
} from '#/components/KnownFollowers'
import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Pills from '#/components/Pills'
import {Portal} from '#/components/Portal'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {ProfileLabel} from '../moderation/ProfileHeaderAlerts'
import {ProfileHoverCardProps} from './types'
const floatingMiddlewares = [
@@ -462,8 +462,7 @@ function Inner({
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
<View style={[a.pb_sm, a.flex_1]}>
<Text
style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold, a.self_start]}>
<Text style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
@@ -477,9 +476,8 @@ function Inner({
{isBlockedUser && (
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{moderation.ui('profileView').alerts.map(cause => (
<Pills.Label
<ProfileLabel
key={getModerationCauseKey(cause)}
size="lg"
cause={cause}
disableDetailsDialog
/>
-61
View File
@@ -1,61 +0,0 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
useProgressGuide,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import {ProgressGuideTask} from './Task'
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
const t = useTheme()
const {_} = useLingui()
const guide = useProgressGuide('like-10-and-follow-7')
const {endProgressGuide} = useProgressGuideControls()
if (guide) {
return (
<View style={[a.flex_col, a.gap_md, style]}>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<Text
style={[
t.atoms.text_contrast_medium,
a.font_semibold,
a.text_sm,
{textTransform: 'uppercase'},
]}>
<Trans>Getting started</Trans>
</Text>
<Button
variant="ghost"
size="tiny"
color="secondary"
shape="round"
label={_(msg`Dismiss getting started guide`)}
onPress={endProgressGuide}>
<ButtonIcon icon={Times} size="sm" />
</Button>
</View>
<ProgressGuideTask
current={guide.numLikes + 1}
total={10 + 1}
title={_(msg`Like 10 posts`)}
subtitle={_(msg`Teach our algorithm what you like`)}
/>
<ProgressGuideTask
current={guide.numFollows + 1}
total={7 + 1}
title={_(msg`Follow 7 accounts`)}
subtitle={_(msg`Bluesky is better with friends!`)}
/>
</View>
)
}
return null
}
-50
View File
@@ -1,50 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import * as Progress from 'react-native-progress'
import {atoms as a, useTheme} from '#/alf'
import {AnimatedCheck} from '../anim/AnimatedCheck'
import {Text} from '../Typography'
export function ProgressGuideTask({
current,
total,
title,
subtitle,
}: {
current: number
total: number
title: string
subtitle?: string
}) {
const t = useTheme()
return (
<View style={[a.flex_row, a.gap_sm, !subtitle && a.align_center]}>
{current === total ? (
<AnimatedCheck playOnMount fill={t.palette.primary_500} width={20} />
) : (
<Progress.Circle
progress={current / total}
color={t.palette.primary_400}
size={20}
thickness={3}
borderWidth={0}
unfilledColor={t.palette.contrast_50}
/>
)}
<View style={[a.flex_col, a.gap_2xs, {marginTop: -2}]}>
<Text style={[a.text_sm, a.font_semibold, a.leading_tight]}>
{title}
</Text>
{subtitle && (
<Text
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}>
{subtitle}
</Text>
)}
</View>
</View>
)
}
-169
View File
@@ -1,169 +0,0 @@
import React, {useImperativeHandle} from 'react'
import {Pressable, useWindowDimensions, View} from 'react-native'
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Portal} from '#/components/Portal'
import {AnimatedCheck, AnimatedCheckRef} from '../anim/AnimatedCheck'
import {Text} from '../Typography'
export interface ProgressGuideToastRef {
open(): void
close(): void
}
export interface ProgressGuideToastProps {
title: string
subtitle?: string
visibleDuration?: number // default 5s
}
export const ProgressGuideToast = React.forwardRef<
ProgressGuideToastRef,
ProgressGuideToastProps
>(function ProgressGuideToast({title, subtitle, visibleDuration}, ref) {
const t = useTheme()
const {_} = useLingui()
const insets = useSafeAreaInsets()
const [isOpen, setIsOpen] = React.useState(false)
const translateY = useSharedValue(0)
const opacity = useSharedValue(0)
const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null)
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>()
const winDim = useWindowDimensions()
/**
* Methods
*/
const close = React.useCallback(() => {
// clear the timeout, in case this was called imperatively
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = undefined
}
// animate the opacity then set isOpen to false when done
const setIsntOpen = () => setIsOpen(false)
opacity.value = withTiming(
0,
{
duration: 400,
easing: Easing.out(Easing.cubic),
},
() => runOnJS(setIsntOpen)(),
)
}, [setIsOpen, opacity])
const open = React.useCallback(() => {
// set isOpen=true to render
setIsOpen(true)
// animate the vertical translation, the opacity, and the checkmark
const playCheckmark = () => animatedCheckRef.current?.play()
opacity.value = 0
opacity.value = withTiming(
1,
{
duration: 100,
easing: Easing.out(Easing.cubic),
},
() => runOnJS(playCheckmark)(),
)
translateY.value = 0
translateY.value = withTiming(insets.top + 10, {
duration: 500,
easing: Easing.out(Easing.cubic),
})
// start the countdown timer to autoclose
timeoutRef.current = setTimeout(close, visibleDuration || 5e3)
}, [setIsOpen, translateY, opacity, insets, close, visibleDuration])
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
)
const containerStyle = React.useMemo(() => {
let left = 10
let right = 10
if (isWeb && winDim.width > 400) {
left = right = (winDim.width - 380) / 2
}
return {
position: isWeb ? 'fixed' : 'absolute',
top: 0,
left,
right,
}
}, [winDim.width])
const animatedStyle = useAnimatedStyle(() => ({
transform: [{translateY: translateY.value}],
opacity: opacity.value,
}))
return (
isOpen && (
<Portal>
<Animated.View
style={[
// @ts-ignore position: fixed is web only
containerStyle,
animatedStyle,
]}>
<Pressable
style={[
t.atoms.bg,
a.flex_row,
a.align_center,
a.gap_md,
a.border,
t.atoms.border_contrast_high,
a.rounded_md,
a.px_lg,
a.py_md,
a.shadow_sm,
{
shadowRadius: 8,
shadowOpacity: 0.1,
shadowOffset: {width: 0, height: 2},
elevation: 8,
},
]}
onPress={close}
accessibilityLabel={_(msg`Tap to dismiss`)}
accessibilityHint="">
<AnimatedCheck
fill={t.palette.primary_500}
ref={animatedCheckRef}
/>
<View>
<Text style={[a.text_md, a.font_semibold]}>{title}</Text>
{subtitle && (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{subtitle}
</Text>
)}
</View>
</Pressable>
</Animated.View>
</Portal>
)
)
})
+9 -26
View File
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {useGate} from '#/lib/statsig/statsig'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
@@ -37,7 +36,6 @@ export function SubmitView({
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
const gate = useGate()
const [details, setDetails] = React.useState<string>('')
const [submitting, setSubmitting] = React.useState<boolean>(false)
const [selectedServices, setSelectedServices] = React.useState<string[]>([
@@ -62,29 +60,15 @@ export function SubmitView({
reason: details,
}
const results = await Promise.all(
selectedServices.map(did => {
if (gate('session_withproxy_fix')) {
return agent
.createModerationReport(report, {
encoding: 'application/json',
headers: {
'atproto-proxy': `${did}#atproto_labeler`,
},
})
.then(
_ => true,
_ => false,
)
} else {
return agent
.withProxy('atproto_labeler', did)
.createModerationReport(report)
.then(
_ => true,
_ => false,
)
}
}),
selectedServices.map(did =>
agent
.withProxy('atproto_labeler', did)
.createModerationReport(report)
.then(
_ => true,
_ => false,
),
),
)
setSubmitting(false)
@@ -108,7 +92,6 @@ export function SubmitView({
onSubmitComplete,
setError,
agent,
gate,
])
return (
@@ -45,7 +45,7 @@ export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
(isWeb || index !== 0) && a.border_t,
t.atoms.border_contrast_low,
]}>
<FeedCard.Default view={item} />
<FeedCard.Default type="feed" view={item} />
</View>
)
}
@@ -9,14 +9,11 @@ import {
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
import {isNative, isWeb} from 'platform/detection'
import {useListMembersQuery} from 'state/queries/list-members'
import {useSession} from 'state/session'
import {List, ListRef} from 'view/com/util/List'
import {SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
@@ -35,21 +32,22 @@ interface ProfilesListProps {
export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
function ProfilesListImpl(
{listUri, moderationOpts, headerHeight, scrollElRef},
{listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef},
ref,
) {
const t = useTheme()
const [initialHeaderHeight] = React.useState(headerHeight)
const bottomBarOffset = useBottomBarOffset(20)
const {currentAccount} = useSession()
const {data, refetch, isError} = useListMembersQuery(listUri, 50)
const [isPTRing, setIsPTRing] = React.useState(false)
const {data, refetch} = listMembersQuery
// The server returns these sorted by descending creation date, so we want to invert
const profiles = data?.pages
.flatMap(p => p.items.map(i => i.subject))
.filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler)
.filter(p => !p.associated?.labeler)
.reverse()
const isOwn = new AtUri(listUri).host === currentAccount?.did
@@ -97,19 +95,7 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
)
}
if (!data) {
return (
<View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}>
<ListMaybePlaceholder
isLoading={true}
isError={isError}
onRetry={refetch}
/>
</View>
)
}
if (data)
if (listMembersQuery)
return (
<List
data={getSortedProfiles()}
+1 -1
View File
@@ -110,7 +110,7 @@ export function QrCodeInner({link}: {link: string}) {
innerEyesOptions={{borderRadius: 3}}
logo={{
href: require('../../../assets/logo.png'),
scale: 0.95,
scale: 1.2,
padding: 2,
hidePieces: true,
}}
+1 -4
View File
@@ -67,10 +67,7 @@ export function QrCodeDialog({
try {
await createAssetAsync(`file://${uri}`)
} catch (e: unknown) {
Toast.show(
_(msg`An error occurred while saving the QR code!`),
'xmark',
)
Toast.show(_(msg`An error occurred while saving the QR code!`))
logger.error('Failed to save QR code', {error: e})
return
}
+1 -2
View File
@@ -66,7 +66,6 @@ function ShareDialogInner({
if (!res) {
Toast.show(
_(msg`You must grant access to your photo library to save the image.`),
'xmark',
)
return
}
@@ -76,7 +75,7 @@ function ShareDialogInner({
Toast.show(_(msg`Image saved to your camera roll!`))
control.close()
} catch (e: unknown) {
Toast.show(_(msg`An error occurred while saving the QR code!`), 'xmark')
Toast.show(_(msg`An error occurred while saving the QR code!`))
logger.error('Failed to save QR code', {error: e})
return
}
+10 -50
View File
@@ -1,20 +1,15 @@
import React from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {precacheResolvedUri} from 'state/queries/resolve-uri'
import {precacheStarterPack} from 'state/queries/starter-packs'
import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf'
import {StarterPack} from '#/components/icons/StarterPack'
import {BaseLink} from '#/components/Link'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
@@ -93,13 +88,10 @@ export function Card({
export function Link({
starterPack,
children,
...rest
}: {
starterPack: StarterPackViewBasic
onPress?: () => void
children: React.ReactNode
}) {
const {_} = useLingui()
const queryClient = useQueryClient()
} & Omit<LinkProps, 'to'>) {
const {record} = starterPack
const {rkey, handleOrDid} = React.useMemo(() => {
const rkey = new AtUri(starterPack.uri).rkey
@@ -112,46 +104,14 @@ export function Link({
}
return (
<BaseLink
action="push"
to={`/starter-pack/${handleOrDid}/${rkey}`}
label={_(msg`Navigate to ${record.name}`)}
onPress={() => {
precacheResolvedUri(
queryClient,
starterPack.creator.handle,
starterPack.creator.did,
)
precacheStarterPack(queryClient, starterPack)
<InternalLink
label={record.name}
{...rest}
to={{
screen: 'StarterPack',
params: {name: handleOrDid, rkey},
}}>
{children}
</BaseLink>
)
}
export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) {
const t = useTheme()
const imageUri = getStarterPackOgCard(starterPack)
return (
<View
style={[
a.mt_xs,
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<Link starterPack={starterPack}>
<Image
source={imageUri}
style={[a.w_full, {aspectRatio: 1.91}]}
accessibilityIgnoresInvertColors={true}
/>
<View style={[a.px_sm, a.py_md]}>
<Card starterPack={starterPack} />
</View>
</Link>
</View>
</InternalLink>
)
}
@@ -78,13 +78,7 @@ function WizardListCard({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
style={[
a.flex_1,
a.font_bold,
a.text_md,
a.leading_tight,
a.self_start,
]}
style={[a.flex_1, a.font_bold, a.text_md, a.leading_tight]}
numberOfLines={1}>
{displayName}
</Text>
-1
View File
@@ -115,7 +115,6 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
logger.error('Failed to edit threadgate', {message: err})
}
-92
View File
@@ -1,92 +0,0 @@
import React from 'react'
import Animated, {
Easing,
useAnimatedProps,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated'
import Svg, {Circle, Path} from 'react-native-svg'
import {Props, useCommonSVGProps} from '#/components/icons/common'
const AnimatedPath = Animated.createAnimatedComponent(Path)
const AnimatedCircle = Animated.createAnimatedComponent(Circle)
const PATH = 'M14.1 27.2l7.1 7.2 16.7-16.8'
export interface AnimatedCheckRef {
play(cb?: () => void): void
}
export interface AnimatedCheckProps extends Props {
playOnMount?: boolean
}
export const AnimatedCheck = React.forwardRef<
AnimatedCheckRef,
AnimatedCheckProps
>(function AnimatedCheck({playOnMount, ...props}, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
const circleAnim = useSharedValue(0)
const checkAnim = useSharedValue(0)
const circleAnimatedProps = useAnimatedProps(() => ({
strokeDashoffset: 166 - circleAnim.value * 166,
}))
const checkAnimatedProps = useAnimatedProps(() => ({
strokeDashoffset: 48 - 48 * checkAnim.value,
}))
const play = React.useCallback(
(cb?: () => void) => {
circleAnim.value = 0
checkAnim.value = 0
circleAnim.value = withTiming(1, {duration: 500, easing: Easing.linear})
checkAnim.value = withDelay(
500,
withTiming(1, {duration: 300, easing: Easing.linear}, cb),
)
},
[circleAnim, checkAnim],
)
React.useImperativeHandle(ref, () => ({
play,
}))
React.useEffect(() => {
if (playOnMount) {
play()
}
}, [play, playOnMount])
return (
<Svg
fill="none"
{...rest}
viewBox="0 0 52 52"
width={size}
height={size}
style={style}>
<AnimatedCircle
animatedProps={circleAnimatedProps}
cx="26"
cy="26"
r="24"
fill="none"
stroke={fill}
strokeWidth={4}
strokeDasharray={166}
/>
<AnimatedPath
animatedProps={checkAnimatedProps}
stroke={fill}
d={PATH}
strokeWidth={4}
strokeDasharray={48}
/>
</Svg>
)
})
+4 -3
View File
@@ -357,11 +357,12 @@ function TargetToggle({children}: React.PropsWithChildren<{}>) {
a.px_sm,
gtMobile && a.px_md,
a.rounded_sm,
t.atoms.bg_contrast_25,
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_50,
t.atoms.bg_contrast_50,
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
ctx.selected && [
{
backgroundColor: t.palette.primary_50,
backgroundColor:
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
},
],
ctx.disabled && {
+4 -1
View File
@@ -196,7 +196,10 @@ function Selectable({
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
backgroundColor:
t.name === 'light'
? t.palette.primary_50
: t.palette.primary_975,
},
style,
]}>
+1 -1
View File
@@ -85,7 +85,7 @@ let ConvoMenu = ({
}
},
onError: () => {
Toast.show(_(msg`Could not mute chat`), 'xmark')
Toast.show(_(msg`Could not mute chat`))
},
})
+1 -1
View File
@@ -36,7 +36,7 @@ export function LeaveConvoPrompt({
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`), 'xmark')
Toast.show(_(msg`Could not leave chat`))
},
})
+8 -2
View File
@@ -72,7 +72,8 @@ let MessageItem = ({
lastInGroupRef.current = isLastInGroup
}
const pendingColor = t.palette.primary_200
const pendingColor =
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
const rt = useMemo(() => {
return new RichTextAPI({text: message.text, facets: message.facets})
@@ -109,7 +110,12 @@ let MessageItem = ({
}>
<RichText
value={rt}
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
style={[
a.text_md,
isFromSelf && {color: t.palette.white},
isPending &&
t.name !== 'light' && {color: t.palette.primary_300},
]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
+1 -1
View File
@@ -54,7 +54,7 @@ export let MessageMenu = ({
)
Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
Toast.show(_(msg`Copied to clipboard`))
}, [_, message.text, message.facets])
const onPressTranslateMessage = React.useCallback(() => {
+2 -7
View File
@@ -168,12 +168,7 @@ function HeaderReady({
</View>
<View style={a.flex_1}>
<Text
style={[
a.text_md,
a.font_bold,
a.self_start,
web(a.leading_normal),
]}
style={[a.text_md, a.font_bold, web(a.leading_normal)]}
numberOfLines={1}>
{displayName}
</Text>
@@ -219,7 +214,7 @@ function HeaderReady({
]}>
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
size="large"
style={[a.pt_xs]}
/>
</View>
+1 -1
View File
@@ -57,7 +57,7 @@ function DialogInner({
const [initialized, setInitialzed] = React.useState(false)
const {mutate: updateDeclaration} = useUpdateActorDeclaration({
onError: () => {
Toast.show(_(msg`Failed to update settings`), 'xmark')
Toast.show(_(msg`Failed to update settings`))
},
})
+1 -3
View File
@@ -2,7 +2,6 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import {FAB} from '#/view/com/util/fab/FAB'
@@ -32,8 +31,7 @@ export function NewChat({
logEvent('chat:open', {logContext: 'NewChatDialog'})
},
onError: error => {
logger.error('Failed to create chat', {safeMessage: error})
Toast.show(_(msg`An issue occurred starting the chat`), 'xmark')
Toast.show(error.message)
},
})
@@ -395,7 +395,7 @@ function ProfileCard({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
style={[t.atoms.text, a.font_bold, a.leading_tight]}
numberOfLines={1}>
{displayName}
</Text>
@@ -2,7 +2,6 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import * as Toast from '#/view/com/util/Toast'
@@ -44,11 +43,7 @@ function SendViaChatDialogInner({
logEvent('chat:open', {logContext: 'SendViaChatDialog'})
},
onError: error => {
logger.error('Failed to share post to chat', {message: error})
Toast.show(
_(msg`An issue occurred while trying to open the chat`),
'xmark',
)
Toast.show(error.message)
},
})
@@ -0,0 +1,175 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyFeedDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Refresh} from '#/components/icons/ArrowRotateCounterClockwise'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Text} from '#/components/Typography'
function CardOuter({
children,
style,
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<View
style={[
a.w_full,
a.p_lg,
a.rounded_md,
a.border,
t.atoms.bg_contrast_25,
!gtMobile && {
width: 300,
},
{
borderColor: t.atoms.bg_contrast_25.backgroundColor,
},
style,
]}>
{children}
</View>
)
}
export function SuggestedFollowCardSkeleton() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<FeedCard.Header>
<FeedCard.AvatarPlaceholder />
<FeedCard.TitleAndBylinePlaceholder />
</FeedCard.Header>
<FeedCard.DescriptionPlaceholder />
</CardOuter>
)
}
export function ErrorState({retry}: {retry: () => void}) {
const t = useTheme()
return (
<>
<CardOuter>
<CircleInfo size="lg" fill={t.palette.negative_400} />
<Text style={[a.font_bold]}>Whoops, something went wrong :(</Text>
<Button
label={'Retry'}
size="small"
variant="ghost"
color="secondary"
onPress={retry}>
<ButtonText>Retry</ButtonText>
<ButtonIcon icon={Refresh} position="right" />
</Button>
</CardOuter>
<SuggestedFollowCardSkeleton />
<SuggestedFollowCardSkeleton />
</>
)
}
export function SuggestedFeedsCards() {
const t = useTheme()
const {_} = useLingui()
const {
data,
isLoading: isLoadingFeeds,
error,
refetch,
} = useGetPopularFeedsQuery({limit: 3})
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const isLoading = isLoadingFeeds || !moderationOpts
const feeds = React.useMemo(() => {
const items: AppBskyFeedDefs.GeneratorView[] = []
if (!data) return items
for (const page of data.pages) {
for (const feed of page.feeds) {
items.push(feed)
}
}
return items
}, [data])
const content = isLoading ? (
Array(3)
.fill(0)
.map((_, i) => <SuggestedFollowCardSkeleton key={i} />)
) : error || !feeds ? (
<ErrorState retry={refetch} />
) : (
<>
{feeds.map(feed => (
<FeedCard.Link key={feed.uri} type="feed" view={feed}>
<CardOuter style={[a.flex_1]}>
<FeedCard.Outer>
<FeedCard.Header>
<FeedCard.Avatar src={feed.avatar} />
<FeedCard.TitleAndByline
title={feed.displayName}
creator={feed.creator}
type="feed"
/>
<FeedCard.Action pin type="feed" uri={feed.uri} />
</FeedCard.Header>
<FeedCard.Description
description={feed.description}
numberOfLines={3}
/>
</FeedCard.Outer>
</CardOuter>
</FeedCard.Link>
))}
</>
)
return gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
{content}
</View>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more accounts on our explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter
style={[a.flex_1, t.atoms.bg_contrast_25, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on our explore page</Trans>
</Text>
<Arrow size="xl" />
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
)
}
+6 -3
View File
@@ -101,13 +101,16 @@ export function useSharedInputStyles() {
]
const error: ViewStyle[] = [
{
backgroundColor: t.palette.negative_25,
borderColor: t.palette.negative_300,
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
},
]
const errorHover: ViewStyle[] = [
{
backgroundColor: t.palette.negative_25,
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: t.palette.negative_500,
},
]
+28 -27
View File
@@ -1,8 +1,6 @@
import React from 'react'
import {Pressable, View, ViewStyle} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import {isNative} from '#/platform/detection'
import {HITSLOP_10} from 'lib/constants'
import {
atoms as a,
@@ -246,7 +244,6 @@ export function LabelText({
<Text
style={[
a.font_bold,
a.leading_tight,
{
userSelect: 'none',
color: disabled
@@ -254,7 +251,7 @@ export function LabelText({
: t.atoms.text_contrast_high.color,
},
native({
paddingTop: 2,
paddingTop: 3,
}),
flatten(style),
]}>
@@ -284,20 +281,24 @@ export function createSharedToggleStyles({
if (selected) {
base.push({
backgroundColor: t.palette.primary_25,
backgroundColor:
t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900,
borderColor: t.palette.primary_500,
})
if (hovered) {
baseHover.push({
backgroundColor: t.palette.primary_100,
borderColor: t.palette.primary_600,
backgroundColor:
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
borderColor:
t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400,
})
}
} else {
if (hovered) {
baseHover.push({
backgroundColor: t.palette.contrast_50,
backgroundColor:
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
borderColor: t.palette.contrast_500,
})
}
@@ -305,13 +306,16 @@ export function createSharedToggleStyles({
if (isInvalid) {
base.push({
backgroundColor: t.palette.negative_25,
borderColor: t.palette.negative_300,
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
})
if (hovered) {
baseHover.push({
backgroundColor: t.palette.negative_25,
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: t.palette.negative_600,
})
}
@@ -347,10 +351,10 @@ export function Checkbox() {
style={[
a.justify_center,
a.align_center,
a.border,
a.rounded_xs,
t.atoms.border_contrast_high,
{
borderWidth: 1,
height: 20,
width: 20,
},
@@ -378,35 +382,34 @@ export function Switch() {
<View
style={[
a.relative,
a.border,
a.rounded_full,
t.atoms.bg,
t.atoms.border_contrast_high,
{
borderWidth: 1,
height: 20,
width: 32,
padding: 2,
width: 30,
},
baseStyles,
hovered ? baseHoverStyles : {},
]}>
<Animated.View
layout={LinearTransition.duration(100)}
<View
style={[
a.absolute,
a.rounded_full,
{
height: 14,
width: 14,
height: 12,
width: 12,
top: 3,
left: 3,
backgroundColor: t.palette.contrast_400,
},
selected
? {
backgroundColor: t.palette.primary_500,
alignSelf: 'flex-end',
left: 13,
}
: {
backgroundColor: t.palette.contrast_400,
alignSelf: 'flex-start',
},
: {},
indicatorStyles,
]}
/>
@@ -432,10 +435,10 @@ export function Radio() {
style={[
a.justify_center,
a.align_center,
a.border,
a.rounded_full,
t.atoms.border_contrast_high,
{
borderWidth: 1,
height: 20,
width: 20,
},
@@ -460,5 +463,3 @@ export function Radio() {
</View>
)
}
export const Platform = isNative ? Switch : Checkbox
+2 -2
View File
@@ -32,7 +32,7 @@ export function useFollowMethods({
} catch (e: any) {
logger.error(`useFollowMethods: failed to follow`, {message: String(e)})
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
})
@@ -47,7 +47,7 @@ export function useFollowMethods({
message: String(e),
})
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
})
@@ -7,7 +7,7 @@ import {
import {isAndroid} from 'platform/detection'
import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
import {useSetActiveStarterPack} from 'state/shell/starter-pack'
import {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army'
export function useStarterPackEntry() {
const [ready, setReady] = React.useState(false)
@@ -39,10 +39,14 @@ export function useStarterPackEntry() {
uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer)
}
} else {
const starterPackUri = SharedPrefs.getString('starterPackUri')
if (starterPackUri) {
uri = httpStarterPackUriToAtUri(starterPackUri)
SharedPrefs.setValue('starterPackUri', null)
const res = await DevicePrefs.getStringValueAsync(
'starterPackUri',
true,
)
if (res) {
uri = httpStarterPackUriToAtUri(res)
DevicePrefs.setStringValueAsync('starterPackUri', null, true)
}
}
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
})
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const VideoClip_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z',
})
+2 -3
View File
@@ -4,7 +4,7 @@ import type {PathProps, SvgProps} from 'react-native-svg'
import {Defs, LinearGradient, Stop} from 'react-native-svg'
import {nanoid} from 'nanoid/non-secure'
import {tokens, useTheme} from '#/alf'
import {tokens} from '#/alf'
export type Props = {
fill?: PathProps['fill']
@@ -22,11 +22,10 @@ export const sizes = {
}
export function useCommonSVGProps(props: Props) {
const t = useTheme()
const {fill, size, gradient, ...rest} = props
const style = StyleSheet.flatten(rest.style)
const _size = Number(size ? sizes[size] : rest.width || sizes.md)
let _fill = fill || style?.color || t.palette.primary_500
let _fill = fill || style?.color || tokens.color.blue_500
let gradientDef = null
if (gradient && tokens.gradients[gradient]) {
+3 -1
View File
@@ -165,7 +165,9 @@ export function ContentHider({
}
const styles = StyleSheet.create({
outer: {},
outer: {
overflow: 'hidden',
},
cover: {
flexDirection: 'row',
alignItems: 'center',
+10 -31
View File
@@ -7,7 +7,6 @@ import {useMutation} from '@tanstack/react-query'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links'
import {useGate} from '#/lib/statsig/statsig'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
@@ -202,46 +201,26 @@ function AppealForm({
const [details, setDetails] = React.useState('')
const isAccountReport = 'did' in subject
const agent = useAgent()
const gate = useGate()
const {mutate, isPending} = useMutation({
mutationFn: async () => {
const $type = !isAccountReport
? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef'
if (gate('session_withproxy_fix')) {
await agent.createModerationReport(
{
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type,
...subject,
},
reason: details,
await agent
.withProxy('atproto_labeler', label.src)
.createModerationReport({
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type,
...subject,
},
{
encoding: 'application/json',
headers: {
'atproto-proxy': `${label.src}#atproto_labeler`,
},
},
)
} else {
await agent
.withProxy('atproto_labeler', label.src)
.createModerationReport({
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type,
...subject,
},
reason: details,
})
}
reason: details,
})
},
onError: err => {
logger.error('Failed to submit label appeal', {message: err})
Toast.show(_(msg`Failed to submit appeal, please try again.`), 'xmark')
Toast.show(_(msg`Failed to submit appeal, please try again.`))
},
onSuccess: () => {
control.close()
+98 -23
View File
@@ -1,17 +1,25 @@
import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {StyleProp, View, ViewStyle} from 'react-native'
import {BSKY_LABELER_DID, ModerationCause, ModerationUI} from '@atproto/api'
import {getModerationCauseKey} from '#/lib/moderation'
import * as Pills from '#/components/Pills'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export function PostAlerts({
modui,
size = 'sm',
size,
style,
}: {
modui: ModerationUI
size?: Pills.CommonProps['size']
size?: 'medium' | 'large'
includeMute?: boolean
style?: StyleProp<ViewStyle>
}) {
@@ -20,23 +28,90 @@ export function PostAlerts({
}
return (
<Pills.Row size={size} style={[size === 'sm' && {marginLeft: -3}, style]}>
{modui.alerts.map(cause => (
<Pills.Label
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
noBg={size === 'sm'}
/>
))}
{modui.informs.map(cause => (
<Pills.Label
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
noBg={size === 'sm'}
/>
))}
</Pills.Row>
<View style={[a.flex_col, a.gap_xs, style]}>
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{modui.alerts.map(cause => (
<PostLabel
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
/>
))}
{modui.informs.map(cause => (
<PostLabel
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
/>
))}
</View>
</View>
)
}
function PostLabel({
cause,
size,
}: {
cause: ModerationCause
size?: 'medium' | 'large'
}) {
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
const t = useTheme()
return (
<>
<Button
label={desc.name}
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
a.rounded_sm,
hovered || pressed
? size === 'large'
? t.atoms.bg_contrast_50
: t.atoms.bg_contrast_25
: size === 'large'
? t.atoms.bg_contrast_25
: undefined,
size === 'large'
? {paddingLeft: 4, paddingRight: 6, paddingVertical: 2}
: {paddingRight: 4, paddingVertical: 1},
]}>
{desc.sourceType === 'labeler' &&
desc.sourceDid !== BSKY_LABELER_DID ? (
<UserAvatar
avatar={desc.sourceAvi}
size={size === 'large' ? 16 : 12}
type="labeler"
shape="circle"
/>
) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
)}
<Text
style={[
a.text_left,
a.leading_snug,
size === 'large' ? {fontSize: 13} : a.text_xs,
size === 'large' ? t.atoms.text : t.atoms.text_contrast_high,
]}>
{desc.name}
</Text>
</View>
)}
</Button>
<ModerationDetailsDialog control={control} modcause={cause} />
</>
)
}
@@ -1,12 +1,25 @@
import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
import {ModerationDecision} from '@atproto/api'
import {StyleProp, View, ViewStyle} from 'react-native'
import {
BSKY_LABELER_DID,
ModerationCause,
ModerationDecision,
} from '@atproto/api'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {getModerationCauseKey} from 'lib/moderation'
import * as Pills from '#/components/Pills'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export function ProfileHeaderAlerts({
moderation,
style,
}: {
moderation: ModerationDecision
style?: StyleProp<ViewStyle>
@@ -17,21 +30,73 @@ export function ProfileHeaderAlerts({
}
return (
<Pills.Row size="lg">
{modui.alerts.map(cause => (
<Pills.Label
size="lg"
key={getModerationCauseKey(cause)}
cause={cause}
/>
))}
{modui.informs.map(cause => (
<Pills.Label
size="lg"
key={getModerationCauseKey(cause)}
cause={cause}
/>
))}
</Pills.Row>
<View style={[a.flex_col, a.gap_xs, style]}>
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{modui.alerts.map(cause => (
<ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
))}
{modui.informs.map(cause => (
<ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
))}
</View>
</View>
)
}
export function ProfileLabel({
cause,
disableDetailsDialog,
}: {
cause: ModerationCause
disableDetailsDialog?: boolean
}) {
const t = useTheme()
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
return (
<>
<Button
disabled={disableDetailsDialog}
label={desc.name}
onPress={() => {
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
{paddingLeft: 6, paddingRight: 8, paddingVertical: 4},
a.gap_xs,
a.rounded_md,
hovered || pressed
? t.atoms.bg_contrast_50
: t.atoms.bg_contrast_25,
]}>
{desc.sourceType === 'labeler' &&
desc.sourceDid !== BSKY_LABELER_DID ? (
<UserAvatar avatar={desc.sourceAvi} size={16} />
) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
)}
<Text
style={[
a.text_left,
a.leading_snug,
a.text_sm,
t.atoms.text_contrast_medium,
a.font_semibold,
]}>
{desc.name}
</Text>
</View>
)}
</Button>
{!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
)}
</>
)
}

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