Merge remote-tracking branch 'upstream/main' into Improve-notification-localization

This commit is contained in:
Minseo Lee
2024-07-12 13:54:31 +09:00
102 changed files with 2577 additions and 1406 deletions
+2
View File
@@ -0,0 +1,2 @@
[*.{kt,kts}]
indent_size=2
+4 -4
View File
@@ -10,7 +10,7 @@ Get the app itself:
## Development Resources ## 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 on 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 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. 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. - Open an issue and give some time for discussion before submitting a PR.
- Stay away from PRs like... - Stay away from PRs like...
- Changing "Post" to "Skeet." - Changing "Post" to "Skeet."
- Refactoring the codebase, eg to replace mobx with redux or something. - Refactoring the codebase, e.g., to replace MobX with Redux or something.
- Adding entirely new features without prior discussion. - 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 ## 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? ## 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 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 integration can be as seamless as first-party through custom feeds, federated services, clients, and more.
## License (MIT) ## License (MIT)
+31
View File
@@ -0,0 +1,31 @@
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"
+1
View File
@@ -221,6 +221,7 @@ module.exports = function (config) {
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js', './plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
'./plugins/shareExtension/withShareExtensions.js', './plugins/shareExtension/withShareExtensions.js',
'./plugins/notificationsExtension/withNotificationsExtension.js', './plugins/notificationsExtension/withNotificationsExtension.js',
'./plugins/withAppDelegateReferrer.js',
].filter(Boolean), ].filter(Boolean),
extra: { extra: {
eas: { eas: {
+9 -9
View File
@@ -13,43 +13,43 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(request.content) contentHandler(request.content)
return return
} }
if reason == "chat-message" { if reason == "chat-message" {
mutateWithChatMessage(bestAttempt) mutateWithChatMessage(bestAttempt)
} else { } else {
mutateWithBadge(bestAttempt) mutateWithBadge(bestAttempt)
} }
contentHandler(bestAttempt) contentHandler(bestAttempt)
} }
override func serviceExtensionTimeWillExpire() { override func serviceExtensionTimeWillExpire() {
// If for some reason the alloted time expires, we don't actually want to display a notification // If for some reason the alloted time expires, we don't actually want to display a notification
} }
func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? { func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? {
return content.mutableCopy() as? UNMutableNotificationContent return content.mutableCopy() as? UNMutableNotificationContent
} }
func mutateWithBadge(_ content: UNMutableNotificationContent) { func mutateWithBadge(_ content: UNMutableNotificationContent) {
var count = prefs?.integer(forKey: "badgeCount") ?? 0 var count = prefs?.integer(forKey: "badgeCount") ?? 0
count += 1 count += 1
// Set the new badge number for the notification, then store that value for using later // Set the new badge number for the notification, then store that value for using later
content.badge = NSNumber(value: count) content.badge = NSNumber(value: count)
prefs?.setValue(count, forKey: "badgeCount") prefs?.setValue(count, forKey: "badgeCount")
} }
func mutateWithChatMessage(_ content: UNMutableNotificationContent) { func mutateWithChatMessage(_ content: UNMutableNotificationContent) {
if self.prefs?.bool(forKey: "playSoundChat") == true { if self.prefs?.bool(forKey: "playSoundChat") == true {
mutateWithDmSound(content) mutateWithDmSound(content)
} }
} }
func mutateWithDefaultSound(_ content: UNMutableNotificationContent) { func mutateWithDefaultSound(_ content: UNMutableNotificationContent) {
content.sound = UNNotificationSound.default content.sound = UNNotificationSound.default
} }
func mutateWithDmSound(_ content: UNMutableNotificationContent) { func mutateWithDmSound(_ content: UNMutableNotificationContent) {
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff")) content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff"))
} }
@@ -30,12 +30,11 @@ class ShareViewController: UIViewController {
} }
} }
private func handleText(item: NSItemProvider) async -> Void { private func handleText(item: NSItemProvider) async {
do { do {
if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String { if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String {
if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), 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) _ = self.openURL(url)
} }
} }
@@ -45,12 +44,11 @@ class ShareViewController: UIViewController {
} }
} }
private func handleUrl(item: NSItemProvider) async -> Void { private func handleUrl(item: NSItemProvider) async {
do { do {
if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL { if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL {
if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), 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) _ = self.openURL(url)
} }
} }
@@ -60,7 +58,7 @@ class ShareViewController: UIViewController {
} }
} }
private func handleImages(items: [NSItemProvider]) async -> Void { private func handleImages(items: [NSItemProvider]) async {
let firstFourItems: [NSItemProvider] let firstFourItems: [NSItemProvider]
if items.count < 4 { if items.count < 4 {
firstFourItems = items firstFourItems = items
@@ -72,7 +70,7 @@ class ShareViewController: UIViewController {
var imageUris = "" var imageUris = ""
for (index, item) in firstFourItems.enumerated() { for (index, item) in firstFourItems.enumerated() {
var imageUriInfo: String? = nil var imageUriInfo: String?
do { do {
if let dataUri = try await item.loadItem(forTypeIdentifier: "public.image") as? URL { if let dataUri = try await item.loadItem(forTypeIdentifier: "public.image") as? URL {
@@ -100,8 +98,7 @@ class ShareViewController: UIViewController {
if valid, if valid,
let encoded = imageUris.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), 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) _ = self.openURL(url)
} }
@@ -119,13 +116,11 @@ class ShareViewController: UIViewController {
// extension does. // extension does.
if let dir = FileManager() if let dir = FileManager()
.containerURL( .containerURL(
forSecurityApplicationGroupIdentifier: "group.app.bsky") forSecurityApplicationGroupIdentifier: "group.app.bsky") {
{
let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg" let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg"
if let newUri = URL(string: filePath), if let newUri = URL(string: filePath),
let jpegData = image.jpegData(compressionQuality: 1) let jpegData = image.jpegData(compressionQuality: 1) {
{
try jpegData.write(to: newUri) try jpegData.write(to: newUri)
return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)" return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)"
} }
@@ -136,7 +131,7 @@ class ShareViewController: UIViewController {
} }
} }
private func completeRequest() -> Void { private func completeRequest() {
self.extensionContext?.completeRequest(returningItems: nil) self.extensionContext?.completeRequest(returningItems: nil)
} }
@@ -5,7 +5,7 @@ import com.google.firebase.messaging.RemoteMessage
class BackgroundNotificationHandler( class BackgroundNotificationHandler(
private val context: Context, private val context: Context,
private val notifInterface: BackgroundNotificationHandlerInterface private val notifInterface: BackgroundNotificationHandlerInterface,
) { ) {
fun handleMessage(remoteMessage: RemoteMessage) { fun handleMessage(remoteMessage: RemoteMessage) {
if (ExpoBackgroundNotificationHandlerModule.isForegrounded) { if (ExpoBackgroundNotificationHandlerModule.isForegrounded) {
@@ -8,67 +8,68 @@ class ExpoBackgroundNotificationHandlerModule : Module() {
var isForegrounded = false var isForegrounded = false
} }
override fun definition() = ModuleDefinition { override fun definition() =
Name("ExpoBackgroundNotificationHandler") ModuleDefinition {
Name("ExpoBackgroundNotificationHandler")
OnCreate { OnCreate {
NotificationPrefs(appContext.reactContext).initialize() NotificationPrefs(appContext.reactContext).initialize()
} }
OnActivityEntersForeground { OnActivityEntersForeground {
isForegrounded = true isForegrounded = true
} }
OnActivityEntersBackground { OnActivityEntersBackground {
isForegrounded = false isForegrounded = false
} }
AsyncFunction("getAllPrefsAsync") { AsyncFunction("getAllPrefsAsync") {
return@AsyncFunction NotificationPrefs(appContext.reactContext).getAllPrefs() return@AsyncFunction NotificationPrefs(appContext.reactContext).getAllPrefs()
} }
AsyncFunction("getBoolAsync") { forKey: String -> AsyncFunction("getBoolAsync") { forKey: String ->
return@AsyncFunction NotificationPrefs(appContext.reactContext).getBoolean(forKey) return@AsyncFunction NotificationPrefs(appContext.reactContext).getBoolean(forKey)
} }
AsyncFunction("getStringAsync") { forKey: String -> AsyncFunction("getStringAsync") { forKey: String ->
return@AsyncFunction NotificationPrefs(appContext.reactContext).getString(forKey) return@AsyncFunction NotificationPrefs(appContext.reactContext).getString(forKey)
} }
AsyncFunction("getStringArrayAsync") { forKey: String -> AsyncFunction("getStringArrayAsync") { forKey: String ->
return@AsyncFunction NotificationPrefs(appContext.reactContext).getStringArray(forKey) return@AsyncFunction NotificationPrefs(appContext.reactContext).getStringArray(forKey)
} }
AsyncFunction("setBoolAsync") { forKey: String, value: Boolean -> AsyncFunction("setBoolAsync") { forKey: String, value: Boolean ->
NotificationPrefs(appContext.reactContext).setBoolean(forKey, value) NotificationPrefs(appContext.reactContext).setBoolean(forKey, value)
} }
AsyncFunction("setStringAsync") { forKey: String, value: String -> AsyncFunction("setStringAsync") { forKey: String, value: String ->
NotificationPrefs(appContext.reactContext).setString(forKey, value) NotificationPrefs(appContext.reactContext).setString(forKey, value)
} }
AsyncFunction("setStringArrayAsync") { forKey: String, value: Array<String> -> AsyncFunction("setStringArrayAsync") { forKey: String, value: Array<String> ->
NotificationPrefs(appContext.reactContext).setStringArray(forKey, value) NotificationPrefs(appContext.reactContext).setStringArray(forKey, value)
} }
AsyncFunction("addToStringArrayAsync") { forKey: String, string: String -> AsyncFunction("addToStringArrayAsync") { forKey: String, string: String ->
NotificationPrefs(appContext.reactContext).addToStringArray(forKey, string) NotificationPrefs(appContext.reactContext).addToStringArray(forKey, string)
} }
AsyncFunction("removeFromStringArrayAsync") { forKey: String, string: String -> AsyncFunction("removeFromStringArrayAsync") { forKey: String, string: String ->
NotificationPrefs(appContext.reactContext).removeFromStringArray(forKey, string) NotificationPrefs(appContext.reactContext).removeFromStringArray(forKey, string)
} }
AsyncFunction("addManyToStringArrayAsync") { forKey: String, strings: Array<String> -> AsyncFunction("addManyToStringArrayAsync") { forKey: String, strings: Array<String> ->
NotificationPrefs(appContext.reactContext).addManyToStringArray(forKey, strings) NotificationPrefs(appContext.reactContext).addManyToStringArray(forKey, strings)
} }
AsyncFunction("removeManyFromStringArrayAsync") { forKey: String, strings: Array<String> -> AsyncFunction("removeManyFromStringArrayAsync") { forKey: String, strings: Array<String> ->
NotificationPrefs(appContext.reactContext).removeManyFromStringArray(forKey, strings) NotificationPrefs(appContext.reactContext).removeManyFromStringArray(forKey, strings)
} }
AsyncFunction("setBadgeCountAsync") { _: Int -> AsyncFunction("setBadgeCountAsync") { _: Int ->
// This does nothing on Android // This does nothing on Android
}
} }
}
} }
@@ -2,20 +2,24 @@ package expo.modules.backgroundnotificationhandler
import android.content.Context import android.content.Context
val DEFAULTS = mapOf<String, Any>( val DEFAULTS =
"playSoundChat" to true, mapOf<String, Any>(
"playSoundFollow" to false, "playSoundChat" to true,
"playSoundLike" to false, "playSoundFollow" to false,
"playSoundMention" to false, "playSoundLike" to false,
"playSoundQuote" to false, "playSoundMention" to false,
"playSoundReply" to false, "playSoundQuote" to false,
"playSoundRepost" to false, "playSoundReply" to false,
"mutedThreads" to mapOf<String, List<String>>() "playSoundRepost" to false,
) "mutedThreads" to mapOf<String, List<String>>(),
)
class NotificationPrefs (private val context: Context?) { class NotificationPrefs(
private val prefs = context?.getSharedPreferences("xyz.blueskyweb.app", Context.MODE_PRIVATE) private val context: Context?,
?: throw Error("Context is null") ) {
private val prefs =
context?.getSharedPreferences("xyz.blueskyweb.app", Context.MODE_PRIVATE)
?: throw Error("Context is null")
fun initialize() { fun initialize() {
prefs prefs
@@ -41,94 +45,99 @@ class NotificationPrefs (private val context: Context?) {
} }
} }
} }
} }.apply()
.apply()
} }
fun getAllPrefs(): MutableMap<String, *> { fun getAllPrefs(): MutableMap<String, *> = prefs.all
return prefs.all
}
fun getBoolean(key: String): Boolean { fun getBoolean(key: String): Boolean = prefs.getBoolean(key, false)
return prefs.getBoolean(key, false)
}
fun getString(key: String): String? { fun getString(key: String): String? = prefs.getString(key, null)
return prefs.getString(key, null)
}
fun getStringArray(key: String): Array<String>? { fun getStringArray(key: String): Array<String>? = prefs.getStringSet(key, null)?.toTypedArray()
return prefs.getStringSet(key, null)?.toTypedArray()
}
fun setBoolean(key: String, value: Boolean) { fun setBoolean(
key: String,
value: Boolean,
) {
prefs prefs
.edit() .edit()
.apply { .apply {
putBoolean(key, value) putBoolean(key, value)
} }.apply()
.apply()
} }
fun setString(key: String, value: String) { fun setString(
key: String,
value: String,
) {
prefs prefs
.edit() .edit()
.apply { .apply {
putString(key, value) putString(key, value)
} }.apply()
.apply()
} }
fun setStringArray(key: String, value: Array<String>) { fun setStringArray(
key: String,
value: Array<String>,
) {
prefs prefs
.edit() .edit()
.apply { .apply {
putStringSet(key, value.toSet()) putStringSet(key, value.toSet())
} }.apply()
.apply()
} }
fun addToStringArray(key: String, string: String) { fun addToStringArray(
key: String,
string: String,
) {
prefs prefs
.edit() .edit()
.apply { .apply {
val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf() val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
set.add(string) set.add(string)
putStringSet(key, set) putStringSet(key, set)
} }.apply()
.apply()
} }
fun removeFromStringArray(key: String, string: String) { fun removeFromStringArray(
key: String,
string: String,
) {
prefs prefs
.edit() .edit()
.apply { .apply {
val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf() val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
set.remove(string) set.remove(string)
putStringSet(key, set) putStringSet(key, set)
} }.apply()
.apply()
} }
fun addManyToStringArray(key: String, strings: Array<String>) { fun addManyToStringArray(
key: String,
strings: Array<String>,
) {
prefs prefs
.edit() .edit()
.apply { .apply {
val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf() val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
set.addAll(strings.toSet()) set.addAll(strings.toSet())
putStringSet(key, set) putStringSet(key, set)
} }.apply()
.apply()
} }
fun removeManyFromStringArray(key: String, strings: Array<String>) { fun removeManyFromStringArray(
key: String,
strings: Array<String>,
) {
prefs prefs
.edit() .edit()
.apply { .apply {
val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf() val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
set.removeAll(strings.toSet()) set.removeAll(strings.toSet())
putStringSet(key, set) putStringSet(key, set)
} }.apply()
.apply()
} }
} }
@@ -2,16 +2,16 @@ import ExpoModulesCore
let APP_GROUP = "group.app.bsky" let APP_GROUP = "group.app.bsky"
let DEFAULTS: [String:Any] = [ let DEFAULTS: [String: Any] = [
"playSoundChat" : true, "playSoundChat": true,
"playSoundFollow": false, "playSoundFollow": false,
"playSoundLike": false, "playSoundLike": false,
"playSoundMention": false, "playSoundMention": false,
"playSoundQuote": false, "playSoundQuote": false,
"playSoundReply": false, "playSoundReply": false,
"playSoundRepost": false, "playSoundRepost": false,
"mutedThreads": [:] as! [String:[String]], "mutedThreads": [:] as! [String: [String]],
"badgeCount": 0, "badgeCount": 0
] ]
/* /*
@@ -23,10 +23,10 @@ let DEFAULTS: [String:Any] = [
*/ */
public class ExpoBackgroundNotificationHandlerModule: Module { public class ExpoBackgroundNotificationHandlerModule: Module {
let userDefaults = UserDefaults(suiteName: APP_GROUP) let userDefaults = UserDefaults(suiteName: APP_GROUP)
public func definition() -> ModuleDefinition { public func definition() -> ModuleDefinition {
Name("ExpoBackgroundNotificationHandler") Name("ExpoBackgroundNotificationHandler")
OnCreate { OnCreate {
DEFAULTS.forEach { p in DEFAULTS.forEach { p in
if userDefaults?.value(forKey: p.key) == nil { if userDefaults?.value(forKey: p.key) == nil {
@@ -34,57 +34,56 @@ public class ExpoBackgroundNotificationHandlerModule: Module {
} }
} }
} }
AsyncFunction("getAllPrefsAsync") { () -> [String:Any]? in AsyncFunction("getAllPrefsAsync") { () -> [String: Any]? in
var keys: [String] = [] var keys: [String] = []
DEFAULTS.forEach { p in DEFAULTS.forEach { p in
keys.append(p.key) keys.append(p.key)
} }
return userDefaults?.dictionaryWithValues(forKeys: keys) return userDefaults?.dictionaryWithValues(forKeys: keys)
} }
AsyncFunction("getBoolAsync") { (forKey: String) -> Bool in AsyncFunction("getBoolAsync") { (forKey: String) -> Bool in
if let pref = userDefaults?.bool(forKey: forKey) { if let pref = userDefaults?.bool(forKey: forKey) {
return pref return pref
} }
return false return false
} }
AsyncFunction("getStringAsync") { (forKey: String) -> String? in AsyncFunction("getStringAsync") { (forKey: String) -> String? in
if let pref = userDefaults?.string(forKey: forKey) { if let pref = userDefaults?.string(forKey: forKey) {
return pref return pref
} }
return nil return nil
} }
AsyncFunction("getStringArrayAsync") { (forKey: String) -> [String]? in AsyncFunction("getStringArrayAsync") { (forKey: String) -> [String]? in
if let pref = userDefaults?.stringArray(forKey: forKey) { if let pref = userDefaults?.stringArray(forKey: forKey) {
return pref return pref
} }
return nil return nil
} }
AsyncFunction("setBoolAsync") { (forKey: String, value: Bool) -> Void in AsyncFunction("setBoolAsync") { (forKey: String, value: Bool) in
userDefaults?.setValue(value, forKey: forKey) userDefaults?.setValue(value, forKey: forKey)
} }
AsyncFunction("setStringAsync") { (forKey: String, value: String) -> Void in AsyncFunction("setStringAsync") { (forKey: String, value: String) in
userDefaults?.setValue(value, forKey: forKey) userDefaults?.setValue(value, forKey: forKey)
} }
AsyncFunction("setStringArrayAsync") { (forKey: String, value: [String]) -> Void in AsyncFunction("setStringArrayAsync") { (forKey: String, value: [String]) in
userDefaults?.setValue(value, forKey: forKey) userDefaults?.setValue(value, forKey: forKey)
} }
AsyncFunction("addToStringArrayAsync") { (forKey: String, string: String) in AsyncFunction("addToStringArrayAsync") { (forKey: String, string: String) in
if var curr = userDefaults?.stringArray(forKey: forKey), if var curr = userDefaults?.stringArray(forKey: forKey),
!curr.contains(string) !curr.contains(string) {
{
curr.append(string) curr.append(string)
userDefaults?.setValue(curr, forKey: forKey) userDefaults?.setValue(curr, forKey: forKey)
} }
} }
AsyncFunction("removeFromStringArrayAsync") { (forKey: String, string: String) in AsyncFunction("removeFromStringArrayAsync") { (forKey: String, string: String) in
if var curr = userDefaults?.stringArray(forKey: forKey) { if var curr = userDefaults?.stringArray(forKey: forKey) {
curr.removeAll { s in curr.removeAll { s in
@@ -93,7 +92,7 @@ public class ExpoBackgroundNotificationHandlerModule: Module {
userDefaults?.setValue(curr, forKey: forKey) userDefaults?.setValue(curr, forKey: forKey)
} }
} }
AsyncFunction("addManyToStringArrayAsync") { (forKey: String, strings: [String]) in AsyncFunction("addManyToStringArrayAsync") { (forKey: String, strings: [String]) in
if var curr = userDefaults?.stringArray(forKey: forKey) { if var curr = userDefaults?.stringArray(forKey: forKey) {
strings.forEach { s in strings.forEach { s in
@@ -104,7 +103,7 @@ public class ExpoBackgroundNotificationHandlerModule: Module {
userDefaults?.setValue(curr, forKey: forKey) userDefaults?.setValue(curr, forKey: forKey)
} }
} }
AsyncFunction("removeManyFromStringArrayAsync") { (forKey: String, strings: [String]) in AsyncFunction("removeManyFromStringArrayAsync") { (forKey: String, strings: [String]) in
if var curr = userDefaults?.stringArray(forKey: forKey) { if var curr = userDefaults?.stringArray(forKey: forKey) {
strings.forEach { s in strings.forEach { s in
@@ -113,7 +112,7 @@ public class ExpoBackgroundNotificationHandlerModule: Module {
userDefaults?.setValue(curr, forKey: forKey) userDefaults?.setValue(curr, forKey: forKey)
} }
} }
AsyncFunction("setBadgeCountAsync") { (count: Int) in AsyncFunction("setBadgeCountAsync") { (count: Int) in
userDefaults?.setValue(count, forKey: "badgeCount") userDefaults?.setValue(count, forKey: "badgeCount")
} }
@@ -5,7 +5,10 @@ import android.graphics.Canvas
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import androidx.appcompat.widget.AppCompatImageView 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) { override fun onDraw(canvas: Canvas) {
super.onDraw(canvas) super.onDraw(canvas)
@@ -34,4 +37,4 @@ class AppCompatImageViewExtended(context: Context, private val parent: GifView):
drawable.start() drawable.start()
} }
} }
} }
@@ -6,49 +6,50 @@ import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.modules.ModuleDefinition
class ExpoBlueskyGifViewModule : Module() { class ExpoBlueskyGifViewModule : Module() {
override fun definition() = ModuleDefinition { override fun definition() =
Name("ExpoBlueskyGifView") ModuleDefinition {
Name("ExpoBlueskyGifView")
AsyncFunction("prefetchAsync") { sources: List<String> -> AsyncFunction("prefetchAsync") { sources: List<String> ->
val activity = appContext.currentActivity ?: return@AsyncFunction val activity = appContext.currentActivity ?: return@AsyncFunction
val glide = Glide.with(activity) val glide = Glide.with(activity)
sources.forEach { source -> sources.forEach { source ->
glide glide
.download(source) .download(source)
.diskCacheStrategy(DiskCacheStrategy.DATA) .diskCacheStrategy(DiskCacheStrategy.DATA)
.submit() .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()
}
} }
} }
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,6 +1,5 @@
package expo.modules.blueskygifview package expo.modules.blueskygifview
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
@@ -15,7 +14,10 @@ import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView 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 // Events
private val onPlayerStateChange by EventDispatcher() private val onPlayerStateChange by EventDispatcher()
@@ -44,8 +46,7 @@ class GifView(context: Context, appContext: AppContext) : ExpoView(context, appC
} }
} }
// <editor-fold desc="Lifecycle">
//<editor-fold desc="Lifecycle">
init { init {
this.setBackgroundColor(Color.TRANSPARENT) this.setBackgroundColor(Color.TRANSPARENT)
@@ -70,80 +71,82 @@ class GifView(context: Context, appContext: AppContext) : ExpoView(context, appC
super.onDetachedFromWindow() super.onDetachedFromWindow()
} }
//</editor-fold> // </editor-fold>
//<editor-fold desc="Loading"> // <editor-fold desc="Loading">
private fun load() { private fun load() {
if (placeholderSource == null || source == null) { if (placeholderSource == null || source == null) {
return return
} }
this.webpRequest = glide.load(source) this.webpRequest =
.diskCacheStrategy(DiskCacheStrategy.DATA) glide
.skipMemoryCache(false) .load(source)
.listener(object: RequestListener<Drawable> { .diskCacheStrategy(DiskCacheStrategy.DATA)
override fun onResourceReady( .skipMemoryCache(false)
resource: Drawable?, .listener(
model: Any?, object : RequestListener<Drawable> {
target: Target<Drawable>?, override fun onResourceReady(
dataSource: com.bumptech.glide.load.DataSource?, resource: Drawable?,
isFirstResource: Boolean model: Any?,
): Boolean { target: Target<Drawable>?,
if (placeholderRequest != null) { dataSource: com.bumptech.glide.load.DataSource?,
glide.clear(placeholderRequest) isFirstResource: Boolean,
} ): Boolean {
return false if (placeholderRequest != null) {
} glide.clear(placeholderRequest)
}
return false
}
override fun onLoadFailed( override fun onLoadFailed(
e: GlideException?, e: GlideException?,
model: Any?, model: Any?,
target: Target<Drawable>?, target: Target<Drawable>?,
isFirstResource: Boolean isFirstResource: Boolean,
): Boolean { ): Boolean = true
return true },
} ).into(this.imageView)
})
.into(this.imageView)
if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) { if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) {
this.placeholderRequest = glide.load(placeholderSource) this.placeholderRequest =
.diskCacheStrategy(DiskCacheStrategy.DATA) glide
// Let's not bloat the memory cache with placeholders .load(placeholderSource)
.skipMemoryCache(true) .diskCacheStrategy(DiskCacheStrategy.DATA)
.listener(object: RequestListener<Drawable> { // Let's not bloat the memory cache with placeholders
override fun onResourceReady( .skipMemoryCache(true)
resource: Drawable?, .listener(
model: Any?, object : RequestListener<Drawable> {
target: Target<Drawable>?, override fun onResourceReady(
dataSource: com.bumptech.glide.load.DataSource?, resource: Drawable?,
isFirstResource: Boolean model: Any?,
): Boolean { target: Target<Drawable>?,
// Incase this request finishes after the webp, let's just not set dataSource: com.bumptech.glide.load.DataSource?,
// the drawable. This shouldn't happen because the request should get cancelled isFirstResource: Boolean,
if (imageView.drawable == null) { ): Boolean {
imageView.setImageDrawable(resource) // Incase this request finishes after the webp, let's just not set
} // the drawable. This shouldn't happen because the request should get cancelled
return true if (imageView.drawable == null) {
} imageView.setImageDrawable(resource)
}
return true
}
override fun onLoadFailed( override fun onLoadFailed(
e: GlideException?, e: GlideException?,
model: Any?, model: Any?,
target: Target<Drawable>?, target: Target<Drawable>?,
isFirstResource: Boolean isFirstResource: Boolean,
): Boolean { ): Boolean = true
return true },
} ).submit()
})
.submit()
} }
} }
//</editor-fold> // </editor-fold>
//<editor-fold desc="Controls"> // <editor-fold desc="Controls">
fun play() { fun play() {
this.imageView.play() this.imageView.play()
@@ -165,16 +168,18 @@ class GifView(context: Context, appContext: AppContext) : ExpoView(context, appC
} }
} }
//</editor-fold> // </editor-fold>
//<editor-fold desc="Util"> // <editor-fold desc="Util">
fun firePlayerStateChange() { fun firePlayerStateChange() {
onPlayerStateChange(mapOf( onPlayerStateChange(
"isPlaying" to this.isPlaying, mapOf(
"isLoaded" to this.isLoaded, "isPlaying" to this.isPlaying,
)) "isLoaded" to this.isLoaded,
),
)
} }
//</editor-fold> // </editor-fold>
} }
@@ -5,11 +5,11 @@ import SDWebImageWebPCoder
public class ExpoBlueskyGifViewModule: Module { public class ExpoBlueskyGifViewModule: Module {
public func definition() -> ModuleDefinition { public func definition() -> ModuleDefinition {
Name("ExpoBlueskyGifView") Name("ExpoBlueskyGifView")
OnCreate { OnCreate {
SDImageCodersManager.shared.addCoder(SDImageGIFCoder.shared) SDImageCodersManager.shared.addCoder(SDImageGIFCoder.shared)
} }
AsyncFunction("prefetchAsync") { (sources: [URL]) in AsyncFunction("prefetchAsync") { (sources: [URL]) in
SDWebImagePrefetcher.shared.prefetchURLs(sources, context: Util.createContext(), progress: nil) SDWebImagePrefetcher.shared.prefetchURLs(sources, context: Util.createContext(), progress: nil)
} }
@@ -18,27 +18,27 @@ public class ExpoBlueskyGifViewModule: Module {
Events( Events(
"onPlayerStateChange" "onPlayerStateChange"
) )
Prop("source") { (view: GifView, prop: String) in Prop("source") { (view: GifView, prop: String) in
view.source = prop view.source = prop
} }
Prop("placeholderSource") { (view: GifView, prop: String) in Prop("placeholderSource") { (view: GifView, prop: String) in
view.placeholderSource = prop view.placeholderSource = prop
} }
Prop("autoplay") { (view: GifView, prop: Bool) in Prop("autoplay") { (view: GifView, prop: Bool) in
view.autoplay = prop view.autoplay = prop
} }
AsyncFunction("toggleAsync") { (view: GifView) in AsyncFunction("toggleAsync") { (view: GifView) in
view.toggle() view.toggle()
} }
AsyncFunction("playAsync") { (view: GifView) in AsyncFunction("playAsync") { (view: GifView) in
view.play() view.play()
} }
AsyncFunction("pauseAsync") { (view: GifView) in AsyncFunction("pauseAsync") { (view: GifView) in
view.pause() view.pause()
} }
@@ -16,14 +16,14 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
) )
private var isPlaying = true private var isPlaying = true
private var isLoaded = false private var isLoaded = false
// Requests // Requests
private var webpOperation: SDWebImageCombinedOperation? private var webpOperation: SDWebImageCombinedOperation?
private var placeholderOperation: SDWebImageCombinedOperation? private var placeholderOperation: SDWebImageCombinedOperation?
// Props // Props
var source: String? = nil var source: String?
var placeholderSource: String? = nil var placeholderSource: String?
var autoplay = true { var autoplay = true {
didSet { didSet {
if !autoplay { if !autoplay {
@@ -78,8 +78,7 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
// See: // See:
// https://github.com/SDWebImage/SDWebImage/blob/master/Docs/HowToUse.md#using-asynchronous-image-caching-independently // https://github.com/SDWebImage/SDWebImage/blob/master/Docs/HowToUse.md#using-asynchronous-image-caching-independently
if !SDImageCache.shared.diskImageDataExists(withKey: source), if !SDImageCache.shared.diskImageDataExists(withKey: source),
let url = URL(string: placeholderSource) let url = URL(string: placeholderSource) {
{
self.placeholderOperation = imageManager.loadImage( self.placeholderOperation = imageManager.loadImage(
with: url, with: url,
options: [.retryFailed], options: [.retryFailed],
@@ -132,8 +131,7 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
if let placeholderSource = self.placeholderSource, if let placeholderSource = self.placeholderSource,
imageUrl?.absoluteString == placeholderSource, imageUrl?.absoluteString == placeholderSource,
self.imageView.image == nil, self.imageView.image == nil,
let image = image let image = image {
{
self.setImage(image) self.setImage(image)
return return
} }
@@ -142,8 +140,7 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
imageUrl?.absoluteString == source, imageUrl?.absoluteString == source,
// UIImage perf suckssss if the image is animated // UIImage perf suckssss if the image is animated
let data = data, let data = data,
let animatedImage = SDAnimatedImage(data: data) let animatedImage = SDAnimatedImage(data: data) {
{
self.placeholderOperation?.cancel() self.placeholderOperation?.cancel()
self.isPlaying = self.autoplay self.isPlaying = self.autoplay
self.isLoaded = true self.isLoaded = true
@@ -1,10 +0,0 @@
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,54 +1,103 @@
package expo.modules.blueskyswissarmy.referrer package expo.modules.blueskyswissarmy.referrer
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log import android.util.Log
import com.android.installreferrer.api.InstallReferrerClient import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener import com.android.installreferrer.api.InstallReferrerStateListener
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.Promise
class ExpoBlueskyReferrerModule : Module() { class ExpoBlueskyReferrerModule : Module() {
override fun definition() = ModuleDefinition { private var intent: Intent? = null
Name("ExpoBlueskyReferrer") private var activityReferrer: Uri? = null
AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise -> override fun definition() =
val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build() ModuleDefinition {
referrerClient.startConnection(object : InstallReferrerStateListener { Name("ExpoBlueskyReferrer")
override fun onInstallReferrerSetupFinished(responseCode: Int) {
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
Log.d("ExpoGooglePlayReferrer", "Successfully retrieved referrer info.")
val response = referrerClient.installReferrer OnNewIntent {
Log.d("ExpoGooglePlayReferrer", "Install referrer: ${response.installReferrer}") intent = it
activityReferrer = appContext.currentActivity?.referrer
}
promise.resolve( AsyncFunction("getReferrerInfoAsync") {
mapOf( val intentReferrer =
"installReferrer" to response.installReferrer, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
"clickTimestamp" to response.referrerClickTimestampSeconds, intent?.getParcelableExtra(Intent.EXTRA_REFERRER, Uri::class.java)
"installTimestamp" to response.installBeginTimestampSeconds
)
)
} else { } else {
Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Unknown error.") intent?.getParcelableExtra(Intent.EXTRA_REFERRER)
promise.reject(
"ERR_GOOGLE_PLAY_REFERRER_UNKNOWN",
"Failed to get referrer info",
Exception("Failed to get referrer info")
)
} }
referrerClient.endConnection()
// 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@AsyncFunction res
} }
override fun onInstallReferrerServiceDisconnected() { // In all other cases, we'll just record the app that sent the intent.
Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Service disconnected.") if (activityReferrer != null) {
referrerClient.endConnection() // referrer could become null here. `.toString()` though can be called on null
promise.reject( val res =
"ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED", mapOf(
"Failed to get referrer info", "referrer" to activityReferrer.toString(),
Exception("Failed to get referrer info") "hostname" to (activityReferrer?.host ?: ""),
) )
activityReferrer = null
return@AsyncFunction res
} }
})
return@AsyncFunction 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"),
)
}
},
)
}
} }
} }
}
@@ -0,0 +1,69 @@
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)
}
}
}
@@ -0,0 +1,241 @@
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"], "platforms": ["ios", "tvos", "android", "web"],
"ios": { "ios": {
"modules": ["ExpoBlueskyDevicePrefsModule", "ExpoBlueskyReferrerModule"] "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"]
}, },
"android": { "android": {
"modules": [ "modules": [
"expo.modules.blueskyswissarmy.deviceprefs.ExpoBlueskyDevicePrefsModule", "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule" "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 Referrer from './src/Referrer'
import * as SharedPrefs from './src/SharedPrefs'
export {DevicePrefs, Referrer} export {Referrer, SharedPrefs}
@@ -1,23 +0,0 @@
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)
}
}
}
@@ -0,0 +1,62 @@
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)
}
}
}
@@ -0,0 +1,89 @@
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)
}
}
@@ -1,18 +0,0 @@
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)
}
@@ -1,16 +0,0 @@
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,9 +1,13 @@
import {requireNativeModule} from 'expo' import {requireNativeModule} from 'expo'
import {GooglePlayReferrerInfo} from './types' import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
export const NativeModule = requireNativeModule('ExpoBlueskyReferrer') export const NativeModule = requireNativeModule('ExpoBlueskyReferrer')
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> { export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo | null> {
return NativeModule.getGooglePlayReferrerInfoAsync() return NativeModule.getGooglePlayReferrerInfoAsync()
} }
export function getReferrerInfoAsync(): Promise<ReferrerInfo | null> {
return NativeModule.getReferrerInfoAsync()
}
@@ -0,0 +1,37 @@
import {SharedPrefs} from '../../index'
import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError()
}
export function getReferrerInfoAsync(): Promise<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: undefined,
}
}
}
const referrerApp = SharedPrefs.getString('referrerApp')
if (referrerApp) {
SharedPrefs.removeValue('referrerApp')
return {
referrer: referrerApp,
hostname: referrerApp,
}
}
return null
}
@@ -1,7 +1,10 @@
import {NotImplementedError} from '../NotImplemented' import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo} from './types' import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
// @ts-ignore throws
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> { export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError() throw new NotImplementedError()
} }
export function getReferrerInfoAsync(): Promise<ReferrerInfo | null> {
throw new NotImplementedError()
}
@@ -0,0 +1,33 @@
import {Platform} from 'react-native'
import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError()
}
export async function getReferrerInfoAsync(): Promise<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,7 +1,10 @@
export type GooglePlayReferrerInfo = export type GooglePlayReferrerInfo = {
| { installReferrer?: string
installReferrer?: string clickTimestamp?: number
clickTimestamp?: number installTimestamp?: number
installTimestamp?: number }
}
| undefined export type ReferrerInfo = {
referrer: string
hostname: string
}
@@ -0,0 +1,51 @@
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
}
@@ -0,0 +1,36 @@
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,16 +13,17 @@ import java.io.FileOutputStream
import java.net.URLEncoder import java.net.URLEncoder
class ExpoReceiveAndroidIntentsModule : Module() { class ExpoReceiveAndroidIntentsModule : Module() {
override fun definition() = ModuleDefinition { override fun definition() =
Name("ExpoReceiveAndroidIntents") ModuleDefinition {
Name("ExpoReceiveAndroidIntents")
OnNewIntent { OnNewIntent {
handleIntent(it) handleIntent(it)
}
} }
}
private fun handleIntent(intent: Intent?) { 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.action == Intent.ACTION_SEND) {
if (intent.type == "text/plain") { if (intent.type == "text/plain") {
@@ -40,7 +41,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleTextIntent(intent: Intent) { private fun handleTextIntent(intent: Intent) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let { intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
val encoded = URLEncoder.encode(it, "UTF-8") 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) val newIntent = Intent(Intent.ACTION_VIEW, uri)
appContext.currentActivity?.startActivity(newIntent) appContext.currentActivity?.startActivity(newIntent)
} }
@@ -48,11 +49,12 @@ class ExpoReceiveAndroidIntentsModule : Module() {
} }
private fun handleImageIntent(intent: Intent) { private fun handleImageIntent(intent: Intent) {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val uri =
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
} else { intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
intent.getParcelableExtra(Intent.EXTRA_STREAM) } else {
} intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
if (uri == null) return if (uri == null) return
handleImageIntents(listOf(uri)) handleImageIntents(listOf(uri))
@@ -76,16 +78,16 @@ class ExpoReceiveAndroidIntentsModule : Module() {
uris.forEachIndexed { index, uri -> uris.forEachIndexed { index, uri ->
val info = getImageInfo(uri) val info = getImageInfo(uri)
val params = buildUriData(info) val params = buildUriData(info)
allParams = "${allParams}${params}" allParams = "${allParams}$params"
if (index < uris.count() - 1) { if (index < uris.count() - 1) {
allParams = "${allParams}," allParams = "$allParams,"
} }
} }
val encoded = URLEncoder.encode(allParams, "UTF-8") 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) val newIntent = Intent(Intent.ACTION_VIEW, it)
appContext.currentActivity?.startActivity(newIntent) appContext.currentActivity?.startActivity(newIntent)
} }
@@ -104,7 +106,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
return mapOf( return mapOf(
"width" to bitmap.width, "width" to bitmap.width,
"height" to bitmap.height, "height" to bitmap.height,
"path" to file.path.toString() "path" to file.path.toString(),
) )
} }
@@ -114,6 +116,6 @@ class ExpoReceiveAndroidIntentsModule : Module() {
val path = info.getValue("path") val path = info.getValue("path")
val width = info.getValue("width") val width = info.getValue("width")
val height = info.getValue("height") 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 class ExpoScrollForwarderModule: Module {
public func definition() -> ModuleDefinition { public func definition() -> ModuleDefinition {
Name("ExpoScrollForwarder") Name("ExpoScrollForwarder")
View(ExpoScrollForwarderView.self) { View(ExpoScrollForwarderView.self) {
Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
view.scrollViewTag = prop view.scrollViewTag = prop
@@ -8,17 +8,17 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
self.tryFindScrollView() self.tryFindScrollView()
} }
} }
private var rctScrollView: RCTScrollView? private var rctScrollView: RCTScrollView?
private var rctRefreshCtrl: RCTRefreshControl? private var rctRefreshCtrl: RCTRefreshControl?
private var cancelGestureRecognizers: [UIGestureRecognizer]? private var cancelGestureRecognizers: [UIGestureRecognizer]?
private var animTimer: Timer? private var animTimer: Timer?
private var initialOffset: CGFloat = 0.0 private var initialOffset: CGFloat = 0.0
private var didImpact: Bool = false private var didImpact: Bool = false
required init(appContext: AppContext? = nil) { required init(appContext: AppContext? = nil) {
super.init(appContext: appContext) super.init(appContext: appContext)
let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:))) let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
pg.delegate = self pg.delegate = self
self.addGestureRecognizer(pg) self.addGestureRecognizer(pg)
@@ -34,28 +34,27 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
self.cancelGestureRecognizers = [lpg, tg] self.cancelGestureRecognizers = [lpg, tg]
} }
// We don't want to recognize the scroll pan gesture and the swipe back gesture together // We don't want to recognize the scroll pan gesture and the swipe back gesture together
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer { if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
return false return false
} }
return true return true
} }
// We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will // We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
// interfere with the native swipe back gesture. // interfere with the native swipe back gesture.
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else { guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
return true return true
} }
let velocity = gestureRecognizer.velocity(in: self) let velocity = gestureRecognizer.velocity(in: self)
return abs(velocity.y) > abs(velocity.x) 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 // 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. // recognizer for this one.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
@@ -64,32 +63,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 // 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. // the scroll view gesture's delegate, so we add an additional recognizer to detect this.
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void { @IBAction func callOnPress(_ sender: UITapGestureRecognizer) {
self.stopTimer() self.stopTimer()
} }
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) -> Void { @IBAction func callOnPan(_ sender: UIPanGestureRecognizer) {
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else { guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
return return
} }
let translation = sender.translation(in: self).y let translation = sender.translation(in: self).y
if sender.state == .began { if sender.state == .began {
if sv.contentOffset.y < 0 { if sv.contentOffset.y < 0 {
sv.contentOffset.y = 0 sv.contentOffset.y = 0
} }
self.initialOffset = sv.contentOffset.y self.initialOffset = sv.contentOffset.y
} }
if sender.state == .changed { if sender.state == .changed {
sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset) sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
if sv.contentOffset.y <= -130, !didImpact { if sv.contentOffset.y <= -130, !didImpact {
let generator = UIImpactFeedbackGenerator(style: .light) let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred() generator.impactOccurred()
self.didImpact = true self.didImpact = true
} }
} }
@@ -97,7 +96,7 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
if sender.state == .ended { if sender.state == .ended {
let velocity = sender.velocity(in: self).y let velocity = sender.velocity(in: self).y
self.didImpact = false self.didImpact = false
if sv.contentOffset.y <= -130 { if sv.contentOffset.y <= -130 {
self.rctRefreshCtrl?.forwarderBeginRefreshing() self.rctRefreshCtrl?.forwarderBeginRefreshing()
return return
@@ -108,40 +107,40 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
if abs(velocity) < 250, sv.contentOffset.y >= 0 { if abs(velocity) < 250, sv.contentOffset.y >= 0 {
return return
} }
self.startDecayAnimation(translation, velocity) self.startDecayAnimation(translation, velocity)
} }
} }
func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) { func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
guard let sv = self.rctScrollView?.scrollView else { guard let sv = self.rctScrollView?.scrollView else {
return return
} }
var velocity = velocity var velocity = velocity
self.enableCancelGestureRecognizers() self.enableCancelGestureRecognizers()
if velocity > 0 { if velocity > 0 {
velocity = min(velocity, 5000) velocity = min(velocity, 5000)
} else { } else {
velocity = max(velocity, -5000) velocity = max(velocity, -5000)
} }
var animTranslation = -translation var animTranslation = -translation
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { timer in self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { _ in
velocity *= 0.9875 velocity *= 0.9875
animTranslation = (-velocity / 120) + animTranslation animTranslation = (-velocity / 120) + animTranslation
let nextOffset = self.dampenOffset(animTranslation + self.initialOffset) let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
if nextOffset <= 0 { if nextOffset <= 0 {
if self.initialOffset <= 1 { if self.initialOffset <= 1 {
self.scrollToOffset(0) self.scrollToOffset(0)
} else { } else {
sv.contentOffset.y = 0 sv.contentOffset.y = 0
} }
self.stopTimer() self.stopTimer()
return return
} else { } else {
@@ -153,61 +152,60 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
} }
} }
} }
func dampenOffset(_ offset: CGFloat) -> CGFloat { func dampenOffset(_ offset: CGFloat) -> CGFloat {
if offset < 0 { if offset < 0 {
return offset - (offset * 0.55) return offset - (offset * 0.55)
} }
return offset return offset
} }
func tryFindScrollView() { func tryFindScrollView() {
guard let scrollViewTag = scrollViewTag else { guard let scrollViewTag = scrollViewTag else {
return return
} }
// Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer. // 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. // Otherwise we might end up with duplicates when we switch back to that scrollview.
self.removeCancelGestureRecognizers() self.removeCancelGestureRecognizers()
self.rctScrollView = self.appContext? self.rctScrollView = self.appContext?
.findView(withTag: scrollViewTag, ofType: RCTScrollView.self) .findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
self.addCancelGestureRecognizers() self.addCancelGestureRecognizers()
} }
func addCancelGestureRecognizers() { func addCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.addGestureRecognizer(r) self.rctScrollView?.scrollView?.addGestureRecognizer(r)
} }
} }
func removeCancelGestureRecognizers() { func removeCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.removeGestureRecognizer(r) self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
} }
} }
func enableCancelGestureRecognizers() { func enableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = true r.isEnabled = true
} }
} }
func disableCancelGestureRecognizers() { func disableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = false r.isEnabled = false
} }
} }
func scrollToOffset(_ offset: Int, animated: Bool = true) -> Void { func scrollToOffset(_ offset: Int, animated: Bool = true) {
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated) self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
} }
func stopTimer() -> Void { func stopTimer() {
self.disableCancelGestureRecognizers() self.disableCancelGestureRecognizers()
self.animTimer?.invalidate() self.animTimer?.invalidate()
self.animTimer = nil self.animTimer = nil
+4 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.88.0", "version": "1.89.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -29,9 +29,12 @@
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit", "test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage", "test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src", "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", "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: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": "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__", "e2e:run": "maestro test __e2e__",
"perf:test": "NODE_ENV=test maestro test", "perf:test": "NODE_ENV=test maestro test",
"perf:test:run": "NODE_ENV=test maestro test __e2e__/perf-test.yml", "perf:test:run": "NODE_ENV=test maestro test __e2e__/perf-test.yml",
+41
View File
@@ -0,0 +1,41 @@
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
})
}
+20 -1
View File
@@ -31,7 +31,7 @@ import {
} from 'lib/routes/types' } from 'lib/routes/types'
import {RouteParams, State} from 'lib/routes/types' import {RouteParams, State} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings' import {bskyTitle} from 'lib/strings/headings'
import {isAndroid, isNative} from 'platform/detection' import {isAndroid, isNative, isWeb} from 'platform/detection'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {AppPasswords} from 'view/screens/AppPasswords' import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
@@ -39,6 +39,7 @@ import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed' import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads' import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {SavedFeeds} from 'view/screens/SavedFeeds' import {SavedFeeds} from 'view/screens/SavedFeeds'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag' import HashtagScreen from '#/screens/Hashtag'
import {ModerationScreen} from '#/screens/Moderation' import {ModerationScreen} from '#/screens/Moderation'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
@@ -48,6 +49,7 @@ import {
StarterPackScreenShort, StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen' } from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard' import {Wizard} from '#/screens/StarterPack/Wizard'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {init as initAnalytics} from './lib/analytics/analytics' import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
@@ -233,6 +235,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => DebugModScreen} getComponent={() => DebugModScreen}
options={{title: title(msg`Moderation states`), requireAuth: true}} options={{title: title(msg`Moderation states`), requireAuth: true}}
/> />
<Stack.Screen
name="SharedPreferencesTester"
getComponent={() => SharedPreferencesTesterScreen}
options={{title: title(msg`Shared Preferences Tester`)}}
/>
<Stack.Screen <Stack.Screen
name="Log" name="Log"
getComponent={() => LogScreen} getComponent={() => LogScreen}
@@ -763,6 +770,18 @@ function logModuleInitTime() {
initMs, initMs,
}) })
if (isWeb) {
Referrer.getReferrerInfoAsync().then(info => {
if (info && info.hostname !== 'bsky.app') {
logEvent('deepLink:referrerReceived', {
to: window.location.href,
referrer: info?.referrer,
hostname: info?.hostname,
})
}
})
}
if (__DEV__) { if (__DEV__) {
// This log is noisy, so keep false committed // This log is noisy, so keep false committed
const shouldLog = false const shouldLog = false
+16 -5
View File
@@ -1,7 +1,9 @@
import React from 'react' import React from 'react'
import {Dimensions} from 'react-native' import {Dimensions} from 'react-native'
import * as themes from '#/alf/themes' import {createThemes, defaultTheme} from '#/alf/themes'
import {Theme, ThemeName} from '#/alf/types'
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
export {atoms} from '#/alf/atoms' export {atoms} from '#/alf/atoms'
export * as tokens from '#/alf/tokens' export * as tokens from '#/alf/tokens'
@@ -39,8 +41,8 @@ function getActiveBreakpoints({width}: {width: number}) {
* Context * Context
*/ */
export const Context = React.createContext<{ export const Context = React.createContext<{
themeName: themes.ThemeName themeName: ThemeName
theme: themes.Theme theme: Theme
breakpoints: { breakpoints: {
active: BreakpointName | undefined active: BreakpointName | undefined
gtPhone: boolean gtPhone: boolean
@@ -49,7 +51,7 @@ export const Context = React.createContext<{
} }
}>({ }>({
themeName: 'light', themeName: 'light',
theme: themes.light, theme: defaultTheme,
breakpoints: { breakpoints: {
active: undefined, active: undefined,
gtPhone: false, gtPhone: false,
@@ -61,7 +63,16 @@ export const Context = React.createContext<{
export function ThemeProvider({ export function ThemeProvider({
children, children,
theme: themeName, theme: themeName,
}: React.PropsWithChildren<{theme: themes.ThemeName}>) { }: React.PropsWithChildren<{theme: ThemeName}>) {
const themes = React.useMemo(() => {
return createThemes({
hues: {
primary: BLUE_HUE,
negative: RED_HUE,
positive: GREEN_HUE,
},
})
}, [])
const theme = themes[themeName] const theme = themes[themeName]
const [breakpoints, setBreakpoints] = React.useState(() => const [breakpoints, setBreakpoints] = React.useState(() =>
getActiveBreakpoints({width: Dimensions.get('window').width}), getActiveBreakpoints({width: Dimensions.get('window').width}),
+569 -447
View File
File diff suppressed because it is too large Load Diff
-78
View File
@@ -1,77 +1,6 @@
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 = { export const color = {
trueBlack: '#000000',
temp_purple: 'rgb(105 0 255)', temp_purple: 'rgb(105 0 255)',
temp_purple_dark: 'rgb(83 0 202)', 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 } as const
export const space = { export const space = {
@@ -178,10 +107,3 @@ export const gradients = {
hover_value: '#755B62', hover_value: '#755B62',
}, },
} as const } 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
+154 -18
View File
@@ -1,21 +1,4 @@
import {StyleProp, ViewStyle, TextStyle} from 'react-native' import {StyleProp, TextStyle, ViewStyle} 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 = { export type TextStyleProp = {
style?: StyleProp<TextStyle> style?: StyleProp<TextStyle>
@@ -24,3 +7,156 @@ export type TextStyleProp = {
export type ViewStyleProp = { export type ViewStyleProp = {
style?: StyleProp<ViewStyle> 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,3 +15,7 @@ export function generateScale(start: number, end: number) {
return start + range * stop 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/themes' import {ThemeName} from '#/alf/types'
export function select<T>(name: ThemeName, options: Record<ThemeName, T>) { export function select<T>(name: ThemeName, options: Record<ThemeName, T>) {
switch (name) { switch (name) {
+2 -1
View File
@@ -4,7 +4,8 @@ import * as SystemUI from 'expo-system-ui'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {useThemePrefs} from 'state/shell' import {useThemePrefs} from 'state/shell'
import {dark, dim, light, ThemeName} from '#/alf/themes' import {dark, dim, light} from '#/alf/themes'
import {ThemeName} from '#/alf/types'
export function useColorModeTheme(): ThemeName { export function useColorModeTheme(): ThemeName {
const theme = useThemeName() const theme = useThemeName()
+34 -28
View File
@@ -13,7 +13,7 @@ import {
} from 'react-native' } from 'react-native'
import {LinearGradient} from 'expo-linear-gradient' import {LinearGradient} from 'expo-linear-gradient'
import {android, atoms as a, flatten, tokens, useTheme} from '#/alf' import {android, atoms as a, flatten, select, tokens, useTheme} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common' import {Props as SVGIconProps} from '#/components/icons/common'
import {normalizeTextStyles} from '#/components/Typography' import {normalizeTextStyles} from '#/components/Typography'
@@ -152,7 +152,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
const {baseStyles, hoverStyles} = React.useMemo(() => { const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = [] const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = [] const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
if (color === 'primary') { if (color === 'primary') {
if (variant === 'solid') { if (variant === 'solid') {
@@ -165,7 +164,11 @@ export const Button = React.forwardRef<View, ButtonProps>(
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
backgroundColor: t.palette.primary_700, backgroundColor: select(t.name, {
light: t.palette.primary_700,
dim: t.palette.primary_300,
dark: t.palette.primary_300,
}),
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
@@ -178,24 +181,18 @@ export const Button = React.forwardRef<View, ButtonProps>(
borderColor: t.palette.primary_500, borderColor: t.palette.primary_500,
}) })
hoverStyles.push(a.border, { hoverStyles.push(a.border, {
backgroundColor: light backgroundColor: t.palette.primary_50,
? t.palette.primary_50
: t.palette.primary_950,
}) })
} else { } else {
baseStyles.push(a.border, { baseStyles.push(a.border, {
borderColor: light borderColor: t.palette.primary_200,
? t.palette.primary_200
: t.palette.primary_900,
}) })
} }
} else if (variant === 'ghost') { } else if (variant === 'ghost') {
if (!disabled) { if (!disabled) {
baseStyles.push(t.atoms.bg) baseStyles.push(t.atoms.bg)
hoverStyles.push({ hoverStyles.push({
backgroundColor: light backgroundColor: t.palette.primary_100,
? t.palette.primary_100
: t.palette.primary_900,
}) })
} }
} }
@@ -203,14 +200,26 @@ export const Button = React.forwardRef<View, ButtonProps>(
if (variant === 'solid') { if (variant === 'solid') {
if (!disabled) { if (!disabled) {
baseStyles.push({ baseStyles.push({
backgroundColor: t.palette.contrast_25, backgroundColor: select(t.name, {
light: t.palette.contrast_25,
dim: t.palette.contrast_100,
dark: t.palette.contrast_100,
}),
}) })
hoverStyles.push({ hoverStyles.push({
backgroundColor: t.palette.contrast_50, backgroundColor: select(t.name, {
light: t.palette.contrast_50,
dim: t.palette.contrast_200,
dark: t.palette.contrast_200,
}),
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
backgroundColor: t.palette.contrast_100, backgroundColor: select(t.name, {
light: t.palette.contrast_100,
dim: t.palette.contrast_25,
dark: t.palette.contrast_25,
}),
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
@@ -247,7 +256,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
backgroundColor: t.palette.contrast_700, backgroundColor: t.palette.contrast_600,
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
@@ -284,7 +293,11 @@ export const Button = React.forwardRef<View, ButtonProps>(
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
backgroundColor: t.palette.negative_700, backgroundColor: select(t.name, {
light: t.palette.negative_700,
dim: t.palette.negative_300,
dark: t.palette.negative_300,
}),
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
@@ -297,24 +310,18 @@ export const Button = React.forwardRef<View, ButtonProps>(
borderColor: t.palette.negative_500, borderColor: t.palette.negative_500,
}) })
hoverStyles.push(a.border, { hoverStyles.push(a.border, {
backgroundColor: light backgroundColor: t.palette.negative_50,
? t.palette.negative_50
: t.palette.negative_975,
}) })
} else { } else {
baseStyles.push(a.border, { baseStyles.push(a.border, {
borderColor: light borderColor: t.palette.negative_200,
? t.palette.negative_200
: t.palette.negative_900,
}) })
} }
} else if (variant === 'ghost') { } else if (variant === 'ghost') {
if (!disabled) { if (!disabled) {
baseStyles.push(t.atoms.bg) baseStyles.push(t.atoms.bg)
hoverStyles.push({ hoverStyles.push({
backgroundColor: light backgroundColor: t.palette.negative_100,
? t.palette.negative_100
: t.palette.negative_975,
}) })
} }
} }
@@ -482,7 +489,6 @@ export function useSharedButtonTextStyles() {
const {color, variant, disabled, size} = useButtonContext() const {color, variant, disabled, size} = useButtonContext()
return React.useMemo(() => { return React.useMemo(() => {
const baseStyles: TextStyle[] = [] const baseStyles: TextStyle[] = []
const light = t.name === 'light'
if (color === 'primary') { if (color === 'primary') {
if (variant === 'solid') { if (variant === 'solid') {
@@ -494,7 +500,7 @@ export function useSharedButtonTextStyles() {
} else if (variant === 'outline') { } else if (variant === 'outline') {
if (!disabled) { if (!disabled) {
baseStyles.push({ baseStyles.push({
color: light ? t.palette.primary_600 : t.palette.primary_500, color: t.palette.primary_600,
}) })
} else { } else {
baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
+6 -2
View File
@@ -13,6 +13,7 @@ import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {useProfilesQuery} from '#/state/queries/profile' import {useProfilesQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide' import {useProgressGuide} from '#/state/shell/progress-guide'
import * as userActionHistory from '#/state/userActionHistory' import * as userActionHistory from '#/state/userActionHistory'
import {SeenPost} from '#/state/userActionHistory' import {SeenPost} from '#/state/userActionHistory'
@@ -127,6 +128,7 @@ function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
} }
function useExperimentalSuggestedUsersQuery() { function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot() const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => { const dids = React.useMemo(() => {
const {likes, follows, seen} = userActionSnapshot const {likes, follows, seen} = userActionSnapshot
@@ -138,8 +140,10 @@ function useExperimentalSuggestedUsersQuery() {
.sort(sortSeenPosts) .sort(sortSeenPosts)
.map(l => new AtUri(l.uri)) .map(l => new AtUri(l.uri))
.map(uri => uri.host) .map(uri => uri.host)
return [...new Set([...likeDids, ...seenDids])] return [...new Set([...likeDids, ...seenDids])].filter(
}, [userActionSnapshot]) did => did !== currentAccount?.did,
)
}, [userActionSnapshot, currentAccount])
const {data, isLoading, error} = useProfilesQuery({ const {data, isLoading, error} = useProfilesQuery({
handles: dids.slice(0, 16), handles: dids.slice(0, 16),
}) })
+5 -6
View File
@@ -2,14 +2,14 @@ import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import { import {
useTheme,
atoms as a, atoms as a,
ViewStyleProp,
TextStyleProp,
flatten, flatten,
TextStyleProp,
useTheme,
ViewStyleProp,
} from '#/alf' } from '#/alf'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
import {Props} from '#/components/icons/common' import {Props} from '#/components/icons/common'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
export function IconCircle({ export function IconCircle({
icon: Icon, icon: Icon,
@@ -32,8 +32,7 @@ export function IconCircle({
{ {
width: size === 'lg' ? 52 : 64, width: size === 'lg' ? 52 : 64,
height: size === 'lg' ? 52 : 64, height: size === 'lg' ? 52 : 64,
backgroundColor: backgroundColor: t.palette.primary_50,
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
}, },
flatten(style), flatten(style),
]}> ]}>
+14 -6
View File
@@ -12,6 +12,7 @@ import {Link, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
const AVI_SIZE = 30 const AVI_SIZE = 30
const AVI_SIZE_SMALL = 20
const AVI_BORDER = 1 const AVI_BORDER = 1
/** /**
@@ -30,10 +31,12 @@ export function KnownFollowers({
profile, profile,
moderationOpts, moderationOpts,
onLinkPress, onLinkPress,
minimal,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
onLinkPress?: LinkProps['onPress'] onLinkPress?: LinkProps['onPress']
minimal?: boolean
}) { }) {
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>( const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
new Map(), new Map(),
@@ -59,6 +62,7 @@ export function KnownFollowers({
cachedKnownFollowers={cachedKnownFollowers} cachedKnownFollowers={cachedKnownFollowers}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
onLinkPress={onLinkPress} onLinkPress={onLinkPress}
minimal={minimal}
/> />
) )
} }
@@ -71,11 +75,13 @@ function KnownFollowersInner({
moderationOpts, moderationOpts,
cachedKnownFollowers, cachedKnownFollowers,
onLinkPress, onLinkPress,
minimal,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
onLinkPress?: LinkProps['onPress'] onLinkPress?: LinkProps['onPress']
minimal?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -110,6 +116,8 @@ function KnownFollowersInner({
*/ */
if (slice.length === 0) return null if (slice.length === 0) return null
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
return ( return (
<Link <Link
label={_( label={_(
@@ -120,7 +128,7 @@ function KnownFollowersInner({
style={[ style={[
a.flex_1, a.flex_1,
a.flex_row, a.flex_row,
a.gap_md, minimal ? a.gap_sm : a.gap_md,
a.align_center, a.align_center,
{marginLeft: -AVI_BORDER}, {marginLeft: -AVI_BORDER},
]}> ]}>
@@ -129,8 +137,8 @@ function KnownFollowersInner({
<View <View
style={[ style={[
{ {
height: AVI_SIZE, height: SIZE,
width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap, width: SIZE + (slice.length - 1) * a.gap_md.gap,
}, },
pressed && { pressed && {
opacity: 0.5, opacity: 0.5,
@@ -145,14 +153,14 @@ function KnownFollowersInner({
{ {
borderWidth: AVI_BORDER, borderWidth: AVI_BORDER,
borderColor: t.atoms.bg.backgroundColor, borderColor: t.atoms.bg.backgroundColor,
width: AVI_SIZE + AVI_BORDER * 2, width: SIZE + AVI_BORDER * 2,
height: AVI_SIZE + AVI_BORDER * 2, height: SIZE + AVI_BORDER * 2,
left: i * a.gap_md.gap, left: i * a.gap_md.gap,
zIndex: AVI_BORDER - i, zIndex: AVI_BORDER - i,
}, },
]}> ]}>
<UserAvatar <UserAvatar
size={AVI_SIZE} size={SIZE}
avatar={prof.avatar} avatar={prof.avatar}
moderation={moderation.ui('avatar')} moderation={moderation.ui('avatar')}
/> />
+3 -1
View File
@@ -166,7 +166,9 @@ export function NameAndHandle({
return ( return (
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}> <Text
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{name} {name}
</Text> </Text>
<Text <Text
@@ -462,7 +462,8 @@ function Inner({
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}> <Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
<View style={[a.pb_sm, a.flex_1]}> <View style={[a.pb_sm, a.flex_1]}>
<Text style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold]}> <Text
style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold, a.self_start]}>
{sanitizeDisplayName( {sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle), profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'), moderation.ui('displayName'),
+26 -9
View File
@@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation' import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions' import {ReportOption} from '#/lib/moderation/useReportOptions'
import {useGate} from '#/lib/statsig/statsig'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@@ -36,6 +37,7 @@ export function SubmitView({
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent() const agent = useAgent()
const gate = useGate()
const [details, setDetails] = React.useState<string>('') const [details, setDetails] = React.useState<string>('')
const [submitting, setSubmitting] = React.useState<boolean>(false) const [submitting, setSubmitting] = React.useState<boolean>(false)
const [selectedServices, setSelectedServices] = React.useState<string[]>([ const [selectedServices, setSelectedServices] = React.useState<string[]>([
@@ -60,15 +62,29 @@ export function SubmitView({
reason: details, reason: details,
} }
const results = await Promise.all( const results = await Promise.all(
selectedServices.map(did => selectedServices.map(did => {
agent if (gate('session_withproxy_fix')) {
.withProxy('atproto_labeler', did) return agent
.createModerationReport(report) .createModerationReport(report, {
.then( encoding: 'application/json',
_ => true, headers: {
_ => false, 'atproto-proxy': `${did}#atproto_labeler`,
), },
), })
.then(
_ => true,
_ => false,
)
} else {
return agent
.withProxy('atproto_labeler', did)
.createModerationReport(report)
.then(
_ => true,
_ => false,
)
}
}),
) )
setSubmitting(false) setSubmitting(false)
@@ -92,6 +108,7 @@ export function SubmitView({
onSubmitComplete, onSubmitComplete,
setError, setError,
agent, agent,
gate,
]) ])
return ( return (
+1 -1
View File
@@ -110,7 +110,7 @@ export function QrCodeInner({link}: {link: string}) {
innerEyesOptions={{borderRadius: 3}} innerEyesOptions={{borderRadius: 3}}
logo={{ logo={{
href: require('../../../assets/logo.png'), href: require('../../../assets/logo.png'),
scale: 1.2, scale: 0.95,
padding: 2, padding: 2,
hidePieces: true, hidePieces: true,
}} }}
@@ -78,7 +78,13 @@ function WizardListCard({
/> />
<View style={[a.flex_1, a.gap_2xs]}> <View style={[a.flex_1, a.gap_2xs]}>
<Text <Text
style={[a.flex_1, a.font_bold, a.text_md, a.leading_tight]} style={[
a.flex_1,
a.font_bold,
a.text_md,
a.leading_tight,
a.self_start,
]}
numberOfLines={1}> numberOfLines={1}>
{displayName} {displayName}
</Text> </Text>
+3 -4
View File
@@ -357,12 +357,11 @@ function TargetToggle({children}: React.PropsWithChildren<{}>) {
a.px_sm, a.px_sm,
gtMobile && a.px_md, gtMobile && a.px_md,
a.rounded_sm, a.rounded_sm,
t.atoms.bg_contrast_50, t.atoms.bg_contrast_25,
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100, (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_50,
ctx.selected && [ ctx.selected && [
{ {
backgroundColor: backgroundColor: t.palette.primary_50,
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
}, },
], ],
ctx.disabled && { ctx.disabled && {
+1 -4
View File
@@ -196,10 +196,7 @@ function Selectable({
t.atoms.bg_contrast_50, t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100, (hovered || focused) && t.atoms.bg_contrast_100,
isSelected && { isSelected && {
backgroundColor: backgroundColor: t.palette.primary_100,
t.name === 'light'
? t.palette.primary_50
: t.palette.primary_975,
}, },
style, style,
]}> ]}>
+2 -8
View File
@@ -72,8 +72,7 @@ let MessageItem = ({
lastInGroupRef.current = isLastInGroup lastInGroupRef.current = isLastInGroup
} }
const pendingColor = const pendingColor = t.palette.primary_200
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
const rt = useMemo(() => { const rt = useMemo(() => {
return new RichTextAPI({text: message.text, facets: message.facets}) return new RichTextAPI({text: message.text, facets: message.facets})
@@ -110,12 +109,7 @@ let MessageItem = ({
}> }>
<RichText <RichText
value={rt} value={rt}
style={[ style={[a.text_md, isFromSelf && {color: t.palette.white}]}
a.text_md,
isFromSelf && {color: t.palette.white},
isPending &&
t.name !== 'light' && {color: t.palette.primary_300},
]}
interactiveStyle={a.underline} interactiveStyle={a.underline}
enableTags enableTags
emojiMultiplier={3} emojiMultiplier={3}
+6 -1
View File
@@ -168,7 +168,12 @@ function HeaderReady({
</View> </View>
<View style={a.flex_1}> <View style={a.flex_1}>
<Text <Text
style={[a.text_md, a.font_bold, web(a.leading_normal)]} style={[
a.text_md,
a.font_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}> numberOfLines={1}>
{displayName} {displayName}
</Text> </Text>
@@ -395,7 +395,7 @@ function ProfileCard({
/> />
<View style={[a.flex_1, a.gap_2xs]}> <View style={[a.flex_1, a.gap_2xs]}>
<Text <Text
style={[t.atoms.text, a.font_bold, a.leading_tight]} style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
numberOfLines={1}> numberOfLines={1}>
{displayName} {displayName}
</Text> </Text>
+3 -6
View File
@@ -101,16 +101,13 @@ export function useSharedInputStyles() {
] ]
const error: ViewStyle[] = [ const error: ViewStyle[] = [
{ {
backgroundColor: backgroundColor: t.palette.negative_25,
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, borderColor: t.palette.negative_300,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
}, },
] ]
const errorHover: ViewStyle[] = [ const errorHover: ViewStyle[] = [
{ {
backgroundColor: backgroundColor: t.palette.negative_25,
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: t.palette.negative_500, borderColor: t.palette.negative_500,
}, },
] ]
+7 -14
View File
@@ -281,24 +281,20 @@ export function createSharedToggleStyles({
if (selected) { if (selected) {
base.push({ base.push({
backgroundColor: backgroundColor: t.palette.primary_25,
t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900,
borderColor: t.palette.primary_500, borderColor: t.palette.primary_500,
}) })
if (hovered) { if (hovered) {
baseHover.push({ baseHover.push({
backgroundColor: backgroundColor: t.palette.primary_100,
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800, borderColor: t.palette.primary_600,
borderColor:
t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400,
}) })
} }
} else { } else {
if (hovered) { if (hovered) {
baseHover.push({ baseHover.push({
backgroundColor: backgroundColor: t.palette.contrast_50,
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
borderColor: t.palette.contrast_500, borderColor: t.palette.contrast_500,
}) })
} }
@@ -306,16 +302,13 @@ export function createSharedToggleStyles({
if (isInvalid) { if (isInvalid) {
base.push({ base.push({
backgroundColor: backgroundColor: t.palette.negative_25,
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975, borderColor: t.palette.negative_300,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
}) })
if (hovered) { if (hovered) {
baseHover.push({ baseHover.push({
backgroundColor: backgroundColor: t.palette.negative_25,
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: t.palette.negative_600, borderColor: t.palette.negative_600,
}) })
} }
@@ -7,7 +7,7 @@ import {
import {isAndroid} from 'platform/detection' import {isAndroid} from 'platform/detection'
import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
import {useSetActiveStarterPack} from 'state/shell/starter-pack' import {useSetActiveStarterPack} from 'state/shell/starter-pack'
import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army' import {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
export function useStarterPackEntry() { export function useStarterPackEntry() {
const [ready, setReady] = React.useState(false) const [ready, setReady] = React.useState(false)
@@ -39,14 +39,10 @@ export function useStarterPackEntry() {
uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer) uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer)
} }
} else { } else {
const res = await DevicePrefs.getStringValueAsync( const starterPackUri = SharedPrefs.getString('starterPackUri')
'starterPackUri', if (starterPackUri) {
true, uri = httpStarterPackUriToAtUri(starterPackUri)
) SharedPrefs.setValue('starterPackUri', null)
if (res) {
uri = httpStarterPackUriToAtUri(res)
DevicePrefs.setStringValueAsync('starterPackUri', null, true)
} }
} }
+3 -2
View File
@@ -4,7 +4,7 @@ import type {PathProps, SvgProps} from 'react-native-svg'
import {Defs, LinearGradient, Stop} from 'react-native-svg' import {Defs, LinearGradient, Stop} from 'react-native-svg'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {tokens} from '#/alf' import {tokens, useTheme} from '#/alf'
export type Props = { export type Props = {
fill?: PathProps['fill'] fill?: PathProps['fill']
@@ -22,10 +22,11 @@ export const sizes = {
} }
export function useCommonSVGProps(props: Props) { export function useCommonSVGProps(props: Props) {
const t = useTheme()
const {fill, size, gradient, ...rest} = props const {fill, size, gradient, ...rest} = props
const style = StyleSheet.flatten(rest.style) const style = StyleSheet.flatten(rest.style)
const _size = Number(size ? sizes[size] : rest.width || sizes.md) const _size = Number(size ? sizes[size] : rest.width || sizes.md)
let _fill = fill || style?.color || tokens.color.blue_500 let _fill = fill || style?.color || t.palette.primary_500
let gradientDef = null let gradientDef = null
if (gradient && tokens.gradients[gradient]) { if (gradient && tokens.gradients[gradient]) {
+30 -9
View File
@@ -7,6 +7,7 @@ import {useMutation} from '@tanstack/react-query'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo' import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {useGate} from '#/lib/statsig/statsig'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
@@ -201,22 +202,42 @@ function AppealForm({
const [details, setDetails] = React.useState('') const [details, setDetails] = React.useState('')
const isAccountReport = 'did' in subject const isAccountReport = 'did' in subject
const agent = useAgent() const agent = useAgent()
const gate = useGate()
const {mutate, isPending} = useMutation({ const {mutate, isPending} = useMutation({
mutationFn: async () => { mutationFn: async () => {
const $type = !isAccountReport const $type = !isAccountReport
? 'com.atproto.repo.strongRef' ? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef' : 'com.atproto.admin.defs#repoRef'
await agent if (gate('session_withproxy_fix')) {
.withProxy('atproto_labeler', label.src) await agent.createModerationReport(
.createModerationReport({ {
reasonType: ComAtprotoModerationDefs.REASONAPPEAL, reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: { subject: {
$type, $type,
...subject, ...subject,
},
reason: details,
}, },
reason: details, {
}) 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,
})
}
}, },
onError: err => { onError: err => {
logger.error('Failed to submit label appeal', {message: err}) logger.error('Failed to submit label appeal', {message: err})
+3 -2
View File
@@ -1,7 +1,8 @@
import React, {ReactNode, createContext, useContext} from 'react' import React, {createContext, ReactNode, useContext} from 'react'
import {TextStyle, ViewStyle} from 'react-native' import {TextStyle, ViewStyle} from 'react-native'
import {ThemeName} from '#/alf/types'
import {darkTheme, defaultTheme, dimTheme} from './themes' import {darkTheme, defaultTheme, dimTheme} from './themes'
import {ThemeName} from '#/alf/themes'
export type ColorScheme = 'light' | 'dark' export type ColorScheme = 'light' | 'dark'
+14 -1
View File
@@ -1,9 +1,12 @@
import React from 'react' import React from 'react'
import * as Linking from 'expo-linking' import * as Linking from 'expo-linking'
import {logEvent} from 'lib/statsig/statsig'
import {isNative} from 'platform/detection' import {isNative} from 'platform/detection'
import {useComposerControls} from 'state/shell'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {useComposerControls} from 'state/shell'
import {useCloseAllActiveElements} from 'state/util' import {useCloseAllActiveElements} from 'state/util'
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
type IntentType = 'compose' type IntentType = 'compose'
@@ -15,6 +18,16 @@ export function useIntentHandler() {
React.useEffect(() => { React.useEffect(() => {
const handleIncomingURL = (url: string) => { const handleIncomingURL = (url: string) => {
Referrer.getReferrerInfoAsync().then(info => {
if (info && info.hostname !== 'bsky.app') {
logEvent('deepLink:referrerReceived', {
to: url,
referrer: info?.referrer,
hostname: info?.hostname,
})
}
})
// We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
// slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
// of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
+1
View File
@@ -25,6 +25,7 @@ export type CommonNavigatorParams = {
ProfileLabelerLikedBy: {name: string} ProfileLabelerLikedBy: {name: string}
Debug: undefined Debug: undefined
DebugMod: undefined DebugMod: undefined
SharedPreferencesTester: undefined
Log: undefined Log: undefined
Support: undefined Support: undefined
PrivacyPolicy: undefined PrivacyPolicy: undefined
+5
View File
@@ -25,6 +25,11 @@ export type LogEvents = {
} }
'state:foreground:sampled': {} 'state:foreground:sampled': {}
'router:navigate:sampled': {} 'router:navigate:sampled': {}
'deepLink:referrerReceived': {
to: string
referrer: string
hostname: string
}
// Screen events // Screen events
'splash:signInPressed': {} 'splash:signInPressed': {}
+2
View File
@@ -1,11 +1,13 @@
export type Gate = export type Gate =
// Keep this alphabetic please. // Keep this alphabetic please.
| 'debug_show_feedcontext' | 'debug_show_feedcontext'
| 'explore_page_profile_card_social_proof'
| 'native_pwi_disabled' | 'native_pwi_disabled'
| 'new_user_guided_tour' | 'new_user_guided_tour'
| 'new_user_progress_guide' | 'new_user_progress_guide'
| 'onboarding_minimum_interests' | 'onboarding_minimum_interests'
| 'request_notifications_permission_after_onboarding_v2' | 'request_notifications_permission_after_onboarding_v2'
| 'session_withproxy_fix'
| 'show_avi_follow_button' | 'show_avi_follow_button'
| 'show_follow_back_label_v2' | 'show_follow_back_label_v2'
| 'suggested_feeds_interstitial' | 'suggested_feeds_interstitial'
-21
View File
@@ -28,8 +28,6 @@ type StatsigUser = {
bundleDate: number bundleDate: number
refSrc: string refSrc: string
refUrl: string refUrl: string
referrer: string
referrerHostname: string
appLanguage: string appLanguage: string
contentLanguages: string[] contentLanguages: string[]
} }
@@ -37,29 +35,12 @@ type StatsigUser = {
let refSrc = '' let refSrc = ''
let refUrl = '' let refUrl = ''
let referrer = ''
let referrerHostname = ''
if (isWeb && typeof window !== 'undefined') { if (isWeb && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
refSrc = params.get('ref_src') ?? '' refSrc = params.get('ref_src') ?? ''
refUrl = decodeURIComponent(params.get('ref_url') ?? '') refUrl = decodeURIComponent(params.get('ref_url') ?? '')
} }
if (
isWeb &&
typeof document !== 'undefined' &&
document != null &&
document.referrer
) {
try {
const url = new URL(document.referrer)
if (url.hostname !== 'bsky.app') {
referrer = document.referrer
referrerHostname = url.hostname
}
} catch {}
}
export type {LogEvents} export type {LogEvents}
function createStatsigOptions(prefetchUsers: StatsigUser[]) { function createStatsigOptions(prefetchUsers: StatsigUser[]) {
@@ -222,8 +203,6 @@ function toStatsigUser(did: string | undefined): StatsigUser {
custom: { custom: {
refSrc, refSrc,
refUrl, refUrl,
referrer,
referrerHostname,
platform: Platform.OS as 'ios' | 'android' | 'web', platform: Platform.OS as 'ios' | 'android' | 'web',
bundleIdentifier: BUNDLE_IDENTIFIER, bundleIdentifier: BUNDLE_IDENTIFIER,
bundleDate: BUNDLE_DATE, bundleDate: BUNDLE_DATE,
+1
View File
@@ -0,0 +1 @@
export const NON_BREAKING_SPACE = '\u00A0'
+7 -7
View File
@@ -1,8 +1,8 @@
import {Platform} from 'react-native' import {Platform} from 'react-native'
import type {Theme} from './ThemeContext'
import {colors} from './styles'
import {darkPalette, lightPalette, dimPalette} from '#/alf/themes' import {darkPalette, dimPalette, lightPalette} from '#/alf/themes'
import {colors} from './styles'
import type {Theme} from './ThemeContext'
export const defaultTheme: Theme = { export const defaultTheme: Theme = {
colorScheme: 'light', colorScheme: 'light',
@@ -308,8 +308,8 @@ export const darkTheme: Theme = {
textVeryLight: darkPalette.contrast_400, textVeryLight: darkPalette.contrast_400,
replyLine: darkPalette.contrast_200, replyLine: darkPalette.contrast_200,
replyLineDot: darkPalette.contrast_200, replyLineDot: darkPalette.contrast_200,
unreadNotifBg: darkPalette.primary_975, unreadNotifBg: darkPalette.primary_25,
unreadNotifBorder: darkPalette.primary_900, unreadNotifBorder: darkPalette.primary_100,
postCtrl: darkPalette.contrast_500, postCtrl: darkPalette.contrast_500,
brandText: darkPalette.primary_500, brandText: darkPalette.primary_500,
emptyStateIcon: darkPalette.contrast_300, emptyStateIcon: darkPalette.contrast_300,
@@ -357,8 +357,8 @@ export const dimTheme: Theme = {
textVeryLight: dimPalette.contrast_400, textVeryLight: dimPalette.contrast_400,
replyLine: dimPalette.contrast_200, replyLine: dimPalette.contrast_200,
replyLineDot: dimPalette.contrast_200, replyLineDot: dimPalette.contrast_200,
unreadNotifBg: dimPalette.primary_975, unreadNotifBg: dimPalette.primary_25,
unreadNotifBorder: dimPalette.primary_900, unreadNotifBorder: dimPalette.primary_100,
postCtrl: dimPalette.contrast_500, postCtrl: dimPalette.contrast_500,
brandText: dimPalette.primary_500, brandText: dimPalette.primary_500,
emptyStateIcon: dimPalette.contrast_300, emptyStateIcon: dimPalette.contrast_300,
@@ -0,0 +1,113 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'view/com/util/Views'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import {SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
export function SharedPreferencesTesterScreen() {
const [currentTestOutput, setCurrentTestOutput] = React.useState<string>('')
return (
<ScrollView contentContainerStyle={{backgroundColor: 'red'}}>
<View style={[a.flex_1]}>
<View>
<Text testID="testOutput">{currentTestOutput}</Text>
</View>
<View style={[a.flex_wrap]}>
<Button
label="btn"
testID="setStringBtn"
style={[a.self_center]}
variant="solid"
color="primary"
size="xsmall"
onPress={async () => {
SharedPrefs.removeValue('testerString')
SharedPrefs.setValue('testerString', 'Hello')
const str = SharedPrefs.getString('testerString')
console.log(JSON.stringify(str))
setCurrentTestOutput(`${str}`)
}}>
<ButtonText>Set String</ButtonText>
</Button>
<Button
label="btn"
testID="removeStringBtn"
style={[a.self_center]}
variant="solid"
color="primary"
size="xsmall"
onPress={async () => {
SharedPrefs.removeValue('testerString')
const str = SharedPrefs.getString('testerString')
setCurrentTestOutput(`${str}`)
}}>
<ButtonText>Remove String</ButtonText>
</Button>
<Button
label="btn"
testID="setBoolBtn"
style={[a.self_center]}
variant="solid"
color="primary"
size="xsmall"
onPress={async () => {
SharedPrefs.removeValue('testerBool')
SharedPrefs.setValue('testerBool', true)
const bool = SharedPrefs.getBool('testerBool')
setCurrentTestOutput(`${bool}`)
}}>
<ButtonText>Set Bool</ButtonText>
</Button>
<Button
label="btn"
testID="setNumberBtn"
style={[a.self_center]}
variant="solid"
color="primary"
size="xsmall"
onPress={async () => {
SharedPrefs.removeValue('testerNumber')
SharedPrefs.setValue('testerNumber', 123)
const num = SharedPrefs.getNumber('testerNumber')
setCurrentTestOutput(`${num}`)
}}>
<ButtonText>Set Number</ButtonText>
</Button>
<Button
label="btn"
testID="addToSetBtn"
style={[a.self_center]}
variant="solid"
color="primary"
size="xsmall"
onPress={async () => {
SharedPrefs.removeFromSet('testerSet', 'Hello!')
SharedPrefs.addToSet('testerSet', 'Hello!')
const contains = SharedPrefs.setContains('testerSet', 'Hello!')
setCurrentTestOutput(`${contains}`)
}}>
<ButtonText>Add to Set</ButtonText>
</Button>
<Button
label="btn"
testID="removeFromSetBtn"
style={[a.self_center]}
variant="solid"
color="primary"
size="xsmall"
onPress={async () => {
SharedPrefs.removeFromSet('testerSet', 'Hello!')
const contains = SharedPrefs.setContains('testerSet', 'Hello!')
setCurrentTestOutput(`${contains}`)
}}>
<ButtonText>Remove from Set</ButtonText>
</Button>
</View>
</View>
</ScrollView>
)
}
+1 -1
View File
@@ -169,7 +169,7 @@ function HashtagScreenTab({
const queryParam = React.useMemo(() => { const queryParam = React.useMemo(() => {
if (!author) return fullTag if (!author) return fullTag
return `${fullTag} from:${sanitizeHandle(author)}` return `${fullTag} from:${author}`
}, [fullTag, author]) }, [fullTag, author])
const { const {
@@ -386,10 +386,10 @@ export function MessagesList({
data={convoState.items} data={convoState.items}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
containWeb={true} disableFullWindowScroll={true}
// Prevents wrong position in Firefox when sending a message // Prevents wrong position in Firefox when sending a message
// as well as scroll getting stuck on Chome when scrolling upwards. // as well as scroll getting stuck on Chome when scrolling upwards.
disableContentVisibility={true} disableContainStyle={true}
disableVirtualization={true} disableVirtualization={true}
style={animatedListStyle} style={animatedListStyle}
// The extra two items account for the header and the footer components // The extra two items account for the header and the footer components
+1 -1
View File
@@ -20,7 +20,7 @@ export function ProfileHeaderDisplayName({
<View pointerEvents="none"> <View pointerEvents="none">
<Text <Text
testID="profileHeaderDisplayName" testID="profileHeaderDisplayName"
style={[t.atoms.text, a.text_4xl, {fontWeight: '500'}]}> style={[t.atoms.text, a.text_4xl, a.self_start, {fontWeight: '500'}]}>
{sanitizeDisplayName( {sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle), profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'), moderation.ui('displayName'),
+1 -1
View File
@@ -101,7 +101,7 @@ export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
onEndReachedThreshold={2} onEndReachedThreshold={2}
renderScrollComponent={props => <KeyboardAwareScrollView {...props} />} renderScrollComponent={props => <KeyboardAwareScrollView {...props} />}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
containWeb={true} disableFullWindowScroll={true}
sideBorders={false} sideBorders={false}
style={{flex: 1}} style={{flex: 1}}
ListEmptyComponent={ ListEmptyComponent={
@@ -80,7 +80,7 @@ export function StepProfiles({
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderScrollComponent={props => <KeyboardAwareScrollView {...props} />} renderScrollComponent={props => <KeyboardAwareScrollView {...props} />}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
containWeb={true} disableFullWindowScroll={true}
sideBorders={false} sideBorders={false}
style={[a.flex_1]} style={[a.flex_1]}
onEndReached={ onEndReached={
+31 -13
View File
@@ -5,6 +5,7 @@ import throttle from 'lodash.throttle'
import {PROD_DEFAULT_FEED} from '#/lib/constants' import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed' import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
import {getFeedPostSlice} from '#/view/com/posts/Feed' import {getFeedPostSlice} from '#/view/com/posts/Feed'
@@ -24,6 +25,7 @@ const stateContext = React.createContext<StateContext>({
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
const agent = useAgent() const agent = useAgent()
const gate = useGate()
const enabled = isDiscoverFeed(feed) && hasSession const enabled = isDiscoverFeed(feed) && hasSession
const queue = React.useRef<Set<string>>(new Set()) const queue = React.useRef<Set<string>>(new Set())
const history = React.useRef< const history = React.useRef<
@@ -43,22 +45,38 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
) )
const sendToFeedNoDelay = React.useCallback(() => { const sendToFeedNoDelay = React.useCallback(() => {
const proxyAgent = agent.withProxy(
// @ts-ignore TODO need to update withProxy() to support this key -prf
'bsky_fg',
// TODO when we start sending to other feeds, we need to grab their DID -prf
'did:web:discover.bsky.app',
) as BskyAgent
const interactions = Array.from(queue.current).map(toInteraction) const interactions = Array.from(queue.current).map(toInteraction)
queue.current.clear() queue.current.clear()
// Send to the feed // Send to the feed
proxyAgent.app.bsky.feed if (gate('session_withproxy_fix')) {
.sendInteractions({interactions}) agent.app.bsky.feed
.catch((e: any) => { .sendInteractions(
logger.warn('Failed to send feed interactions', {error: e}) {interactions},
}) {
encoding: 'application/json',
headers: {
// TODO when we start sending to other feeds, we need to grab their DID -prf
'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg',
},
},
)
.catch((e: any) => {
logger.warn('Failed to send feed interactions', {error: e})
})
} else {
const proxyAgent = agent.withProxy(
// @ts-ignore TODO need to update withProxy() to support this key -prf
'bsky_fg',
// TODO when we start sending to other feeds, we need to grab their DID -prf
'did:web:discover.bsky.app',
) as BskyAgent
proxyAgent.app.bsky.feed
.sendInteractions({interactions})
.catch((e: any) => {
logger.warn('Failed to send feed interactions', {error: e})
})
}
// Send to Statsig // Send to Statsig
if (aggregatedStats.current === null) { if (aggregatedStats.current === null) {
@@ -66,7 +84,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
} }
sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions) sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions)
throttledFlushAggregatedStats() throttledFlushAggregatedStats()
}, [agent, throttledFlushAggregatedStats]) }, [agent, gate, throttledFlushAggregatedStats])
const sendToFeed = React.useMemo( const sendToFeed = React.useMemo(
() => () =>
+4
View File
@@ -12,6 +12,7 @@ import {tryFetchGates} from '#/lib/statsig/statsig'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger' import {logger} from '#/logger'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {addSessionErrorLog} from './logging'
import { import {
configureModerationForAccount, configureModerationForAccount,
configureModerationForGuest, configureModerationForGuest,
@@ -194,6 +195,9 @@ async function prepareAgent(
const account = agentToSessionAccountOrThrow(agent) const account = agentToSessionAccountOrThrow(agent)
agent.setPersistSessionHandler(event => { agent.setPersistSessionHandler(event => {
onSessionChange(agent, account.did, event) onSessionChange(agent, account.did, event)
if (event !== 'create' && event !== 'update') {
addSessionErrorLog(account.did, event)
}
}) })
return {agent, account} return {agent, account}
} }
+18 -1
View File
@@ -1,4 +1,4 @@
import {AtpSessionData} from '@atproto/api' import {AtpSessionData, AtpSessionEvent} from '@atproto/api'
import {sha256} from 'js-sha256' import {sha256} from 'js-sha256'
import {Statsig} from 'statsig-react-native-expo' import {Statsig} from 'statsig-react-native-expo'
@@ -70,6 +70,23 @@ export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
let nextMessageIndex = 0 let nextMessageIndex = 0
const MAX_SLICE_LENGTH = 1000 const MAX_SLICE_LENGTH = 1000
// Not gated.
export function addSessionErrorLog(did: string, event: AtpSessionEvent) {
try {
if (!Statsig.initializeCalled() || !Statsig.getStableID()) {
return
}
const stack = (new Error().stack ?? '').slice(0, MAX_SLICE_LENGTH)
Statsig.logEvent('session:error', null, {
did,
event,
stack,
})
} catch (e) {
console.error(e)
}
}
export function addSessionDebugLog(log: Log) { export function addSessionDebugLog(log: Log) {
try { try {
if (!Statsig.initializeCalled() || !Statsig.getStableID()) { if (!Statsig.initializeCalled() || !Statsig.getStableID()) {
+35 -38
View File
@@ -14,7 +14,6 @@ import {
KeyboardAvoidingView, KeyboardAvoidingView,
LayoutChangeEvent, LayoutChangeEvent,
StyleSheet, StyleSheet,
TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import Animated, { import Animated, {
@@ -24,7 +23,6 @@ import Animated, {
withTiming, withTiming,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {LinearGradient} from 'expo-linear-gradient'
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetPostThread, AppBskyFeedGetPostThread,
@@ -61,21 +59,21 @@ import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {HITSLOP_10, MAX_GRAPHEME_LENGTH} from 'lib/constants' import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {insertMentionAt} from 'lib/strings/mention-manip' import {insertMentionAt} from 'lib/strings/mention-manip'
import {shortenLinks} from 'lib/strings/rich-text-manip' import {shortenLinks} from 'lib/strings/rich-text-manip'
import {colors, gradients, s} from 'lib/styles' import {colors, s} from 'lib/styles'
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection' import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
import {useDialogStateControlContext} from 'state/dialogs' import {useDialogStateControlContext} from 'state/dialogs'
import {GalleryModel} from 'state/models/media/gallery' import {GalleryModel} from 'state/models/media/gallery'
import {ComposerOpts} from 'state/shell/composer' import {ComposerOpts} from 'state/shell/composer'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed' import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed'
@@ -458,20 +456,25 @@ export const ComposePost = observer(function ComposePost({
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal> <View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal>
<Animated.View style={topBarAnimatedStyle}> <Animated.View style={topBarAnimatedStyle}>
<View style={styles.topbarInner}> <View style={styles.topbarInner}>
<TouchableOpacity <Button
testID="composerDiscardButton" label={_(msg`Cancel`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[
a.rounded_full,
a.py_sm,
{paddingLeft: 7, paddingRight: 7},
]}
onPress={onPressCancel} onPress={onPressCancel}
onAccessibilityEscape={onPressCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel`)}
accessibilityHint={_( accessibilityHint={_(
msg`Closes post composer and discards post draft`, msg`Closes post composer and discards post draft`,
)} )}>
hitSlop={HITSLOP_10}> <ButtonText style={[a.text_md]}>
<Text style={[pal.link, s.f18]}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Text> </ButtonText>
</TouchableOpacity> </Button>
<View style={a.flex_1} /> <View style={a.flex_1} />
{isProcessing ? ( {isProcessing ? (
<> <>
@@ -488,31 +491,25 @@ export const ComposePost = observer(function ComposePost({
hasMedia={hasMedia} hasMedia={hasMedia}
/> />
{canPost ? ( {canPost ? (
<TouchableOpacity <Button
testID="composerPublishBtn" testID="composerPublishBtn"
onPress={onPressPublish} label={
accessibilityRole="button"
accessibilityLabel={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`) replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
} }
accessibilityHint=""> variant="solid"
<LinearGradient color="primary"
colors={[ shape="default"
gradients.blueLight.start, size="small"
gradients.blueLight.end, style={[a.rounded_full, a.py_sm]}
]} onPress={onPressPublish}>
start={{x: 0, y: 0}} <ButtonText style={[a.text_md]}>
end={{x: 1, y: 1}} {replyTo ? (
style={styles.postBtn}> <Trans context="action">Reply</Trans>
<Text style={[s.white, s.f16, s.bold]}> ) : (
{replyTo ? ( <Trans context="action">Post</Trans>
<Trans context="action">Reply</Trans> )}
) : ( </ButtonText>
<Trans context="action">Post</Trans> </Button>
)}
</Text>
</LinearGradient>
</TouchableOpacity>
) : ( ) : (
<View style={[styles.postBtn, pal.btn]}> <View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}> <Text style={[pal.textLight, s.f16, s.bold]}>
@@ -828,7 +825,7 @@ const styles = StyleSheet.create({
topbarInner: { topbarInner: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 8,
height: 54, height: 54,
gap: 4, gap: 4,
}, },
+7 -2
View File
@@ -174,8 +174,13 @@ function AltTextInner({
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}> <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
<Trans>Add alt text</Trans> <Trans>Add alt text</Trans>
</Text> </Text>
<View style={[a.w_full, a.align_center, native({maxHeight: 200})]}> <View style={[a.align_center]}>
<GifEmbed link={link} params={params} hideAlt /> <GifEmbed
link={link}
params={params}
hideAlt
style={[native({maxHeight: 225})]}
/>
</View> </View>
</View> </View>
</View> </View>
@@ -1,11 +1,13 @@
import React from 'react' import React from 'react'
import Picker from '@emoji-mart/react'
import { import {
StyleSheet, GestureResponderEvent,
TouchableWithoutFeedback, TouchableWithoutFeedback,
useWindowDimensions, useWindowDimensions,
View, View,
} from 'react-native' } from 'react-native'
import Picker from '@emoji-mart/react'
import {atoms as a} from '#/alf'
import {textInputWebEmitter} from '../TextInput.web' import {textInputWebEmitter} from '../TextInput.web'
const HEIGHT_OFFSET = 40 const HEIGHT_OFFSET = 40
@@ -96,12 +98,29 @@ export function EmojiPicker({state, close}: IProps) {
if (!state.isOpen) return null if (!state.isOpen) return null
const onPressBackdrop = (e: GestureResponderEvent) => {
// @ts-ignore web only
if (e.nativeEvent?.pointerId === -1) return
close()
}
return ( return (
<TouchableWithoutFeedback <TouchableWithoutFeedback
accessibilityRole="button" accessibilityRole="button"
onPress={close} onPress={onPressBackdrop}
accessibilityViewIsModal> accessibilityViewIsModal>
<View style={styles.mask}> <View
style={[
a.fixed,
a.w_full,
a.h_full,
a.align_center,
{
top: 0,
left: 0,
right: 0,
},
]}>
{/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}> <TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[{position: 'absolute'}, position]}> <View style={[{position: 'absolute'}, position]}>
@@ -118,20 +137,3 @@ export function EmojiPicker({state, close}: IProps) {
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
) )
} }
const styles = StyleSheet.create({
mask: {
// @ts-ignore web ony
position: 'fixed',
top: 0,
left: 0,
right: 0,
width: '100%',
height: '100%',
alignItems: 'center',
},
picker: {
marginHorizontal: 'auto',
paddingRight: 50,
},
})
+6 -3
View File
@@ -58,6 +58,7 @@ import {useNavigation} from '@react-navigation/native'
import {parseTenorGif} from '#/lib/strings/embed-player' import {parseTenorGif} from '#/lib/strings/embed-player'
import {logger} from '#/logger' import {logger} from '#/logger'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {forceLTR} from 'lib/strings/bidi'
import {DM_SERVICE_HEADERS} from 'state/queries/messages/const' import {DM_SERVICE_HEADERS} from 'state/queries/messages/const'
import {useAgent} from 'state/session' import {useAgent} from 'state/session'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
@@ -178,8 +179,10 @@ let FeedItem = ({
key={authors[0].href} key={authors[0].href}
style={[pal.text, s.bold]} style={[pal.text, s.bold]}
href={authors[0].href} href={authors[0].href}
text={sanitizeDisplayName( text={forceLTR(
authors[0].profile.displayName || authors[0].profile.handle, sanitizeDisplayName(
authors[0].profile.displayName || authors[0].profile.handle,
),
)} )}
disableMismatchWarning disableMismatchWarning
/> />
@@ -378,7 +381,7 @@ let FeedItem = ({
showDmButton={item.type === 'starterpack-joined' || isFollowBack} showDmButton={item.type === 'starterpack-joined' || isFollowBack}
/> />
<ExpandedAuthorsList visible={isAuthorsExpanded} authors={authors} /> <ExpandedAuthorsList visible={isAuthorsExpanded} authors={authors} />
<Text style={[styles.meta, pal.text]}> <Text style={[styles.meta, a.self_start, pal.text]}>
{action} {action}
<TimeElapsed timestamp={item.notification.indexedAt}> <TimeElapsed timestamp={item.notification.indexedAt}>
{({timeElapsed}) => ( {({timeElapsed}) => (
+1 -1
View File
@@ -281,7 +281,7 @@ let PostThreadItemLoaded = ({
<Link style={s.flex1} href={authorHref} title={authorTitle}> <Link style={s.flex1} href={authorHref} title={authorTitle}>
<Text <Text
type="xl-bold" type="xl-bold"
style={[pal.text]} style={[pal.text, a.self_start]}
numberOfLines={1} numberOfLines={1}
lineHeight={1.2}> lineHeight={1.2}>
{sanitizeDisplayName( {sanitizeDisplayName(
+19 -11
View File
@@ -15,6 +15,7 @@ import {useQueryClient} from '@tanstack/react-query'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {MAX_POST_LINES} from 'lib/constants' import {MAX_POST_LINES} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@@ -145,6 +146,9 @@ function PostInner({
precacheProfile(queryClient, post.author) precacheProfile(queryClient, post.author)
}, [queryClient, post.author]) }, [queryClient, post.author])
const {currentAccount} = useSession()
const isMe = replyAuthorDid === currentAccount?.did
return ( return (
<Link <Link
href={itemHref} href={itemHref}
@@ -187,17 +191,21 @@ function PostInner({
style={[pal.textLight, s.mr2]} style={[pal.textLight, s.mr2]}
lineHeight={1.2} lineHeight={1.2}
numberOfLines={1}> numberOfLines={1}>
<Trans context="description"> {isMe ? (
Reply to{' '} <Trans context="description">Reply to you</Trans>
<ProfileHoverCard inline did={replyAuthorDid}> ) : (
<UserInfoText <Trans context="description">
type="sm" Reply to{' '}
did={replyAuthorDid} <ProfileHoverCard inline did={replyAuthorDid}>
attr="displayName" <UserInfoText
style={[pal.textLight]} type="sm"
/> did={replyAuthorDid}
</ProfileHoverCard> attr="displayName"
</Trans> style={[pal.textLight]}
/>
</ProfileHoverCard>
</Trans>
)}
</Text> </Text>
</View> </View>
)} )}
+43 -24
View File
@@ -43,6 +43,7 @@ import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar' import {PreviewableUserAvatar} from '../util/UserAvatar'
import {AviFollowButton} from './AviFollowButton' import {AviFollowButton} from './AviFollowButton'
import hairlineWidth = StyleSheet.hairlineWidth import hairlineWidth = StyleSheet.hairlineWidth
import {useSession} from '#/state/session'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
interface FeedItemProps { interface FeedItemProps {
@@ -200,6 +201,11 @@ let FeedItemInner = ({
}, },
] ]
const {currentAccount} = useSession()
const isOwner =
AppBskyFeedDefs.isReasonRepost(reason) &&
reason.by.did === currentAccount?.did
return ( return (
<Link <Link
testID={`feedItem-by-${post.author.handle}`} testID={`feedItem-by-${post.author.handle}`}
@@ -250,11 +256,15 @@ let FeedItemInner = ({
<Link <Link
style={styles.includeReason} style={styles.includeReason}
href={makeProfileLink(reason.by)} href={makeProfileLink(reason.by)}
title={_( title={
msg`Reposted by ${sanitizeDisplayName( isOwner
reason.by.displayName || reason.by.handle, ? _(msg`Reposted by you`)
)}`, : _(
)} msg`Reposted by ${sanitizeDisplayName(
reason.by.displayName || reason.by.handle,
)}`,
)
}
onBeforePress={onOpenReposter}> onBeforePress={onOpenReposter}>
<Repost <Repost
style={{color: pal.colors.textLight, marginRight: 3}} style={{color: pal.colors.textLight, marginRight: 3}}
@@ -266,24 +276,28 @@ let FeedItemInner = ({
style={pal.textLight} style={pal.textLight}
lineHeight={1.2} lineHeight={1.2}
numberOfLines={1}> numberOfLines={1}>
<Trans> {isOwner ? (
Reposted by{' '} <Trans>Reposted by you</Trans>
<ProfileHoverCard inline did={reason.by.did}> ) : (
<TextLinkOnWebOnly <Trans>
type="sm-bold" Reposted by{' '}
style={pal.textLight} <ProfileHoverCard inline did={reason.by.did}>
lineHeight={1.2} <TextLinkOnWebOnly
numberOfLines={1} type="sm-bold"
text={sanitizeDisplayName( style={pal.textLight}
reason.by.displayName || lineHeight={1.2}
sanitizeHandle(reason.by.handle), numberOfLines={1}
moderation.ui('displayName'), text={sanitizeDisplayName(
)} reason.by.displayName ||
href={makeProfileLink(reason.by)} sanitizeHandle(reason.by.handle),
onBeforePress={onOpenReposter} moderation.ui('displayName'),
/> )}
</ProfileHoverCard> href={makeProfileLink(reason.by)}
</Trans> onBeforePress={onOpenReposter}
/>
</ProfileHoverCard>
</Trans>
)}
</Text> </Text>
</Link> </Link>
) : null} ) : null}
@@ -421,6 +435,9 @@ function ReplyToLabel({
blocked?: boolean blocked?: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {currentAccount} = useSession()
const isMe = profile.did === currentAccount?.did
return ( return (
<View style={[s.flexRow, s.mb2, s.alignCenter]}> <View style={[s.flexRow, s.mb2, s.alignCenter]}>
<FontAwesomeIcon <FontAwesomeIcon
@@ -433,7 +450,9 @@ function ReplyToLabel({
style={[pal.textLight, s.mr2]} style={[pal.textLight, s.mr2]}
lineHeight={1.2} lineHeight={1.2}
numberOfLines={1}> numberOfLines={1}>
{blocked ? ( {isMe ? (
<Trans context="description">Reply to you</Trans>
) : blocked ? (
<Trans context="description">Reply to a blocked post</Trans> <Trans context="description">Reply to a blocked post</Trans>
) : ( ) : (
<Trans context="description"> <Trans context="description">
+38 -67
View File
@@ -5,7 +5,6 @@ import {
moderateProfile, moderateProfile,
ModerationDecision, ModerationDecision,
} from '@atproto/api' } from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -19,12 +18,16 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {precacheProfile} from 'state/queries/profile' import {precacheProfile} from 'state/queries/profile'
import {atoms as a} from '#/alf'
import {
KnownFollowers,
shouldShowKnownFollowers,
} from '#/components/KnownFollowers'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar' import {PreviewableUserAvatar} from '../util/UserAvatar'
import {FollowButton} from './FollowButton' import {FollowButton} from './FollowButton'
import hairlineWidth = StyleSheet.hairlineWidth import hairlineWidth = StyleSheet.hairlineWidth
import {atoms as a} from '#/alf'
import * as Pills from '#/components/Pills' import * as Pills from '#/components/Pills'
export function ProfileCard({ export function ProfileCard({
@@ -33,22 +36,22 @@ export function ProfileCard({
noModFilter, noModFilter,
noBg, noBg,
noBorder, noBorder,
followers,
renderButton, renderButton,
onPress, onPress,
style, style,
showKnownFollowers,
}: { }: {
testID?: string testID?: string
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileViewBasic
noModFilter?: boolean noModFilter?: boolean
noBg?: boolean noBg?: boolean
noBorder?: boolean noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
renderButton?: ( renderButton?: (
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>, profile: Shadow<AppBskyActorDefs.ProfileViewBasic>,
) => React.ReactNode ) => React.ReactNode
onPress?: () => void onPress?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
showKnownFollowers?: boolean
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const pal = usePalette('default') const pal = usePalette('default')
@@ -70,6 +73,11 @@ export function ProfileCard({
return null return null
} }
const knownFollowersVisible =
showKnownFollowers &&
shouldShowKnownFollowers(profile.viewer?.knownFollowers) &&
moderationOpts
return ( return (
<Link <Link
testID={testID} testID={testID}
@@ -97,7 +105,7 @@ export function ProfileCard({
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<Text <Text
type="lg" type="lg"
style={[s.bold, pal.text]} style={[s.bold, pal.text, a.self_start]}
numberOfLines={1} numberOfLines={1}
lineHeight={1.2}> lineHeight={1.2}>
{sanitizeDisplayName( {sanitizeDisplayName(
@@ -118,14 +126,30 @@ export function ProfileCard({
<View style={styles.layoutButton}>{renderButton(profile)}</View> <View style={styles.layoutButton}>{renderButton(profile)}</View>
) : undefined} ) : undefined}
</View> </View>
{profile.description ? ( {profile.description || knownFollowersVisible ? (
<View style={styles.details}> <View style={styles.details}>
<Text style={pal.text} numberOfLines={4}> {profile.description ? (
{profile.description as string} <Text style={pal.text} numberOfLines={4}>
</Text> {profile.description as string}
</Text>
) : null}
{knownFollowersVisible ? (
<View
style={[
a.flex_row,
a.align_center,
a.gap_sm,
!!profile.description && a.mt_md,
]}>
<KnownFollowers
minimal
profile={profile}
moderationOpts={moderationOpts}
/>
</View>
) : null}
</View> </View>
) : null} ) : null}
<FollowersList followers={followers} />
</Link> </Link>
) )
} }
@@ -155,73 +179,20 @@ export function ProfileCardPills({
) )
} }
function FollowersList({
followers,
}: {
followers?: AppBskyActorDefs.ProfileView[] | undefined
}) {
const pal = usePalette('default')
const moderationOpts = useModerationOpts()
const followersWithMods = React.useMemo(() => {
if (!followers || !moderationOpts) {
return []
}
return followers
.map(f => ({
f,
mod: moderateProfile(f, moderationOpts),
}))
.filter(({mod}) => !mod.ui('profileList').filter)
}, [followers, moderationOpts])
if (!followersWithMods?.length) {
return null
}
return (
<View style={styles.followedBy}>
<Text
type="sm"
style={[styles.followsByDesc, pal.textLight]}
numberOfLines={2}
lineHeight={1.2}>
<Trans>
Followed by{' '}
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
</Trans>
</Text>
{followersWithMods.slice(0, 3).map(({f, mod}) => (
<View key={f.did} style={styles.followedByAviContainer}>
<View style={[styles.followedByAvi, pal.view]}>
<PreviewableUserAvatar
size={32}
profile={f}
moderation={mod.ui('avatar')}
type={f.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
</View>
))}
</View>
)
}
export function ProfileCardWithFollowBtn({ export function ProfileCardWithFollowBtn({
profile, profile,
noBg, noBg,
noBorder, noBorder,
followers,
onPress, onPress,
logContext = 'ProfileCard', logContext = 'ProfileCard',
showKnownFollowers,
}: { }: {
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileView
noBg?: boolean noBg?: boolean
noBorder?: boolean noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
onPress?: () => void onPress?: () => void
logContext?: 'ProfileCard' | 'StarterPackProfilesList' logContext?: 'ProfileCard' | 'StarterPackProfilesList'
showKnownFollowers?: boolean
}) { }) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const isMe = profile.did === currentAccount?.did const isMe = profile.did === currentAccount?.did
@@ -231,7 +202,6 @@ export function ProfileCardWithFollowBtn({
profile={profile} profile={profile}
noBg={noBg} noBg={noBg}
noBorder={noBorder} noBorder={noBorder}
followers={followers}
renderButton={ renderButton={
isMe isMe
? undefined ? undefined
@@ -240,6 +210,7 @@ export function ProfileCardWithFollowBtn({
) )
} }
onPress={onPress} onPress={onPress}
showKnownFollowers={!isMe && showKnownFollowers}
/> />
) )
} }
+3 -2
View File
@@ -24,11 +24,12 @@ export type ListProps<ItemT> = Omit<
refreshing?: boolean refreshing?: boolean
onRefresh?: () => void onRefresh?: () => void
onItemSeen?: (item: ItemT) => void onItemSeen?: (item: ItemT) => void
containWeb?: boolean
desktopFixedHeight?: number | boolean desktopFixedHeight?: number | boolean
// Web only prop to contain the scroll to the container rather than the window
disableFullWindowScroll?: boolean
sideBorders?: boolean sideBorders?: boolean
// Web only prop to disable a perf optimization (which would otherwise be on). // Web only prop to disable a perf optimization (which would otherwise be on).
disableContentVisibility?: boolean disableContainStyle?: boolean
} }
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
+25 -22
View File
@@ -23,9 +23,11 @@ export type ListProps<ItemT> = Omit<
onRefresh?: () => void onRefresh?: () => void
onItemSeen?: (item: ItemT) => void onItemSeen?: (item: ItemT) => void
desktopFixedHeight?: number | boolean desktopFixedHeight?: number | boolean
containWeb?: boolean // Web only prop to contain the scroll to the container rather than the window
disableFullWindowScroll?: boolean
sideBorders?: boolean sideBorders?: boolean
disableContentVisibility?: boolean // Web only prop to disable a perf optimization (which would otherwise be on).
disableContainStyle?: boolean
} }
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
@@ -39,7 +41,7 @@ function ListImpl<ItemT>(
ListHeaderComponent, ListHeaderComponent,
ListFooterComponent, ListFooterComponent,
ListEmptyComponent, ListEmptyComponent,
containWeb, disableFullWindowScroll,
contentContainerStyle, contentContainerStyle,
data, data,
desktopFixedHeight, desktopFixedHeight,
@@ -58,7 +60,7 @@ function ListImpl<ItemT>(
extraData, extraData,
style, style,
sideBorders = true, sideBorders = true,
disableContentVisibility, disableContainStyle,
...props ...props
}: ListProps<ItemT>, }: ListProps<ItemT>,
ref: React.Ref<ListMethods>, ref: React.Ref<ListMethods>,
@@ -112,7 +114,7 @@ function ListImpl<ItemT>(
} }
const getScrollableNode = React.useCallback(() => { const getScrollableNode = React.useCallback(() => {
if (containWeb) { if (disableFullWindowScroll) {
const element = nativeRef.current as HTMLDivElement | null const element = nativeRef.current as HTMLDivElement | null
if (!element) return if (!element) return
@@ -182,7 +184,7 @@ function ListImpl<ItemT>(
}, },
} }
} }
}, [containWeb]) }, [disableFullWindowScroll])
const nativeRef = React.useRef<HTMLDivElement>(null) const nativeRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle( React.useImperativeHandle(
@@ -267,7 +269,12 @@ function ListImpl<ItemT>(
return () => { return () => {
element?.removeEventListener('scroll', handleScroll) element?.removeEventListener('scroll', handleScroll)
} }
}, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode]) }, [
isInsideVisibleTree,
handleScroll,
disableFullWindowScroll,
getScrollableNode,
])
// --- onScrolledDownChange --- // --- onScrolledDownChange ---
const isScrolledDown = useRef(false) const isScrolledDown = useRef(false)
@@ -308,7 +315,7 @@ function ListImpl<ItemT>(
{...props} {...props}
style={[ style={[
style, style,
containWeb && { disableFullWindowScroll && {
flex: 1, flex: 1,
// @ts-expect-error web only // @ts-expect-error web only
'overflow-y': 'scroll', 'overflow-y': 'scroll',
@@ -332,13 +339,13 @@ function ListImpl<ItemT>(
pal.border, pal.border,
]}> ]}>
<Visibility <Visibility
root={containWeb ? nativeRef : null} root={disableFullWindowScroll ? nativeRef : null}
onVisibleChange={handleAboveTheFoldVisibleChange} onVisibleChange={handleAboveTheFoldVisibleChange}
style={[styles.aboveTheFoldDetector, {height: headerOffset}]} style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
/> />
{onStartReached && !isEmpty && ( {onStartReached && !isEmpty && (
<Visibility <Visibility
root={containWeb ? nativeRef : null} root={disableFullWindowScroll ? nativeRef : null}
onVisibleChange={onHeadVisibilityChange} onVisibleChange={onHeadVisibilityChange}
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'} topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
/> />
@@ -356,13 +363,13 @@ function ListImpl<ItemT>(
renderItem={renderItem} renderItem={renderItem}
extraData={extraData} extraData={extraData}
onItemSeen={onItemSeen} onItemSeen={onItemSeen}
disableContentVisibility={disableContentVisibility} disableContainStyle={disableContainStyle}
/> />
) )
})} })}
{onEndReached && !isEmpty && ( {onEndReached && !isEmpty && (
<Visibility <Visibility
root={containWeb ? nativeRef : null} root={disableFullWindowScroll ? nativeRef : null}
onVisibleChange={onTailVisibilityChange} onVisibleChange={onTailVisibilityChange}
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
key={data?.length} key={data?.length}
@@ -406,7 +413,7 @@ let Row = function RowImpl<ItemT>({
renderItem, renderItem,
extraData: _unused, extraData: _unused,
onItemSeen, onItemSeen,
disableContentVisibility, disableContainStyle,
}: { }: {
item: ItemT item: ItemT
index: number index: number
@@ -416,7 +423,7 @@ let Row = function RowImpl<ItemT>({
| ((data: {index: number; item: any; separators: any}) => React.ReactNode) | ((data: {index: number; item: any; separators: any}) => React.ReactNode)
extraData: any extraData: any
onItemSeen: ((item: any) => void) | undefined onItemSeen: ((item: any) => void) | undefined
disableContentVisibility?: boolean disableContainStyle?: boolean
}): React.ReactNode { }): React.ReactNode {
const rowRef = React.useRef(null) const rowRef = React.useRef(null)
const intersectionTimeout = React.useRef<NodeJS.Timer | undefined>(undefined) const intersectionTimeout = React.useRef<NodeJS.Timer | undefined>(undefined)
@@ -465,14 +472,10 @@ let Row = function RowImpl<ItemT>({
return null return null
} }
const shouldDisableContentVisibility = disableContentVisibility || isSafari const shouldDisableContainStyle = disableContainStyle || isSafari
return ( return (
<View <View
style={ style={shouldDisableContainStyle ? undefined : styles.contain}
shouldDisableContentVisibility
? undefined
: styles.contentVisibilityAuto
}
ref={rowRef}> ref={rowRef}>
{renderItem({item, index, separators: null as any})} {renderItem({item, index, separators: null as any})}
</View> </View>
@@ -544,9 +547,9 @@ const styles = StyleSheet.create({
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto', marginRight: 'auto',
}, },
contentVisibilityAuto: { contain: {
// @ts-ignore web only // @ts-ignore web only
contentVisibility: 'auto', contain: 'layout paint',
}, },
minHeightViewport: { minHeightViewport: {
// @ts-ignore web only // @ts-ignore web only
+8 -10
View File
@@ -6,6 +6,8 @@ import {useQueryClient} from '@tanstack/react-query'
import {precacheProfile} from '#/state/queries/profile' import {precacheProfile} from '#/state/queries/profile'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {forceLTR} from 'lib/strings/bidi'
import {NON_BREAKING_SPACE} from 'lib/strings/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {niceDate} from 'lib/strings/time' import {niceDate} from 'lib/strings/time'
@@ -32,8 +34,6 @@ interface PostMetaOpts {
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
} }
const NON_BREAKING_SPACE = '\u00A0'
let PostMeta = (opts: PostMetaOpts): React.ReactNode => { let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const displayName = opts.author.displayName || opts.author.handle const displayName = opts.author.displayName || opts.author.handle
@@ -70,14 +70,12 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
style={[pal.text]} style={[pal.text]}
lineHeight={1.2} lineHeight={1.2}
disableMismatchWarning disableMismatchWarning
text={ text={forceLTR(
<> sanitizeDisplayName(
{sanitizeDisplayName( displayName,
displayName, opts.moderation?.ui('displayName'),
opts.moderation?.ui('displayName'), ),
)} )}
</>
}
href={profileLink} href={profileLink}
onBeforePress={onBeforePressAuthor} onBeforePress={onBeforePressAuthor}
/> />
+11 -2
View File
@@ -1,5 +1,12 @@
import React from 'react' import React from 'react'
import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native' import {
Pressable,
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api' import {AppBskyEmbedExternal} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@@ -89,10 +96,12 @@ export function GifEmbed({
params, params,
link, link,
hideAlt, hideAlt,
style = {width: '100%'},
}: { }: {
params: EmbedPlayerParams params: EmbedPlayerParams
link: AppBskyEmbedExternal.ViewExternal link: AppBskyEmbedExternal.ViewExternal
hideAlt?: boolean hideAlt?: boolean
style?: StyleProp<ViewStyle>
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const autoplayDisabled = useAutoplayDisabled() const autoplayDisabled = useAutoplayDisabled()
@@ -124,7 +133,7 @@ export function GifEmbed({
) )
return ( return (
<View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, {width: '100%'}]}> <View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, style]}>
<View <View
style={[ style={[
a.rounded_sm, a.rounded_sm,
+8 -4
View File
@@ -131,7 +131,7 @@ export function PostEmbeds({
const {alt, thumb, aspectRatio} = images[0] const {alt, thumb, aspectRatio} = images[0]
return ( return (
<ContentHider modui={moderation?.ui('contentMedia')}> <ContentHider modui={moderation?.ui('contentMedia')}>
<View style={[styles.imagesContainer, style]}> <View style={[styles.container, style]}>
<AutoSizedImage <AutoSizedImage
alt={alt} alt={alt}
uri={thumb} uri={thumb}
@@ -156,7 +156,7 @@ export function PostEmbeds({
return ( return (
<ContentHider modui={moderation?.ui('contentMedia')}> <ContentHider modui={moderation?.ui('contentMedia')}>
<View style={[styles.imagesContainer, style]}> <View style={[styles.container, style]}>
<ImageLayoutGrid <ImageLayoutGrid
images={embed.images} images={embed.images}
onPress={_openLightbox} onPress={_openLightbox}
@@ -174,7 +174,11 @@ export function PostEmbeds({
const link = embed.external const link = embed.external
return ( return (
<ContentHider modui={moderation?.ui('contentMedia')}> <ContentHider modui={moderation?.ui('contentMedia')}>
<ExternalLinkEmbed link={link} onOpen={onOpen} style={style} /> <ExternalLinkEmbed
link={link}
onOpen={onOpen}
style={[styles.container, style]}
/>
</ContentHider> </ContentHider>
) )
} }
@@ -183,7 +187,7 @@ export function PostEmbeds({
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
imagesContainer: { container: {
marginTop: 8, marginTop: 8,
}, },
altContainer: { altContainer: {
+12 -3
View File
@@ -10,6 +10,7 @@ import {
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -241,7 +242,7 @@ type ExploreScreenItems =
| { | {
type: 'profile' type: 'profile'
key: string key: string
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileView
} }
| { | {
type: 'feed' type: 'feed'
@@ -291,6 +292,7 @@ export function Explore() {
error: feedsError, error: feedsError,
fetchNextPage: fetchNextFeedsPage, fetchNextPage: fetchNextFeedsPage,
} = useGetPopularFeedsQuery({limit: 10}) } = useGetPopularFeedsQuery({limit: 10})
const gate = useGate()
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
const onLoadMoreProfiles = React.useCallback(async () => { const onLoadMoreProfiles = React.useCallback(async () => {
@@ -492,7 +494,14 @@ export function Explore() {
case 'profile': { case 'profile': {
return ( return (
<View style={[a.border_b, t.atoms.border_contrast_low]}> <View style={[a.border_b, t.atoms.border_contrast_low]}>
<ProfileCardWithFollowBtn profile={item.profile} noBg noBorder /> <ProfileCardWithFollowBtn
profile={item.profile}
noBg
noBorder
showKnownFollowers={gate(
'explore_page_profile_card_social_proof',
)}
/>
</View> </View>
) )
} }
@@ -555,7 +564,7 @@ export function Explore() {
} }
} }
}, },
[t, moderationOpts], [t, moderationOpts, gate],
) )
return ( return (
+2 -1
View File
@@ -68,6 +68,7 @@ import {navigate, resetToTab} from '#/Navigation'
import {Email2FAToggle} from './Email2FAToggle' import {Email2FAToggle} from './Email2FAToggle'
import {ExportCarDialog} from './ExportCarDialog' import {ExportCarDialog} from './ExportCarDialog'
import hairlineWidth = StyleSheet.hairlineWidth import hairlineWidth = StyleSheet.hairlineWidth
import {atoms as a} from '#/alf'
function SettingsAccountCard({ function SettingsAccountCard({
account, account,
@@ -104,7 +105,7 @@ function SettingsAccountCard({
/> />
</View> </View>
<View style={[s.flex1]}> <View style={[s.flex1]}>
<Text type="md-bold" style={pal.text} numberOfLines={1}> <Text type="md-bold" style={[pal.text, a.self_start]} numberOfLines={1}>
{profile?.displayName || account.handle} {profile?.displayName || account.handle}
</Text> </Text>
<Text type="sm" style={pal.textLight} numberOfLines={1}> <Text type="sm" style={pal.textLight} numberOfLines={1}>
+27 -23
View File
@@ -20,29 +20,31 @@ export function Buttons() {
<H1>Buttons</H1> <H1>Buttons</H1>
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}> <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}>
{['primary', 'secondary', 'secondary_inverted'].map(color => ( {['primary', 'secondary', 'secondary_inverted', 'negative'].map(
<View key={color} style={[a.gap_md, a.align_start]}> color => (
{['solid', 'outline', 'ghost'].map(variant => ( <View key={color} style={[a.gap_md, a.align_start]}>
<React.Fragment key={variant}> {['solid', 'outline', 'ghost'].map(variant => (
<Button <React.Fragment key={variant}>
variant={variant as ButtonVariant} <Button
color={color as ButtonColor} variant={variant as ButtonVariant}
size="large" color={color as ButtonColor}
label="Click here"> size="large"
<ButtonText>Button</ButtonText> label="Click here">
</Button> <ButtonText>Button</ButtonText>
<Button </Button>
disabled <Button
variant={variant as ButtonVariant} disabled
color={color as ButtonColor} variant={variant as ButtonVariant}
size="large" color={color as ButtonColor}
label="Click here"> size="large"
<ButtonText>Button</ButtonText> label="Click here">
</Button> <ButtonText>Button</ButtonText>
</React.Fragment> </Button>
))} </React.Fragment>
</View> ))}
))} </View>
),
)}
<View style={[a.flex_row, a.gap_md, a.align_start]}> <View style={[a.flex_row, a.gap_md, a.align_start]}>
<View style={[a.gap_md, a.align_start]}> <View style={[a.gap_md, a.align_start]}>
@@ -68,6 +70,7 @@ export function Buttons() {
), ),
)} )}
</View> </View>
{/*
<View style={[a.gap_md, a.align_start]}> <View style={[a.gap_md, a.align_start]}>
{['gradient_sunset', 'gradient_nordic', 'gradient_bonfire'].map( {['gradient_sunset', 'gradient_nordic', 'gradient_bonfire'].map(
name => ( name => (
@@ -91,6 +94,7 @@ export function Buttons() {
), ),
)} )}
</View> </View>
*/}
</View> </View>
</View> </View>
+13
View File
@@ -1,7 +1,9 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {useDialogStateControlContext} from '#/state/dialogs' import {useDialogStateControlContext} from '#/state/dialogs'
import {NavigationProp} from 'lib/routes/types'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
@@ -18,6 +20,7 @@ export function Dialogs() {
const [shouldRenderUnmountTest, setShouldRenderUnmountTest] = const [shouldRenderUnmountTest, setShouldRenderUnmountTest] =
React.useState(false) React.useState(false)
const unmountTestInterval = React.useRef<number>() const unmountTestInterval = React.useRef<number>()
const navigation = useNavigation<NavigationProp>()
const onUnmountTestStartPressWithClose = () => { const onUnmountTestStartPressWithClose = () => {
setShouldRenderUnmountTest(true) setShouldRenderUnmountTest(true)
@@ -134,6 +137,16 @@ export function Dialogs() {
<ButtonText>End Unmount Test</ButtonText> <ButtonText>End Unmount Test</ButtonText>
</Button> </Button>
<Button
variant="solid"
color="primary"
size="small"
onPress={() => navigation.navigate('SharedPreferencesTester')}
label="two"
testID="sharedPrefsTestOpenBtn">
<ButtonText>Open Shared Prefs Tester</ButtonText>
</Button>
<Prompt.Outer control={prompt}> <Prompt.Outer control={prompt}>
<Prompt.TitleText>This is a prompt</Prompt.TitleText> <Prompt.TitleText>This is a prompt</Prompt.TitleText>
<Prompt.DescriptionText> <Prompt.DescriptionText>
+10
View File
@@ -41,6 +41,16 @@ export function Forms() {
/> />
</TextField.Root> </TextField.Root>
<TextField.Root>
<TextField.Icon icon={Globe} />
<TextField.Input
value={value}
onChangeText={setValue}
label="Text field"
isInvalid
/>
</TextField.Root>
<View style={[a.w_full]}> <View style={[a.w_full]}>
<TextField.LabelText>Text field</TextField.LabelText> <TextField.LabelText>Text field</TextField.LabelText>
<TextField.Root> <TextField.Root>

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