Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90dc3edc5b | |||
| 587c0c6257 | |||
| 4b5d6e6efb | |||
| f68b15219f | |||
| bcd096b85a | |||
| d8f72c1ee1 | |||
| 4553e6b649 | |||
| dd2fedb2e6 | |||
| c7b48cbdca | |||
| 389e6f1509 | |||
| dd1944e9b8 | |||
| 77c4a9922f | |||
| a61b75be57 | |||
| 7ee67e4e7e | |||
| 175df72972 | |||
| 863764b3fe | |||
| 702dfa8536 | |||
| 1ae7fa6363 | |||
| 960f4450ee | |||
| 5a142161cb | |||
| 27e09e7adc | |||
| 23dd638f6a | |||
| 1895ae2e69 | |||
| 179a913f20 | |||
| 625d7460d8 | |||
| f2a69c4528 | |||
| a9765fd23f | |||
| 78fb666020 | |||
| 2e5f95c8dd | |||
| 58036ffb52 | |||
| b1ca2503de | |||
| 6bc001a30e | |||
| fd15bfec40 |
@@ -9,11 +9,11 @@ describe('isNetworkError', () => {
|
||||
]
|
||||
const outputs = [true, false, false, true]
|
||||
|
||||
it('correctly distinguishes network errors', () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]
|
||||
const result = isNetworkError(input)
|
||||
expect(result).toEqual(outputs[i])
|
||||
}
|
||||
})
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]
|
||||
const output = outputs[i]
|
||||
it(`correctly distinguishes network errors for ${input}`, () => {
|
||||
expect(isNetworkError(input)).toEqual(output)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
+2
-18
@@ -234,24 +234,8 @@ module.exports = function (config) {
|
||||
'expo-font',
|
||||
{
|
||||
fonts: [
|
||||
// './assets/fonts/inter/Inter-Thin.otf',
|
||||
// './assets/fonts/inter/Inter-ThinItalic.otf',
|
||||
// './assets/fonts/inter/Inter-ExtraLight.otf',
|
||||
// './assets/fonts/inter/Inter-ExtraLightItalic.otf',
|
||||
// './assets/fonts/inter/Inter-Light.otf',
|
||||
// './assets/fonts/inter/Inter-LightItalic.otf',
|
||||
'./assets/fonts/inter/Inter-Regular.otf',
|
||||
'./assets/fonts/inter/Inter-Italic.otf',
|
||||
'./assets/fonts/inter/Inter-Medium.otf',
|
||||
'./assets/fonts/inter/Inter-MediumItalic.otf',
|
||||
'./assets/fonts/inter/Inter-SemiBold.otf',
|
||||
'./assets/fonts/inter/Inter-SemiBoldItalic.otf',
|
||||
'./assets/fonts/inter/Inter-Bold.otf',
|
||||
'./assets/fonts/inter/Inter-BoldItalic.otf',
|
||||
'./assets/fonts/inter/Inter-ExtraBold.otf',
|
||||
'./assets/fonts/inter/Inter-ExtraBoldItalic.otf',
|
||||
'./assets/fonts/inter/Inter-Black.otf',
|
||||
'./assets/fonts/inter/Inter-BlackItalic.otf',
|
||||
'./assets/fonts/inter/InterVariable.ttf',
|
||||
'./assets/fonts/inter/InterVariable-Italic.ttf',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,24 +13,8 @@
|
||||
|
||||
<!-- Hello Humans! API docs at https://atproto.com -->
|
||||
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-Regular.1f5ed03b6dd9fd1f9982.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-Italic.95778eb0c75dc956257e.otf">
|
||||
<!--
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-Medium.296aa2d65964269836b3.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-MediumItalic.0e57e17a6311368e2114.otf">
|
||||
-->
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-SemiBold.2277990330981b8409bb.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-SemiBoldItalic.f62fea3df3a521d6c8a7.otf">
|
||||
<!--
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-Bold.8d330503e1d034ad68de.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-BoldItalic.bb17e63f9baa0d861a20.otf">
|
||||
-->
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-ExtraBold.ff2581a193bf6b7e0b06.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-ExtraBoldItalic.0e50b40728d24d40fdf4.otf">
|
||||
<!--
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-Black.66e9a87f1c921e844ed4.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-BlackItalic.27b9f0ad06fd13a7b9da.otf">
|
||||
-->
|
||||
<link rel="preload" as="font" type="font/ttf" href="/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf">
|
||||
<link rel="preload" as="font" type="font/ttf" href="/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf">
|
||||
|
||||
<style>
|
||||
/**
|
||||
@@ -53,7 +37,7 @@
|
||||
body {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
font-family: 'Inter-Regular', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Liberation Sans', Helvetica, Arial, sans-serif;
|
||||
font-family: InterVariable, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Liberation Sans', Helvetica, Arial, sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
/* Platform-specific reset */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
@@ -54,21 +54,6 @@ jest.mock('expo-image-manipulator', () => ({
|
||||
SaveFormat: jest.requireActual('expo-image-manipulator').SaveFormat,
|
||||
}))
|
||||
|
||||
jest.mock('@segment/analytics-react-native', () => ({
|
||||
createClient: () => ({
|
||||
add: jest.fn(),
|
||||
}),
|
||||
useAnalytics: () => ({
|
||||
track: jest.fn(),
|
||||
identify: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
group: jest.fn(),
|
||||
screen: jest.fn(),
|
||||
alias: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('expo-camera', () => ({
|
||||
Camera: {
|
||||
useCameraPermissions: jest.fn(() => [true]),
|
||||
|
||||
@@ -40,4 +40,4 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
@@ -1,5 +1,14 @@
|
||||
import UIKit
|
||||
|
||||
let IMAGE_EXTENSIONS: [String] = ["png", "jpg", "jpeg", "gif", "heic"]
|
||||
let MOVIE_EXTENSIONS: [String] = ["mov", "mp4", "m4v"]
|
||||
|
||||
enum URLType: String, CaseIterable {
|
||||
case image
|
||||
case movie
|
||||
case other
|
||||
}
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
// This allows other forks to use this extension while also changing their
|
||||
// scheme.
|
||||
@@ -23,7 +32,7 @@ class ShareViewController: UIViewController {
|
||||
await self.handleUrl(item: firstAttachment)
|
||||
} else if firstAttachment.hasItemConformingToTypeIdentifier("public.image") {
|
||||
await self.handleImages(items: attachments)
|
||||
} else if firstAttachment.hasItemConformingToTypeIdentifier("public.video") {
|
||||
} else if firstAttachment.hasItemConformingToTypeIdentifier("public.movie") {
|
||||
await self.handleVideos(items: attachments)
|
||||
} else {
|
||||
self.completeRequest()
|
||||
@@ -43,9 +52,18 @@ class ShareViewController: UIViewController {
|
||||
|
||||
private func handleUrl(item: NSItemProvider) async {
|
||||
if let data = try? await item.loadItem(forTypeIdentifier: "public.url") as? URL {
|
||||
if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
|
||||
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") {
|
||||
_ = self.openURL(url)
|
||||
switch data.type {
|
||||
case .image:
|
||||
await handleImages(items: [item])
|
||||
return
|
||||
case .movie:
|
||||
await handleVideos(items: [item])
|
||||
return
|
||||
case .other:
|
||||
if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
|
||||
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") {
|
||||
_ = self.openURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.completeRequest()
|
||||
@@ -101,13 +119,13 @@ class ShareViewController: UIViewController {
|
||||
private func handleVideos(items: [NSItemProvider]) async {
|
||||
let firstItem = items.first
|
||||
|
||||
if let dataUri = try? await firstItem?.loadItem(forTypeIdentifier: "public.video") as? URL {
|
||||
if let dataUri = try? await firstItem?.loadItem(forTypeIdentifier: "public.movie") as? URL {
|
||||
let ext = String(dataUri.lastPathComponent.split(separator: ".").last ?? "mp4")
|
||||
if let tempUrl = getTempUrl(ext: ext) {
|
||||
let data = try? Data(contentsOf: dataUri)
|
||||
try? data?.write(to: tempUrl)
|
||||
|
||||
if let encoded = dataUri.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
|
||||
if let encoded = tempUrl.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
|
||||
let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(encoded)") {
|
||||
_ = self.openURL(url)
|
||||
}
|
||||
@@ -150,10 +168,29 @@ class ShareViewController: UIViewController {
|
||||
var responder: UIResponder? = self
|
||||
while responder != nil {
|
||||
if let application = responder as? UIApplication {
|
||||
return application.perform(#selector(openURL(_:)), with: url) != nil
|
||||
application.open(url)
|
||||
return true
|
||||
}
|
||||
responder = responder?.next
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
var type: URLType {
|
||||
get {
|
||||
guard self.absoluteString.starts(with: "file://"),
|
||||
let ext = self.pathComponents.last?.split(separator: ".").last?.lowercased() else {
|
||||
return .other
|
||||
}
|
||||
|
||||
if IMAGE_EXTENSIONS.contains(ext) {
|
||||
return .image
|
||||
} else if MOVIE_EXTENSIONS.contains(ext) {
|
||||
return .movie
|
||||
}
|
||||
return .other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+79
-21
@@ -12,6 +12,11 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URLEncoder
|
||||
|
||||
enum class AttachmentType {
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
}
|
||||
|
||||
class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
override fun definition() =
|
||||
ModuleDefinition {
|
||||
@@ -23,17 +28,26 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
if (appContext.currentActivity == null || intent == null) return
|
||||
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
if (intent.type == "text/plain") {
|
||||
handleTextIntent(intent)
|
||||
} else if (intent.type.toString().startsWith("image/")) {
|
||||
handleImageIntent(intent)
|
||||
if (appContext.currentActivity == null) return
|
||||
intent?.let {
|
||||
if (it.action == Intent.ACTION_SEND && it.type == "text/plain") {
|
||||
handleTextIntent(it)
|
||||
return
|
||||
}
|
||||
} else if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
if (intent.type.toString().startsWith("image/")) {
|
||||
handleImagesIntent(intent)
|
||||
|
||||
val type =
|
||||
if (it.type.toString().startsWith("image/")) {
|
||||
AttachmentType.IMAGE
|
||||
} else if (it.type.toString().startsWith("video/")) {
|
||||
AttachmentType.VIDEO
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
if (it.action == Intent.ACTION_SEND) {
|
||||
handleAttachmentIntent(it, type)
|
||||
} else if (it.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
handleAttachmentsIntent(it, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,26 +62,46 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleImageIntent(intent: Intent) {
|
||||
private fun handleAttachmentIntent(
|
||||
intent: Intent,
|
||||
type: AttachmentType,
|
||||
) {
|
||||
val uri =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
if (uri == null) return
|
||||
|
||||
handleImageIntents(listOf(uri))
|
||||
uri?.let {
|
||||
when (type) {
|
||||
AttachmentType.IMAGE -> handleImageIntents(listOf(it))
|
||||
AttachmentType.VIDEO -> handleVideoIntents(listOf(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleImagesIntent(intent: Intent) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let {
|
||||
handleImageIntents(it.filterIsInstance<Uri>().take(4))
|
||||
private fun handleAttachmentsIntent(
|
||||
intent: Intent,
|
||||
type: AttachmentType,
|
||||
) {
|
||||
val uris =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent
|
||||
.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
?.filterIsInstance<Uri>()
|
||||
?.take(4)
|
||||
} else {
|
||||
intent
|
||||
.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
?.filterIsInstance<Uri>()
|
||||
?.take(4)
|
||||
}
|
||||
} else {
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let {
|
||||
handleImageIntents(it.filterIsInstance<Uri>().take(4))
|
||||
|
||||
uris?.let {
|
||||
when (type) {
|
||||
AttachmentType.IMAGE -> handleImageIntents(it)
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,11 +127,33 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVideoIntents(uris: List<Uri>) {
|
||||
val uri = uris[0]
|
||||
// If there is no extension for the file, substringAfterLast returns the original string - not
|
||||
// null, so we check for that below
|
||||
// It doesn't actually matter what the extension is, so defaulting to mp4 is fine, even if the
|
||||
// video isn't actually an mp4
|
||||
var extension = uri.path?.substringAfterLast(".")
|
||||
if (extension == null || extension == uri.path) {
|
||||
extension = "mp4"
|
||||
}
|
||||
val file = createFile(extension)
|
||||
|
||||
val out = FileOutputStream(file)
|
||||
appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use {
|
||||
it.copyTo(out)
|
||||
}
|
||||
"bluesky://intent/compose?videoUri=${URLEncoder.encode(file.path, "UTF-8")}".toUri().let {
|
||||
val newIntent = Intent(Intent.ACTION_VIEW, it)
|
||||
appContext.currentActivity?.startActivity(newIntent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getImageInfo(uri: Uri): Map<String, Any> {
|
||||
val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
|
||||
// We have to save this so that we can access it later when uploading the image.
|
||||
// createTempFile will automatically place a unique string between "img" and "temp.jpeg"
|
||||
val file = File.createTempFile("img", "temp.jpeg", appContext.currentActivity?.cacheDir)
|
||||
val file = createFile("jpeg")
|
||||
val out = FileOutputStream(file)
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
||||
out.flush()
|
||||
@@ -110,6 +166,8 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun createFile(extension: String): File = File.createTempFile(extension, "temp.$extension", appContext.currentActivity?.cacheDir)
|
||||
|
||||
// We will pas the width and height to the app here, since getting measurements
|
||||
// on the RN side is a bit more involved, and we already have them here anyway.
|
||||
private fun buildUriData(info: Map<String, Any>): String {
|
||||
|
||||
+1
-5
@@ -53,7 +53,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.13.7",
|
||||
"@atproto/api": "^0.13.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -81,10 +81,6 @@
|
||||
"@react-navigation/drawer": "^6.6.15",
|
||||
"@react-navigation/native": "^6.1.17",
|
||||
"@react-navigation/native-stack": "^6.9.26",
|
||||
"@segment/analytics-next": "^1.51.3",
|
||||
"@segment/analytics-react": "^1.0.0-rc1",
|
||||
"@segment/analytics-react-native": "^2.10.1",
|
||||
"@segment/sovran-react-native": "^0.4.5",
|
||||
"@sentry/react-native": "5.32.0",
|
||||
"@tamagui/focus-scope": "^1.84.1",
|
||||
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||
|
||||
@@ -27,6 +27,29 @@ const withIntentFilters = config => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
action: [
|
||||
{
|
||||
$: {
|
||||
'android:name': 'android.intent.action.SEND',
|
||||
},
|
||||
},
|
||||
],
|
||||
category: [
|
||||
{
|
||||
$: {
|
||||
'android:name': 'android.intent.category.DEFAULT',
|
||||
},
|
||||
},
|
||||
],
|
||||
data: [
|
||||
{
|
||||
$: {
|
||||
'android:mimeType': 'video/*',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
action: [
|
||||
{
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
StackActions,
|
||||
} from '@react-navigation/native'
|
||||
|
||||
import {init as initAnalytics} from '#/lib/analytics/analytics'
|
||||
import {timeout} from '#/lib/async/timeout'
|
||||
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
@@ -647,8 +646,6 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
function onReady() {
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
initAnalytics(currentAccount)
|
||||
|
||||
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
|
||||
openModal({name: 'verify-email', showReminder: true})
|
||||
snoozeEmailConfirmationPrompt()
|
||||
|
||||
+2
-20
@@ -40,28 +40,10 @@ export function applyFonts(
|
||||
fontFamily: 'system' | 'theme',
|
||||
) {
|
||||
if (fontFamily === 'theme') {
|
||||
style.fontFamily =
|
||||
{
|
||||
// '100': 'Inter-Thin',
|
||||
// '200': 'Inter-ExtraLight',
|
||||
// '300': 'Inter-Light',
|
||||
'100': 'Inter-Regular',
|
||||
'200': 'Inter-Regular',
|
||||
'300': 'Inter-Regular',
|
||||
'400': 'Inter-Regular',
|
||||
'500': 'Inter-Medium',
|
||||
'600': 'Inter-SemiBold',
|
||||
'700': 'Inter-Bold',
|
||||
'800': 'Inter-ExtraBold',
|
||||
'900': 'Inter-Black',
|
||||
}[style.fontWeight as string] || 'Inter-Regular'
|
||||
style.fontFamily = 'InterVariable'
|
||||
|
||||
if (style.fontStyle === 'italic') {
|
||||
if (style.fontFamily === 'Inter-Regular') {
|
||||
style.fontFamily = 'Inter-Italic'
|
||||
} else {
|
||||
style.fontFamily += 'Italic'
|
||||
}
|
||||
style.fontFamily += 'Italic'
|
||||
}
|
||||
|
||||
// fallback families only supported on web
|
||||
|
||||
@@ -305,6 +305,7 @@ export function createThemes({
|
||||
} as const
|
||||
|
||||
const light: Theme = {
|
||||
scheme: 'light',
|
||||
name: 'light',
|
||||
palette: lightPalette,
|
||||
atoms: {
|
||||
@@ -390,6 +391,7 @@ export function createThemes({
|
||||
}
|
||||
|
||||
const dark: Theme = {
|
||||
scheme: 'dark',
|
||||
name: 'dark',
|
||||
palette: darkPalette,
|
||||
atoms: {
|
||||
@@ -479,6 +481,7 @@ export function createThemes({
|
||||
|
||||
const dim: Theme = {
|
||||
...dark,
|
||||
scheme: 'dark',
|
||||
name: 'dim',
|
||||
palette: dimPalette,
|
||||
atoms: {
|
||||
|
||||
@@ -60,6 +60,15 @@ export const fontWeight = {
|
||||
} as const
|
||||
|
||||
export const gradients = {
|
||||
primary: {
|
||||
values: [
|
||||
[0, '#054CFF'],
|
||||
[0.4, '#1085FE'],
|
||||
[0.6, '#1085FE'],
|
||||
[1, '#59B9FF'],
|
||||
],
|
||||
hover_value: '#1085FE',
|
||||
},
|
||||
sky: {
|
||||
values: [
|
||||
[0, '#0A7AFF'],
|
||||
|
||||
@@ -156,6 +156,7 @@ export type ThemedAtoms = {
|
||||
}
|
||||
}
|
||||
export type Theme = {
|
||||
scheme: 'light' | 'dark' // for library support
|
||||
name: ThemeName
|
||||
palette: Palette
|
||||
atoms: ThemedAtoms
|
||||
|
||||
@@ -126,10 +126,12 @@ function AccountItem({
|
||||
<UserAvatar avatar={profile?.avatar} size={24} />
|
||||
</View>
|
||||
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
|
||||
<Text style={[a.font_bold]}>
|
||||
<Text emoji style={[a.font_bold]}>
|
||||
{profile?.displayName || account.handle}{' '}
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text>
|
||||
<Text emoji style={[t.atoms.text_contrast_medium]}>
|
||||
{account.handle}
|
||||
</Text>
|
||||
</Text>
|
||||
{isCurrentAccount ? (
|
||||
<Check
|
||||
|
||||
@@ -24,6 +24,7 @@ export type ButtonColor =
|
||||
| 'secondary'
|
||||
| 'secondary_inverted'
|
||||
| 'negative'
|
||||
| 'gradient_primary'
|
||||
| 'gradient_sky'
|
||||
| 'gradient_midnight'
|
||||
| 'gradient_sunrise'
|
||||
@@ -412,6 +413,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
secondary: tokens.gradients.sky,
|
||||
secondary_inverted: tokens.gradients.sky,
|
||||
negative: tokens.gradients.sky,
|
||||
gradient_primary: tokens.gradients.primary,
|
||||
gradient_sky: tokens.gradients.sky,
|
||||
gradient_midnight: tokens.gradients.midnight,
|
||||
gradient_sunrise: tokens.gradients.sunrise,
|
||||
@@ -444,7 +446,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
[state, variant, color, size, disabled],
|
||||
)
|
||||
|
||||
const flattenedBaseStyles = flatten(baseStyles)
|
||||
const flattenedBaseStyles = flatten([baseStyles, style])
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -464,7 +466,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
flattenedBaseStyles,
|
||||
flatten(style),
|
||||
...(state.hovered || state.pressed
|
||||
? [hoverStyles, flatten(hoverStyleProp)]
|
||||
: []),
|
||||
@@ -626,9 +627,9 @@ export function useSharedButtonTextStyles() {
|
||||
}
|
||||
|
||||
if (size === 'large') {
|
||||
baseStyles.push(a.text_md, a.leading_tight, web({paddingTop: 1}))
|
||||
baseStyles.push(a.text_md, a.leading_tight, web({top: -0.4}))
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.text_sm, a.leading_tight, web({paddingTop: 1}))
|
||||
baseStyles.push(a.text_sm, a.leading_tight, web({top: -0.4}))
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push(a.text_xs, a.leading_tight)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import QRCode from 'react-native-qrcode-styled'
|
||||
import ViewShot from 'react-native-view-shot'
|
||||
import type ViewShot from 'react-native-view-shot'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {Logo} from 'view/icons/Logo'
|
||||
import {Logotype} from 'view/icons/Logotype'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const LazyViewShot = React.lazy(
|
||||
// @ts-expect-error dynamic import
|
||||
() => import('react-native-view-shot/src/index'),
|
||||
)
|
||||
|
||||
interface Props {
|
||||
starterPack: AppBskyGraphDefs.StarterPackView
|
||||
link: string
|
||||
@@ -29,7 +34,7 @@ export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
|
||||
}
|
||||
|
||||
return (
|
||||
<ViewShot ref={ref}>
|
||||
<LazyViewShot ref={ref}>
|
||||
<LinearGradientBackground
|
||||
style={[
|
||||
{width: 300, minHeight: 390},
|
||||
@@ -79,7 +84,7 @@ export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradientBackground>
|
||||
</ViewShot>
|
||||
</LazyViewShot>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import ViewShot from 'react-native-view-shot'
|
||||
import type ViewShot from 'react-native-view-shot'
|
||||
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
||||
import {createAssetAsync} from 'expo-media-library'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
@@ -8,9 +8,9 @@ import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
@@ -153,46 +153,54 @@ export function QrCodeDialog({
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Create a QR code for a starter pack`)}>
|
||||
<View style={[a.flex_1, a.align_center, a.gap_5xl]}>
|
||||
{!link ? (
|
||||
<View style={[a.align_center, a.p_xl]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<QrCode starterPack={starterPack} link={link} ref={ref} />
|
||||
{isProcessing ? (
|
||||
<View>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={[a.w_full, a.gap_md, isWeb && [a.flex_row_reverse]]}>
|
||||
<Button
|
||||
label={_(msg`Copy QR code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={isWeb ? onCopyPress : onSharePress}>
|
||||
<ButtonText>
|
||||
{isWeb ? <Trans>Copy</Trans> : <Trans>Share</Trans>}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Save QR code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={onSavePress}>
|
||||
<ButtonText>
|
||||
<Trans>Save</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<React.Suspense fallback={<Loading />}>
|
||||
{!link ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<QrCode starterPack={starterPack} link={link} ref={ref} />
|
||||
{isProcessing ? (
|
||||
<View>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={[a.w_full, a.gap_md, isWeb && [a.flex_row_reverse]]}>
|
||||
<Button
|
||||
label={_(msg`Copy QR code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={isWeb ? onCopyPress : onSharePress}>
|
||||
<ButtonText>
|
||||
{isWeb ? <Trans>Copy</Trans> : <Trans>Share</Trans>}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Save QR code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={onSavePress}>
|
||||
<ButtonText>
|
||||
<Trans>Save</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</React.Suspense>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<View style={[a.align_center, a.p_xl]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Svg, {Circle, Path} from 'react-native-svg'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Nux, useUpsertNuxMutation} from '#/state/queries/nuxs'
|
||||
import {atoms as a, ViewStyleProp} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {TenMillion} from './'
|
||||
|
||||
export function Trigger({children}: {children: ButtonProps['children']}) {
|
||||
const {_} = useLingui()
|
||||
const {mutate: upsertNux} = useUpsertNuxMutation()
|
||||
const [show, setShow] = React.useState(false)
|
||||
const [fallback, setFallback] = React.useState(false)
|
||||
const control = Prompt.usePromptControl()
|
||||
|
||||
const handleOnPress = () => {
|
||||
if (!fallback) {
|
||||
setShow(true)
|
||||
upsertNux({
|
||||
id: Nux.TenMillionDialog,
|
||||
completed: true,
|
||||
data: undefined,
|
||||
})
|
||||
} else {
|
||||
control.open()
|
||||
}
|
||||
}
|
||||
|
||||
const onHandleFallback = () => {
|
||||
setFallback(true)
|
||||
control.open()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Bluesky is celebrating 10 million users!`)}
|
||||
onPress={handleOnPress}>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
{show && !fallback && (
|
||||
<TenMillion
|
||||
showTimeout={0}
|
||||
onClose={() => setShow(false)}
|
||||
onFallback={onHandleFallback}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Prompt.Outer control={control}>
|
||||
<View style={{maxWidth: 300}}>
|
||||
<Prompt.TitleText>
|
||||
<Trans>Bluesky is celebrating 10 million users!</Trans>
|
||||
</Prompt.TitleText>
|
||||
</View>
|
||||
<Prompt.DescriptionText>
|
||||
<Trans>
|
||||
Together, we're rebuilding the social internet. We're glad you're
|
||||
here.
|
||||
</Trans>
|
||||
</Prompt.DescriptionText>
|
||||
<Prompt.DescriptionText>
|
||||
<Trans>
|
||||
To learn more,{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`View our post`)}
|
||||
to="/profile/bsky.app/post/3l47prg3wgy23"
|
||||
onPress={() => {
|
||||
control.close()
|
||||
}}
|
||||
style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>check out our post.</Trans>
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</Prompt.DescriptionText>
|
||||
<Dialog.Close />
|
||||
</Prompt.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Icon({width, style}: {width: number} & ViewStyleProp) {
|
||||
return (
|
||||
<Svg width={width} height={width} viewBox="0 0 36 36" style={style}>
|
||||
<Path
|
||||
fill="#dd2e44"
|
||||
d="M11.626 7.488a1.4 1.4 0 0 0-.268.395l-.008-.008L.134 33.141l.011.011c-.208.403.14 1.223.853 1.937c.713.713 1.533 1.061 1.936.853l.01.01L28.21 24.735l-.008-.009c.147-.07.282-.155.395-.269c1.562-1.562-.971-6.627-5.656-11.313c-4.687-4.686-9.752-7.218-11.315-5.656"
|
||||
/>
|
||||
<Path
|
||||
fill="#ea596e"
|
||||
d="M13 12L.416 32.506l-.282.635l.011.011c-.208.403.14 1.223.853 1.937c.232.232.473.408.709.557L17 17z"
|
||||
/>
|
||||
<Path
|
||||
fill="#a0041e"
|
||||
d="M23.012 13.066c4.67 4.672 7.263 9.652 5.789 11.124c-1.473 1.474-6.453-1.118-11.126-5.788c-4.671-4.672-7.263-9.654-5.79-11.127c1.474-1.473 6.454 1.119 11.127 5.791"
|
||||
/>
|
||||
<Path
|
||||
fill="#aa8dd8"
|
||||
d="M18.59 13.609a1 1 0 0 1-.734.215c-.868-.094-1.598-.396-2.109-.873c-.541-.505-.808-1.183-.735-1.862c.128-1.192 1.324-2.286 3.363-2.066c.793.085 1.147-.17 1.159-.292c.014-.121-.277-.446-1.07-.532c-.868-.094-1.598-.396-2.11-.873c-.541-.505-.809-1.183-.735-1.862c.13-1.192 1.325-2.286 3.362-2.065c.578.062.883-.057 1.012-.134c.103-.063.144-.123.148-.158c.012-.121-.275-.446-1.07-.532a1 1 0 0 1-.886-1.102a.997.997 0 0 1 1.101-.886c2.037.219 2.973 1.542 2.844 2.735c-.13 1.194-1.325 2.286-3.364 2.067c-.578-.063-.88.057-1.01.134c-.103.062-.145.123-.149.157c-.013.122.276.446 1.071.532c2.037.22 2.973 1.542 2.844 2.735s-1.324 2.286-3.362 2.065c-.578-.062-.882.058-1.012.134c-.104.064-.144.124-.148.158c-.013.121.276.446 1.07.532a1 1 0 0 1 .52 1.773"
|
||||
/>
|
||||
<Path
|
||||
fill="#77b255"
|
||||
d="M30.661 22.857c1.973-.557 3.334.323 3.658 1.478c.324 1.154-.378 2.615-2.35 3.17c-.77.216-1.001.584-.97.701c.034.118.425.312 1.193.095c1.972-.555 3.333.325 3.657 1.479c.326 1.155-.378 2.614-2.351 3.17c-.769.216-1.001.585-.967.702s.423.311 1.192.095a1 1 0 1 1 .54 1.925c-1.971.555-3.333-.323-3.659-1.479c-.324-1.154.379-2.613 2.353-3.169c.77-.217 1.001-.584.967-.702c-.032-.117-.422-.312-1.19-.096c-1.974.556-3.334-.322-3.659-1.479c-.325-1.154.378-2.613 2.351-3.17c.768-.215.999-.585.967-.701c-.034-.118-.423-.312-1.192-.096a1 1 0 1 1-.54-1.923"
|
||||
/>
|
||||
<Path
|
||||
fill="#aa8dd8"
|
||||
d="M23.001 20.16a1.001 1.001 0 0 1-.626-1.781c.218-.175 5.418-4.259 12.767-3.208a1 1 0 1 1-.283 1.979c-6.493-.922-11.187 2.754-11.233 2.791a1 1 0 0 1-.625.219"
|
||||
/>
|
||||
<Path
|
||||
fill="#77b255"
|
||||
d="M5.754 16a1 1 0 0 1-.958-1.287c1.133-3.773 2.16-9.794.898-11.364c-.141-.178-.354-.353-.842-.316c-.938.072-.849 2.051-.848 2.071a1 1 0 1 1-1.994.149c-.103-1.379.326-4.035 2.692-4.214c1.056-.08 1.933.287 2.552 1.057c2.371 2.951-.036 11.506-.542 13.192a1 1 0 0 1-.958.712"
|
||||
/>
|
||||
<Circle cx="25.5" cy="9.5" r="1.5" fill="#5c913b" />
|
||||
<Circle cx="2" cy="18" r="2" fill="#9266cc" />
|
||||
<Circle cx="32.5" cy="19.5" r="1.5" fill="#5c913b" />
|
||||
<Circle cx="23.5" cy="31.5" r="1.5" fill="#5c913b" />
|
||||
<Circle cx="28" cy="4" r="2" fill="#ffcc4d" />
|
||||
<Circle cx="32.5" cy="8.5" r="1.5" fill="#ffcc4d" />
|
||||
<Circle cx="29.5" cy="12.5" r="1.5" fill="#ffcc4d" />
|
||||
<Circle cx="7.5" cy="23.5" r="1.5" fill="#ffcc4d" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,712 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {FadeIn} from 'react-native-reanimated'
|
||||
import ViewShot from 'react-native-view-shot'
|
||||
import {Image} from 'expo-image'
|
||||
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
||||
import * as MediaLibrary from 'expo-media-library'
|
||||
import {moderateProfile} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {getCanvas} from '#/lib/canvas'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {Logomark} from '#/view/icons/Logomark'
|
||||
import {
|
||||
atoms as a,
|
||||
ThemeProvider,
|
||||
tokens,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {OnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/OnePercent'
|
||||
import {PointOnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/PointOnePercent'
|
||||
import {TenPercent} from '#/components/dialogs/nuxs/TenMillion/icons/TenPercent'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {GradientFill} from '#/components/GradientFill'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||
import {Download_Stroke2_Corner0_Rounded as Download} from '#/components/icons/Download'
|
||||
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const DEBUG = false
|
||||
const RATIO = 8 / 10
|
||||
const WIDTH = 2000
|
||||
const HEIGHT = WIDTH * RATIO
|
||||
|
||||
function getFontSize(count: number) {
|
||||
const length = count.toString().length
|
||||
if (length < 7) {
|
||||
return 80
|
||||
} else if (length < 5) {
|
||||
return 100
|
||||
} else {
|
||||
return 70
|
||||
}
|
||||
}
|
||||
|
||||
function getPercentBadge(percent: number) {
|
||||
if (percent <= 0.001) {
|
||||
return PointOnePercent
|
||||
} else if (percent <= 0.01) {
|
||||
return OnePercent
|
||||
} else if (percent <= 0.1) {
|
||||
return TenPercent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function Frame({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.w_full,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
paddingTop: '80%',
|
||||
},
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function TenMillion({
|
||||
showTimeout,
|
||||
onClose,
|
||||
onFallback,
|
||||
}: {
|
||||
showTimeout?: number
|
||||
onClose?: () => void
|
||||
onFallback?: () => void
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const [userNumber, setUserNumber] = React.useState<number>(0)
|
||||
const fetching = React.useRef(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchUserNumber() {
|
||||
const isBlueskyHosted = agent.sessionManager.pdsUrl
|
||||
?.toString()
|
||||
.includes('bsky.network')
|
||||
|
||||
if (isBlueskyHosted && agent.session?.accessJwt) {
|
||||
const res = await fetch(
|
||||
`https://bsky.social/xrpc/com.atproto.temp.getSignupNumber`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${agent.session.accessJwt}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Network request failed')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.number && data.number <= 10_000_000) {
|
||||
setUserNumber(data.number)
|
||||
} else {
|
||||
// should be rare
|
||||
nuxDialogs.dismissActiveNux()
|
||||
onFallback?.()
|
||||
}
|
||||
} else {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
onFallback?.()
|
||||
}
|
||||
}
|
||||
|
||||
if (!fetching.current) {
|
||||
fetching.current = true
|
||||
networkRetry(3, fetchUserNumber).catch(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
onFallback?.()
|
||||
})
|
||||
}
|
||||
}, [
|
||||
agent.sessionManager.pdsUrl,
|
||||
agent.session?.accessJwt,
|
||||
setUserNumber,
|
||||
nuxDialogs.dismissActiveNux,
|
||||
nuxDialogs,
|
||||
onFallback,
|
||||
])
|
||||
|
||||
return userNumber ? (
|
||||
<TenMillionInner
|
||||
userNumber={userNumber}
|
||||
showTimeout={showTimeout ?? 3e3}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
export function TenMillionInner({
|
||||
userNumber,
|
||||
showTimeout,
|
||||
onClose: onCloseOuter,
|
||||
}: {
|
||||
userNumber: number
|
||||
showTimeout: number
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const lightTheme = useTheme('light')
|
||||
const {_, i18n} = useLingui()
|
||||
const control = Dialog.useDialogControl()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {openComposer} = useComposerControls()
|
||||
const {currentAccount} = useSession()
|
||||
const {
|
||||
isLoading: isProfileLoading,
|
||||
data: profile,
|
||||
error: profileError,
|
||||
} = useProfileQuery({
|
||||
did: currentAccount!.did,
|
||||
})
|
||||
const moderationOpts = useModerationOpts()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const moderation = React.useMemo(() => {
|
||||
return profile && moderationOpts
|
||||
? moderateProfile(profile, moderationOpts)
|
||||
: undefined
|
||||
}, [profile, moderationOpts])
|
||||
const [uri, setUri] = React.useState<string | null>(null)
|
||||
const percent = userNumber / 10_000_000
|
||||
const Badge = getPercentBadge(percent)
|
||||
const isLoadingData = isProfileLoading || !moderation || !profile
|
||||
const isLoadingImage = !uri
|
||||
|
||||
const displayName = React.useMemo(() => {
|
||||
if (!profile || !moderation) return ''
|
||||
return sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
}, [profile, moderation])
|
||||
const handle = React.useMemo(() => {
|
||||
if (!profile) return ''
|
||||
return sanitizeHandle(profile.handle, '@')
|
||||
}, [profile])
|
||||
const joinedDate = React.useMemo(() => {
|
||||
if (!profile || !profile.createdAt) return ''
|
||||
const date = i18n.date(profile.createdAt, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
return date
|
||||
}, [i18n, profile])
|
||||
|
||||
const error: string = React.useMemo(() => {
|
||||
if (profileError) {
|
||||
return _(
|
||||
msg`Oh no! We weren't able to generate an image for you to share. Rest assured, we're glad you're here 🦋`,
|
||||
)
|
||||
}
|
||||
return ''
|
||||
}, [_, profileError])
|
||||
|
||||
/*
|
||||
* Opening and closing
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
control.open()
|
||||
}, showTimeout)
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [control, showTimeout])
|
||||
const onClose = React.useCallback(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
onCloseOuter?.()
|
||||
}, [nuxDialogs, onCloseOuter])
|
||||
|
||||
/*
|
||||
* Actions
|
||||
*/
|
||||
const sharePost = React.useCallback(() => {
|
||||
if (uri) {
|
||||
control.close(() => {
|
||||
setTimeout(() => {
|
||||
logEvent('tmd:post', {})
|
||||
openComposer({
|
||||
text: _(
|
||||
msg`Bluesky now has over 10 million users, and I was #${i18n.number(
|
||||
userNumber,
|
||||
)}!`,
|
||||
),
|
||||
imageUris: [
|
||||
{
|
||||
uri,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
altText: _(
|
||||
msg`A virtual certificate with text "Celebrating 10M users on Bluesky, #${i18n.number(
|
||||
userNumber,
|
||||
)}, ${displayName} ${handle}, joined on ${joinedDate}"`,
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
}, 1e3)
|
||||
})
|
||||
}
|
||||
}, [
|
||||
_,
|
||||
i18n,
|
||||
control,
|
||||
openComposer,
|
||||
uri,
|
||||
userNumber,
|
||||
displayName,
|
||||
handle,
|
||||
joinedDate,
|
||||
])
|
||||
const onNativeShare = React.useCallback(() => {
|
||||
if (uri) {
|
||||
control.close(() => {
|
||||
logEvent('tmd:share', {})
|
||||
shareUrl(uri)
|
||||
})
|
||||
}
|
||||
}, [uri, control])
|
||||
const onNativeDownload = React.useCallback(async () => {
|
||||
if (uri) {
|
||||
const res = await requestMediaLibraryPermissionsAsync()
|
||||
|
||||
if (!res) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`You must grant access to your photo library to save the image.`,
|
||||
),
|
||||
'xmark',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await MediaLibrary.createAssetAsync(uri)
|
||||
logEvent('tmd:download', {})
|
||||
Toast.show(_(msg`Image saved to your camera roll!`))
|
||||
} catch (e: unknown) {
|
||||
console.log(e)
|
||||
Toast.show(_(msg`An error occurred while saving the image!`), 'xmark')
|
||||
return
|
||||
}
|
||||
}
|
||||
}, [_, uri])
|
||||
const onWebDownload = React.useCallback(async () => {
|
||||
if (uri) {
|
||||
const canvas = await getCanvas(uri)
|
||||
const imgHref = canvas
|
||||
.toDataURL('image/png')
|
||||
.replace('image/png', 'image/octet-stream')
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('download', `Bluesky 10M Users.png`)
|
||||
link.setAttribute('href', imgHref)
|
||||
link.click()
|
||||
logEvent('tmd:download', {})
|
||||
}
|
||||
}, [uri])
|
||||
|
||||
/*
|
||||
* Canvas stuff
|
||||
*/
|
||||
const imageRef = React.useRef<ViewShot>(null)
|
||||
const captureInProgress = React.useRef(false)
|
||||
const onCanvasReady = React.useCallback(async () => {
|
||||
if (
|
||||
imageRef.current &&
|
||||
imageRef.current.capture &&
|
||||
!captureInProgress.current
|
||||
) {
|
||||
captureInProgress.current = true
|
||||
const uri = await imageRef.current.capture()
|
||||
setUri(uri)
|
||||
}
|
||||
}, [setUri])
|
||||
const canvas = isLoadingData ? null : (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.overflow_hidden,
|
||||
DEBUG
|
||||
? {
|
||||
width: 600,
|
||||
height: 600 * RATIO,
|
||||
}
|
||||
: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
]}>
|
||||
<View style={{width: 600}}>
|
||||
<ThemeProvider theme="light">
|
||||
<Frame>
|
||||
<ViewShot
|
||||
ref={imageRef}
|
||||
options={{width: WIDTH, height: HEIGHT}}
|
||||
style={[a.absolute, a.inset_0]}>
|
||||
<View
|
||||
onLayout={onCanvasReady}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
top: -1,
|
||||
bottom: -1,
|
||||
left: -1,
|
||||
right: -1,
|
||||
paddingVertical: 48,
|
||||
paddingHorizontal: 48,
|
||||
},
|
||||
]}>
|
||||
<GradientFill gradient={tokens.gradients.bonfire} />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_md,
|
||||
{
|
||||
backgroundColor: 'white',
|
||||
shadowRadius: 32,
|
||||
shadowOpacity: 0.1,
|
||||
elevation: 24,
|
||||
shadowColor: tokens.gradients.bonfire.values[0][1],
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.px_xl,
|
||||
a.py_xl,
|
||||
{
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
]}>
|
||||
<Logomark fill={t.palette.primary_500} width={36} />
|
||||
</View>
|
||||
|
||||
{/* Centered content */}
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingBottom: isNative ? 0 : 24,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
a.pb_sm,
|
||||
lightTheme.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
Celebrating {formatCount(i18n, 10000000)} users
|
||||
</Trans>{' '}
|
||||
🎉
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.align_start]}>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.font_heavy,
|
||||
{
|
||||
color: t.palette.primary_500,
|
||||
fontSize: 32,
|
||||
width: 32,
|
||||
top: isNative ? -10 : 0,
|
||||
left: 0,
|
||||
transform: [
|
||||
{
|
||||
translateX: -16,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}>
|
||||
#
|
||||
</Text>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
a.relative,
|
||||
a.text_center,
|
||||
a.font_heavy,
|
||||
{
|
||||
fontStyle: 'italic',
|
||||
fontSize: getFontSize(userNumber),
|
||||
lineHeight: getFontSize(userNumber),
|
||||
letterSpacing: -2,
|
||||
},
|
||||
]}>
|
||||
{i18n.number(userNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{Badge && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
width: 64,
|
||||
height: 64,
|
||||
top: isNative ? 75 : 85,
|
||||
right: '5%',
|
||||
transform: [
|
||||
{
|
||||
rotate: '8deg',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}>
|
||||
<Badge fill={t.palette.primary_500} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* End centered content */}
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.px_xl,
|
||||
a.py_xl,
|
||||
{
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{/*
|
||||
<UserAvatar
|
||||
size={36}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
onLoad={onCanvasReady}
|
||||
/>
|
||||
*/}
|
||||
<View style={[a.gap_2xs, a.flex_1]}>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
{maxWidth: '60%'},
|
||||
]}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<View
|
||||
style={[a.flex_row, a.justify_between, a.gap_4xl]}>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
lightTheme.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{handle}
|
||||
</Text>
|
||||
|
||||
{profile.createdAt && (
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="head"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
a.text_right,
|
||||
lightTheme.atoms.text_contrast_low,
|
||||
]}>
|
||||
<Trans>Joined on {joinedDate}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ViewShot>
|
||||
</Frame>
|
||||
</ThemeProvider>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control} onClose={onClose}>
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Ten Million`)}
|
||||
style={[
|
||||
{
|
||||
padding: 0,
|
||||
paddingTop: 0,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
isNative && {
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
},
|
||||
]}>
|
||||
<Frame>
|
||||
<View
|
||||
style={[a.absolute, a.inset_0, a.align_center, a.justify_center]}>
|
||||
<GradientFill gradient={tokens.gradients.bonfire} />
|
||||
{error ? (
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
a.pb_md,
|
||||
{
|
||||
maxWidth: 300,
|
||||
},
|
||||
]}>
|
||||
(╯°□°)╯︵ ┻━┻
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xl,
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
{
|
||||
maxWidth: 300,
|
||||
},
|
||||
]}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
) : isLoadingData || isLoadingImage ? (
|
||||
<Loader size="xl" fill="white" />
|
||||
) : (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(150)}
|
||||
style={[a.w_full, a.h_full]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={{uri}}
|
||||
style={[a.w_full, a.h_full]}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</Frame>
|
||||
|
||||
{canvas}
|
||||
|
||||
<View style={[gtMobile ? a.p_2xl : a.p_xl]}>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
style={[a.text_5xl, a.leading_tight, a.pb_lg, a.font_heavy]}>
|
||||
<Trans>Thanks for being one of our first 10 million users.</Trans>
|
||||
</Text>
|
||||
|
||||
<Text style={[a.leading_snug, a.text_lg, a.pb_xl]}>
|
||||
<Trans>
|
||||
Together, we're rebuilding the social internet. We're glad
|
||||
you're here.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_end,
|
||||
a.gap_md,
|
||||
a.pt_xl,
|
||||
]}>
|
||||
{gtMobile && (
|
||||
<Text
|
||||
style={[a.text_md, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Brag a little!</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
disabled={isLoadingImage}
|
||||
label={
|
||||
isNative && isIOS
|
||||
? _(msg`Share image externally`)
|
||||
: _(msg`Download image`)
|
||||
}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="square"
|
||||
onPress={
|
||||
isNative
|
||||
? isIOS
|
||||
? onNativeShare
|
||||
: onNativeDownload
|
||||
: onWebDownload
|
||||
}>
|
||||
<ButtonIcon icon={isNative && isIOS ? Share : Download} />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isLoadingImage}
|
||||
label={_(msg`Share image in post`)}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onPress={sharePost}>
|
||||
<ButtonText>{_(msg`Share`)}</ButtonText>
|
||||
<ButtonIcon position="right" icon={ImageIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -16,10 +16,11 @@ import {
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {SessionAccount, useSession} from '#/state/session'
|
||||
import {useOnboardingState} from '#/state/shell'
|
||||
/*
|
||||
* NUXs
|
||||
*/
|
||||
import {NeueTypography} from '#/components/dialogs/nuxs/NeueTypography'
|
||||
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
|
||||
// NUXs
|
||||
import {TenMillion} from '#/components/dialogs/nuxs/TenMillion'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
type Context = {
|
||||
@@ -36,14 +37,11 @@ const queuedNuxs: {
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) => boolean
|
||||
}[] = [
|
||||
{
|
||||
id: Nux.TenMillionDialog,
|
||||
},
|
||||
{
|
||||
id: Nux.NeueTypography,
|
||||
enabled(props) {
|
||||
if (props.currentProfile.createdAt) {
|
||||
if (new Date(props.currentProfile.createdAt) < new Date('2024-09-25')) {
|
||||
if (new Date(props.currentProfile.createdAt) < new Date('2024-10-01')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -176,7 +174,6 @@ function Inner({
|
||||
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{activeNux === Nux.TenMillionDialog && <TenMillion />}
|
||||
{activeNux === Nux.NeueTypography && <NeueTypography />}
|
||||
</Context.Provider>
|
||||
)
|
||||
|
||||
@@ -135,6 +135,8 @@ export function createInput(Component: typeof TextInput) {
|
||||
placeholder,
|
||||
value,
|
||||
onChangeText,
|
||||
onFocus,
|
||||
onBlur,
|
||||
isInvalid,
|
||||
inputRef,
|
||||
style,
|
||||
@@ -173,8 +175,14 @@ export function createInput(Component: typeof TextInput) {
|
||||
ref={refs}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={ctx.onFocus}
|
||||
onBlur={ctx.onBlur}
|
||||
onFocus={e => {
|
||||
ctx.onFocus()
|
||||
onFocus?.(e)
|
||||
}}
|
||||
onBlur={e => {
|
||||
ctx.onBlur()
|
||||
onBlur?.(e)
|
||||
}}
|
||||
placeholder={placeholder || label}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
@@ -188,8 +196,8 @@ export function createInput(Component: typeof TextInput) {
|
||||
a.px_xs,
|
||||
{
|
||||
// paddingVertical doesn't work w/multiline - esb
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 13,
|
||||
lineHeight: a.text_md.fontSize * 1.1875,
|
||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||
minHeight: rest.multiline ? 80 : undefined,
|
||||
@@ -197,13 +205,14 @@ export function createInput(Component: typeof TextInput) {
|
||||
},
|
||||
// fix for autofill styles covering border
|
||||
web({
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 11,
|
||||
marginTop: 2,
|
||||
marginBottom: 2,
|
||||
}),
|
||||
android({
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
}),
|
||||
style,
|
||||
]}
|
||||
|
||||
@@ -9,8 +9,8 @@ export function useHeaderOffset() {
|
||||
return 0
|
||||
}
|
||||
const navBarHeight = 42
|
||||
const tabBarPad = 10 + 10 + 3 // padding + border
|
||||
const normalLineHeight = 1.2
|
||||
const tabBarText = 16 * normalLineHeight * fontScale
|
||||
const tabBarPad = 10 + 10 + 6 // padding + arbitrary
|
||||
const normalLineHeight = 20 // matches tab bar
|
||||
const tabBarText = normalLineHeight * fontScale
|
||||
return navBarHeight + tabBarPad + tabBarText
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import React from 'react'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createClient, SegmentClient} from '@segment/analytics-react-native'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
import {sha256} from 'js-sha256'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {SessionAccount, useSession} from '#/state/session'
|
||||
import {ScreenPropertiesMap, TrackPropertiesMap} from './types'
|
||||
|
||||
type AppInfo = {
|
||||
build?: string | undefined
|
||||
name?: string | undefined
|
||||
namespace?: string | undefined
|
||||
version?: string | undefined
|
||||
}
|
||||
|
||||
// Delay creating until first actual use.
|
||||
let segmentClient: SegmentClient | null = null
|
||||
function getClient(): SegmentClient {
|
||||
if (!segmentClient) {
|
||||
segmentClient = createClient({
|
||||
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
|
||||
trackAppLifecycleEvents: false,
|
||||
proxy: 'https://api.events.bsky.app/v1',
|
||||
})
|
||||
}
|
||||
return segmentClient
|
||||
}
|
||||
|
||||
export const track = async <E extends keyof TrackPropertiesMap>(
|
||||
event: E,
|
||||
properties?: TrackPropertiesMap[E],
|
||||
) => {
|
||||
await getClient().track(event, properties)
|
||||
}
|
||||
|
||||
export function useAnalytics() {
|
||||
const {hasSession} = useSession()
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (hasSession) {
|
||||
return {
|
||||
async screen<E extends keyof ScreenPropertiesMap>(
|
||||
event: E,
|
||||
properties?: ScreenPropertiesMap[E],
|
||||
) {
|
||||
await getClient().screen(event, properties)
|
||||
},
|
||||
async track<E extends keyof TrackPropertiesMap>(
|
||||
event: E,
|
||||
properties?: TrackPropertiesMap[E],
|
||||
) {
|
||||
await getClient().track(event, properties)
|
||||
},
|
||||
}
|
||||
}
|
||||
// dont send analytics pings for anonymous users
|
||||
return {
|
||||
screen: async () => {},
|
||||
track: async () => {},
|
||||
}
|
||||
}, [hasSession])
|
||||
}
|
||||
|
||||
export function init(account: SessionAccount | undefined) {
|
||||
setupListenersOnce()
|
||||
|
||||
if (account) {
|
||||
const client = getClient()
|
||||
if (account.did) {
|
||||
const did_hashed = sha256(account.did)
|
||||
client.identify(did_hashed, {did_hashed})
|
||||
Sentry.setUser({id: did_hashed})
|
||||
logger.debug('Ping w/hash')
|
||||
} else {
|
||||
logger.debug('Ping w/o hash')
|
||||
client.identify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let didSetupListeners = false
|
||||
function setupListenersOnce() {
|
||||
if (didSetupListeners) {
|
||||
return
|
||||
}
|
||||
didSetupListeners = true
|
||||
// NOTE
|
||||
// this is a copy of segment's own lifecycle event tracking
|
||||
// we handle it manually to ensure that it never fires while the app is backgrounded
|
||||
// -prf
|
||||
const client = getClient()
|
||||
client.isReady.onChange(async () => {
|
||||
if (AppState.currentState !== 'active') {
|
||||
logger.debug('Prevented a metrics ping while the app was backgrounded')
|
||||
return
|
||||
}
|
||||
const context = client.context.get()
|
||||
if (typeof context?.app === 'undefined') {
|
||||
logger.debug('Aborted metrics ping due to unavailable context')
|
||||
return
|
||||
}
|
||||
|
||||
const oldAppInfo = await readAppInfo()
|
||||
const newAppInfo = context.app as AppInfo
|
||||
writeAppInfo(newAppInfo)
|
||||
logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
|
||||
|
||||
if (typeof oldAppInfo === 'undefined') {
|
||||
client.track('Application Installed', {
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
})
|
||||
} else if (newAppInfo.version !== oldAppInfo.version) {
|
||||
client.track('Application Updated', {
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
previous_version: oldAppInfo.version,
|
||||
previous_build: oldAppInfo.build,
|
||||
})
|
||||
}
|
||||
client.track('Application Opened', {
|
||||
from_background: false,
|
||||
version: newAppInfo.version,
|
||||
build: newAppInfo.build,
|
||||
})
|
||||
})
|
||||
|
||||
let lastState: AppStateStatus = AppState.currentState
|
||||
AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||
if (state === 'active' && lastState !== 'active') {
|
||||
const context = client.context.get()
|
||||
client.track('Application Opened', {
|
||||
from_background: true,
|
||||
version: context?.app?.version,
|
||||
build: context?.app?.build,
|
||||
})
|
||||
} else if (state !== 'active' && lastState === 'active') {
|
||||
client.track('Application Backgrounded')
|
||||
}
|
||||
lastState = state
|
||||
})
|
||||
}
|
||||
|
||||
async function writeAppInfo(value: AppInfo) {
|
||||
await AsyncStorage.setItem('BSKY_APP_INFO', JSON.stringify(value))
|
||||
}
|
||||
|
||||
async function readAppInfo(): Promise<AppInfo | undefined> {
|
||||
const rawData = await AsyncStorage.getItem('BSKY_APP_INFO')
|
||||
const obj = rawData ? JSON.parse(rawData) : undefined
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
return obj
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from 'react'
|
||||
import {createClient} from '@segment/analytics-react'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
import {sha256} from 'js-sha256'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {SessionAccount, useSession} from '#/state/session'
|
||||
import {ScreenPropertiesMap, TrackPropertiesMap} from './types'
|
||||
|
||||
type SegmentClient = ReturnType<typeof createClient>
|
||||
|
||||
// Delay creating until first actual use.
|
||||
let segmentClient: SegmentClient | null = null
|
||||
function getClient(): SegmentClient {
|
||||
if (!segmentClient) {
|
||||
segmentClient = createClient(
|
||||
{
|
||||
writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
|
||||
},
|
||||
{
|
||||
integrations: {
|
||||
'Segment.io': {
|
||||
apiHost: 'api.events.bsky.app/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
return segmentClient
|
||||
}
|
||||
|
||||
export const track = async <E extends keyof TrackPropertiesMap>(
|
||||
event: E,
|
||||
properties?: TrackPropertiesMap[E],
|
||||
) => {
|
||||
await getClient().track(event, properties)
|
||||
}
|
||||
|
||||
export function useAnalytics() {
|
||||
const {hasSession} = useSession()
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (hasSession) {
|
||||
return {
|
||||
async screen<E extends keyof ScreenPropertiesMap>(
|
||||
event: E,
|
||||
properties?: ScreenPropertiesMap[E],
|
||||
) {
|
||||
await getClient().screen(event, properties)
|
||||
},
|
||||
async track<E extends keyof TrackPropertiesMap>(
|
||||
event: E,
|
||||
properties?: TrackPropertiesMap[E],
|
||||
) {
|
||||
await getClient().track(event, properties)
|
||||
},
|
||||
}
|
||||
}
|
||||
// dont send analytics pings for anonymous users
|
||||
return {
|
||||
screen: async () => {},
|
||||
track: async () => {},
|
||||
}
|
||||
}, [hasSession])
|
||||
}
|
||||
|
||||
export function init(account: SessionAccount | undefined) {
|
||||
if (account) {
|
||||
const client = getClient()
|
||||
if (account.did) {
|
||||
const did_hashed = sha256(account.did)
|
||||
client.identify(did_hashed, {did_hashed})
|
||||
Sentry.setUser({id: did_hashed})
|
||||
logger.debug('Ping w/hash')
|
||||
} else {
|
||||
logger.debug('Ping w/o hash')
|
||||
client.identify()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
export type TrackPropertiesMap = {
|
||||
// LOGIN / SIGN UP events
|
||||
'Sign In': {resumedSession: boolean} // CAN BE SERVER
|
||||
'Create Account': {} // CAN BE SERVER
|
||||
'Try Create Account': {}
|
||||
'Signin:PressedForgotPassword': {}
|
||||
'Signin:PressedSelectService': {}
|
||||
// COMPOSER / CREATE POST events
|
||||
'Create Post': {imageCount: string | number} // CAN BE SERVER
|
||||
'Composer:PastedPhotos': {}
|
||||
'Composer:CameraOpened': {}
|
||||
'Composer:GalleryOpened': {}
|
||||
'Composer:ThreadgateOpened': {}
|
||||
'HomeScreen:PressCompose': {}
|
||||
'ProfileScreen:PressCompose': {}
|
||||
// EDIT PROFILE events
|
||||
'EditHandle:ViewCustomForm': {}
|
||||
'EditHandle:ViewProvidedForm': {}
|
||||
'EditHandle:SetNewHandle': {}
|
||||
'EditProfile:AvatarSelected': {}
|
||||
'EditProfile:BannerSelected': {}
|
||||
'EditProfile:Save': {} // CAN BE SERVER
|
||||
// FEED events
|
||||
'Feed:onRefresh': {}
|
||||
'Feed:onEndReached': {}
|
||||
// POST events
|
||||
'Post:Like': {} // CAN BE SERVER
|
||||
'Post:Unlike': {} // CAN BE SERVER
|
||||
'Post:Repost': {} // CAN BE SERVER
|
||||
'Post:Unrepost': {} // CAN BE SERVER
|
||||
'Post:Delete': {} // CAN BE SERVER
|
||||
'Post:ThreadMute': {} // CAN BE SERVER
|
||||
'Post:ThreadUnmute': {} // CAN BE SERVER
|
||||
'Post:Reply': {} // CAN BE SERVER
|
||||
'Post:EditThreadgateOpened': {}
|
||||
'Post:ThreadgateEdited': {}
|
||||
// PROFILE events
|
||||
'Profile:Follow': {
|
||||
username: string
|
||||
}
|
||||
'Profile:Unfollow': {
|
||||
username: string
|
||||
}
|
||||
// PROFILE HEADER events
|
||||
'ProfileHeader:EditProfileButtonClicked': {}
|
||||
'ProfileHeader:FollowersButtonClicked': {
|
||||
handle: string
|
||||
}
|
||||
'ProfileHeader:FollowsButtonClicked': {
|
||||
handle: string
|
||||
}
|
||||
'ProfileHeader:ShareButtonClicked': {}
|
||||
'ProfileHeader:MuteAccountButtonClicked': {}
|
||||
'ProfileHeader:UnmuteAccountButtonClicked': {}
|
||||
'ProfileHeader:ReportAccountButtonClicked': {}
|
||||
'ProfileHeader:AddToListsButtonClicked': {}
|
||||
'ProfileHeader:BlockAccountButtonClicked': {}
|
||||
'ProfileHeader:UnblockAccountButtonClicked': {}
|
||||
'ProfileHeader:FollowButtonClicked': {}
|
||||
'ProfileHeader:UnfollowButtonClicked': {}
|
||||
'ProfileHeader:SuggestedFollowsOpened': {}
|
||||
'ProfileHeader:SuggestedFollowFollowed': {}
|
||||
'ViewHeader:MenuButtonClicked': {}
|
||||
// SETTINGS events
|
||||
'Settings:SwitchAccountButtonClicked': {}
|
||||
'Settings:AddAccountButtonClicked': {}
|
||||
'Settings:ChangeHandleButtonClicked': {}
|
||||
'Settings:InvitecodesButtonClicked': {}
|
||||
'Settings:SignOutButtonClicked': {}
|
||||
'Settings:ContentlanguagesButtonClicked': {}
|
||||
// MENU events
|
||||
'Menu:ItemClicked': {url: string}
|
||||
'Menu:FeedbackClicked': {}
|
||||
'Menu:HelpClicked': {}
|
||||
// MOBILE SHELL events
|
||||
'MobileShell:MyProfileButtonPressed': {}
|
||||
'MobileShell:HomeButtonPressed': {}
|
||||
'MobileShell:SearchButtonPressed': {}
|
||||
'MobileShell:NotificationsButtonPressed': {}
|
||||
'MobileShell:FeedsButtonPressed': {}
|
||||
'MobileShell:MessagesButtonPressed': {}
|
||||
// NOTIFICATIONS events
|
||||
'Notificatons:OpenApp': {}
|
||||
// LISTS events
|
||||
'Lists:onRefresh': {}
|
||||
'Lists:onEndReached': {}
|
||||
'CreateList:AvatarSelected': {}
|
||||
'CreateList:SaveCurateList': {} // CAN BE SERVER
|
||||
'CreateList:SaveModList': {} // CAN BE SERVER
|
||||
'Lists:Mute': {} // CAN BE SERVER
|
||||
'Lists:Unmute': {} // CAN BE SERVER
|
||||
'Lists:Block': {} // CAN BE SERVER
|
||||
'Lists:Unblock': {} // CAN BE SERVER
|
||||
'Lists:Delete': {} // CAN BE SERVER
|
||||
'Lists:Share': {} // CAN BE SERVER
|
||||
// CUSTOM FEED events
|
||||
'CustomFeed:Save': {}
|
||||
'CustomFeed:Unsave': {}
|
||||
'CustomFeed:Like': {}
|
||||
'CustomFeed:Unlike': {}
|
||||
'CustomFeed:Share': {}
|
||||
'CustomFeed:Pin': {
|
||||
uri: string
|
||||
name?: string
|
||||
}
|
||||
'CustomFeed:Unpin': {
|
||||
uri: string
|
||||
name?: string
|
||||
}
|
||||
'CustomFeed:Reorder': {
|
||||
uri: string
|
||||
name?: string
|
||||
index: number
|
||||
}
|
||||
'CustomFeed:LoadMore': {}
|
||||
'MultiFeed:onEndReached': {}
|
||||
'MultiFeed:onRefresh': {}
|
||||
// MODERATION events
|
||||
'Moderation:ContentfilteringButtonClicked': {}
|
||||
// ONBOARDING events
|
||||
'Onboarding:Begin': {}
|
||||
'Onboarding:Complete': {}
|
||||
'Onboarding:Skipped': {}
|
||||
'Onboarding:Reset': {}
|
||||
'Onboarding:SuggestedFollowFollowed': {}
|
||||
'Onboarding:CustomFeedAdded': {}
|
||||
// Onboarding v2
|
||||
'OnboardingV2:Begin': {}
|
||||
'OnboardingV2:StepInterests:Start': {}
|
||||
'OnboardingV2:StepInterests:End': {
|
||||
selectedInterests: string[]
|
||||
selectedInterestsLength: number
|
||||
}
|
||||
'OnboardingV2:StepInterests:Error': {}
|
||||
'OnboardingV2:StepSuggestedAccounts:Start': {}
|
||||
'OnboardingV2:StepSuggestedAccounts:End': {
|
||||
selectedAccountsLength: number
|
||||
}
|
||||
'OnboardingV2:StepFollowingFeed:Start': {}
|
||||
'OnboardingV2:StepFollowingFeed:End': {}
|
||||
'OnboardingV2:StepAlgoFeeds:Start': {}
|
||||
'OnboardingV2:StepAlgoFeeds:End': {
|
||||
selectedPrimaryFeeds: string[]
|
||||
selectedPrimaryFeedsLength: number
|
||||
selectedSecondaryFeeds: string[]
|
||||
selectedSecondaryFeedsLength: number
|
||||
}
|
||||
'OnboardingV2:StepTopicalFeeds:Start': {}
|
||||
'OnboardingV2:StepTopicalFeeds:End': {
|
||||
selectedFeeds: string[]
|
||||
selectedFeedsLength: number
|
||||
}
|
||||
'OnboardingV2:StepModeration:Start': {}
|
||||
'OnboardingV2:StepModeration:End': {}
|
||||
'OnboardingV2:StepProfile:Start': {}
|
||||
'OnboardingV2:StepProfile:End': {}
|
||||
'OnboardingV2:StepFinished:Start': {}
|
||||
'OnboardingV2:StepFinished:End': {}
|
||||
'OnboardingV2:Complete': {}
|
||||
'OnboardingV2:Skip': {}
|
||||
}
|
||||
|
||||
export type ScreenPropertiesMap = {
|
||||
Login: {}
|
||||
CreateAccount: {}
|
||||
'Choose Account': {}
|
||||
'Signin:ForgotPassword': {}
|
||||
'Signin:SetNewPasswordForm': {}
|
||||
'Signin:PasswordUpdatedForm': {}
|
||||
Feed: {}
|
||||
Notifications: {}
|
||||
Profile: {}
|
||||
'Profile:Preview': {}
|
||||
Settings: {}
|
||||
AppPasswords: {}
|
||||
Moderation: {}
|
||||
PreferencesExternalEmbeds: {}
|
||||
BlockedAccounts: {}
|
||||
MutedAccounts: {}
|
||||
SavedFeeds: {}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class AuthorFeedAPI implements FeedAPI {
|
||||
agent: BskyAgent
|
||||
params: GetAuthorFeed.QueryParams
|
||||
_params: GetAuthorFeed.QueryParams
|
||||
|
||||
constructor({
|
||||
agent,
|
||||
@@ -18,7 +18,13 @@ export class AuthorFeedAPI implements FeedAPI {
|
||||
feedParams: GetAuthorFeed.QueryParams
|
||||
}) {
|
||||
this.agent = agent
|
||||
this.params = feedParams
|
||||
this._params = feedParams
|
||||
}
|
||||
|
||||
get params() {
|
||||
const params = {...this._params}
|
||||
params.includePins = params.filter !== 'posts_with_media'
|
||||
return params
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
@@ -57,8 +63,9 @@ export class AuthorFeedAPI implements FeedAPI {
|
||||
return feed.filter(post => {
|
||||
const isReply = post.reply
|
||||
const isRepost = AppBskyFeedDefs.isReasonRepost(post.reason)
|
||||
const isPin = AppBskyFeedDefs.isReasonPin(post.reason)
|
||||
if (!isReply) return true
|
||||
if (isRepost) return true
|
||||
if (isRepost || isPin) return true
|
||||
return isReply && isAuthorReplyChain(this.params.actor, post, feed)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,17 +13,19 @@ import {isNative} from '#/platform/detection'
|
||||
|
||||
const DEFAULT_TARGET_SCALE = isNative || isTouchDevice ? 0.98 : 1
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export function PressableScale({
|
||||
targetScale = DEFAULT_TARGET_SCALE,
|
||||
children,
|
||||
contentContainerStyle,
|
||||
style,
|
||||
onPressIn,
|
||||
onPressOut,
|
||||
...rest
|
||||
}: {
|
||||
targetScale?: number
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
} & Exclude<PressableProps, 'onPressIn' | 'onPressOut'>) {
|
||||
style?: StyleProp<ViewStyle>
|
||||
} & Exclude<PressableProps, 'onPressIn' | 'onPressOut' | 'style'>) {
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
@@ -31,7 +33,7 @@ export function PressableScale({
|
||||
}))
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
<AnimatedPressable
|
||||
accessibilityRole="button"
|
||||
onPressIn={e => {
|
||||
'worklet'
|
||||
@@ -49,10 +51,9 @@ export function PressableScale({
|
||||
cancelAnimation(scale)
|
||||
scale.value = withTiming(1, {duration: 100})
|
||||
}}
|
||||
style={[animatedStyle, style]}
|
||||
{...rest}>
|
||||
<Animated.View style={[animatedStyle, contentContainerStyle]}>
|
||||
{children as React.ReactNode}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
{children}
|
||||
</AnimatedPressable>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {useCallback, useState} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {SessionAccount, useSessionApi} from '#/state/session'
|
||||
@@ -14,7 +13,6 @@ import {LogEvents} from '../statsig/statsig'
|
||||
export function useAccountSwitcher() {
|
||||
const [pendingDid, setPendingDid] = useState<string | null>(null)
|
||||
const {_} = useLingui()
|
||||
const {track} = useAnalytics()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
|
||||
@@ -23,7 +21,6 @@ export function useAccountSwitcher() {
|
||||
account: SessionAccount,
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => {
|
||||
track('Settings:SwitchAccountButtonClicked')
|
||||
if (pendingDid) {
|
||||
// The session API isn't resilient to race conditions so let's just ignore this.
|
||||
return
|
||||
@@ -62,7 +59,7 @@ export function useAccountSwitcher() {
|
||||
setPendingDid(null)
|
||||
}
|
||||
},
|
||||
[_, track, resumeSession, requestSwitchToAccount, pendingDid],
|
||||
[_, resumeSession, requestSwitchToAccount, pendingDid],
|
||||
)
|
||||
|
||||
return {onPressSwitchAccount, pendingDid}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react'
|
||||
import * as Linking from 'expo-linking'
|
||||
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useSession} from 'state/session'
|
||||
import {useComposerControls} from 'state/shell'
|
||||
import {useCloseAllActiveElements} from 'state/util'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
@@ -52,6 +52,7 @@ export function useIntentHandler() {
|
||||
composeIntent({
|
||||
text: params.get('text'),
|
||||
imageUrisStr: params.get('imageUris'),
|
||||
videoUri: params.get('videoUri'),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -80,14 +81,25 @@ export function useComposeIntent() {
|
||||
({
|
||||
text,
|
||||
imageUrisStr,
|
||||
videoUri,
|
||||
}: {
|
||||
text: string | null
|
||||
imageUrisStr: string | null // unused for right now, will be used later with intents
|
||||
imageUrisStr: string | null
|
||||
videoUri: string | null
|
||||
}) => {
|
||||
if (!hasSession) return
|
||||
|
||||
closeAllActiveElements()
|
||||
|
||||
// Whenever a video URI is present, we don't support adding images right now.
|
||||
if (videoUri) {
|
||||
openComposer({
|
||||
text: text ?? undefined,
|
||||
videoUri,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const imageUris = imageUrisStr
|
||||
?.split(',')
|
||||
.filter(part => {
|
||||
|
||||
@@ -3,19 +3,18 @@ import * as Notifications from 'expo-notifications'
|
||||
import {CommonActions, useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
import {useCurrentConvoId} from 'state/messages/current-convo-id'
|
||||
import {RQKEY as RQKEY_NOTIFS} from 'state/queries/notifications/feed'
|
||||
import {invalidateCachedUnreadPage} from 'state/queries/notifications/unread'
|
||||
import {truncateAndInvalidate} from 'state/queries/util'
|
||||
import {useSession} from 'state/session'
|
||||
import {useLoggedOutViewControls} from 'state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from 'state/util'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
|
||||
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
|
||||
import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {resetToTab} from '#/Navigation'
|
||||
|
||||
type NotificationReason =
|
||||
@@ -228,7 +227,6 @@ export function useNotificationsHandler() {
|
||||
{},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
track('Notificatons:OpenApp')
|
||||
logEvent('notifications:openApp', {})
|
||||
invalidateCachedUnreadPage()
|
||||
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Dimensions} from 'react-native'
|
||||
|
||||
import {isSafari} from 'lib/browser'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {isSafari} from '#/lib/browser'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
|
||||
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
|
||||
|
||||
@@ -185,6 +185,20 @@ export function parseEmbedPlayerFromUrl(
|
||||
playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`,
|
||||
}
|
||||
}
|
||||
if (typeOrLocale === 'episode' || idOrType === 'episode') {
|
||||
return {
|
||||
type: 'spotify_song',
|
||||
source: 'spotify',
|
||||
playerUri: `https://open.spotify.com/embed/episode/${id ?? idOrType}`,
|
||||
}
|
||||
}
|
||||
if (typeOrLocale === 'show' || idOrType === 'show') {
|
||||
return {
|
||||
type: 'spotify_song',
|
||||
source: 'spotify',
|
||||
playerUri: `https://open.spotify.com/embed/show/${id ?? idOrType}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,19 @@ export function cleanError(str: any): string {
|
||||
return str
|
||||
}
|
||||
|
||||
const NETWORK_ERRORS = [
|
||||
'Abort',
|
||||
'Network request failed',
|
||||
'Failed to fetch',
|
||||
'Load failed',
|
||||
]
|
||||
|
||||
export function isNetworkError(e: unknown) {
|
||||
const str = String(e)
|
||||
return (
|
||||
str.includes('Abort') ||
|
||||
str.includes('Network request failed') ||
|
||||
str.includes('Failed to fetch')
|
||||
)
|
||||
for (const err of NETWORK_ERRORS) {
|
||||
if (str.includes(err)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+8
-2
@@ -1,10 +1,11 @@
|
||||
import format from 'date-fns/format'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {Sentry} from '#/logger/sentry'
|
||||
import * as env from '#/env'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {DebugContext} from '#/logger/debugContext'
|
||||
import {add} from '#/logger/logDump'
|
||||
import {Sentry} from '#/logger/sentry'
|
||||
import * as env from '#/env'
|
||||
|
||||
export enum LogLevel {
|
||||
Debug = 'debug',
|
||||
@@ -160,6 +161,11 @@ export const sentryTransport: Transport = (
|
||||
timestamp: timestamp / 1000, // Sentry expects seconds
|
||||
})
|
||||
|
||||
// We don't want to send any network errors to sentry
|
||||
if (isNetworkError(message)) {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Send all higher levels with `captureMessage`, with appropriate severity
|
||||
* level
|
||||
|
||||
@@ -3,7 +3,6 @@ import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
|
||||
@@ -23,16 +22,11 @@ export const ChooseAccountForm = ({
|
||||
onPressBack: () => void
|
||||
}) => {
|
||||
const [pendingDid, setPendingDid] = React.useState<string | null>(null)
|
||||
const {track, screen} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
|
||||
React.useEffect(() => {
|
||||
screen('Choose Account')
|
||||
}, [screen])
|
||||
|
||||
const onSelect = React.useCallback(
|
||||
async (account: SessionAccount) => {
|
||||
if (pendingDid) {
|
||||
@@ -56,7 +50,6 @@ export const ChooseAccountForm = ({
|
||||
logContext: 'ChooseAccountForm',
|
||||
withPassword: false,
|
||||
})
|
||||
track('Sign In', {resumedSession: true})
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
} catch (e: any) {
|
||||
logger.error('choose account: initSession failed', {
|
||||
@@ -70,7 +63,6 @@ export const ChooseAccountForm = ({
|
||||
},
|
||||
[
|
||||
currentAccount,
|
||||
track,
|
||||
resumeSession,
|
||||
pendingDid,
|
||||
onSelectAccount,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import React, {useState} from 'react'
|
||||
import {ActivityIndicator, Keyboard, View} from 'react-native'
|
||||
import {ComAtprotoServerDescribeServer} from '@atproto/api'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
@@ -6,7 +6,6 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as EmailValidator from 'email-validator'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
@@ -41,13 +40,8 @@ export const ForgotPasswordForm = ({
|
||||
const t = useTheme()
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const {screen} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
|
||||
useEffect(() => {
|
||||
screen('Signin:ForgotPassword')
|
||||
}, [screen])
|
||||
|
||||
const onPressSelectService = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
}, [])
|
||||
|
||||
@@ -13,15 +13,14 @@ import {
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {createFullHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
||||
import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {FormError} from '#/components/forms/FormError'
|
||||
@@ -57,7 +56,6 @@ export const LoginForm = ({
|
||||
onPressBack: () => void
|
||||
onPressForgotPassword: () => void
|
||||
}) => {
|
||||
const {track} = useAnalytics()
|
||||
const t = useTheme()
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
|
||||
@@ -74,8 +72,7 @@ export const LoginForm = ({
|
||||
|
||||
const onPressSelectService = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
track('Signin:PressedSelectService')
|
||||
}, [track])
|
||||
}, [])
|
||||
|
||||
const onPressNext = async () => {
|
||||
if (isProcessing) return
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, {useEffect} from 'react'
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -14,14 +13,9 @@ export const PasswordUpdatedForm = ({
|
||||
}: {
|
||||
onPressNext: () => void
|
||||
}) => {
|
||||
const {screen} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
useEffect(() => {
|
||||
screen('Signin:PasswordUpdatedForm')
|
||||
}, [screen])
|
||||
|
||||
return (
|
||||
<FormContainer
|
||||
testID="passwordUpdatedForm"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import React, {useState} from 'react'
|
||||
import {ActivityIndicator, View} from 'react-native'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {checkAndFormatResetCode} from '#/lib/strings/password'
|
||||
@@ -31,14 +30,9 @@ export const SetNewPasswordForm = ({
|
||||
onPressBack: () => void
|
||||
onPasswordSet: () => void
|
||||
}) => {
|
||||
const {screen} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
screen('Signin:SetNewPasswordForm')
|
||||
}, [screen])
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [resetCode, setResetCode] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
|
||||
@@ -4,7 +4,6 @@ import {LayoutAnimationConfig} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {DEFAULT_SERVICE} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {useServiceQuery} from '#/state/queries/service'
|
||||
@@ -31,7 +30,6 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
|
||||
const {_} = useLingui()
|
||||
|
||||
const {accounts} = useSession()
|
||||
const {track} = useAnalytics()
|
||||
const {requestedAccountSwitchTo} = useLoggedOutView()
|
||||
const requestedAccount = accounts.find(
|
||||
acc => acc.did === requestedAccountSwitchTo,
|
||||
@@ -87,7 +85,6 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
|
||||
}, [serviceError, serviceUrl, _])
|
||||
|
||||
const onPressForgotPassword = () => {
|
||||
track('Signin:PressedForgotPassword')
|
||||
setCurrentForm(Forms.ForgotPassword)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,9 @@ import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
||||
import {Link} from '#/components/Link'
|
||||
@@ -203,6 +204,19 @@ function ChatListItemReady({
|
||||
onFocus={onFocus}
|
||||
onBlur={onMouseLeave}
|
||||
style={[a.relative]}>
|
||||
<View
|
||||
style={[
|
||||
a.z_10,
|
||||
a.absolute,
|
||||
{top: tokens.space.md, left: tokens.space.lg},
|
||||
]}>
|
||||
<PreviewableUserAvatar
|
||||
profile={profile}
|
||||
size={52}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Link
|
||||
to={`/messages/${convo.id}`}
|
||||
label={displayName}
|
||||
@@ -236,11 +250,8 @@ function ChatListItemReady({
|
||||
(hovered || pressed || focused) && t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={52}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
{/* Avatar goes here */}
|
||||
<View style={{width: 52, height: 52}} />
|
||||
|
||||
<View style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}>
|
||||
<View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}>
|
||||
@@ -357,7 +368,7 @@ function ChatListItemReady({
|
||||
a.self_end,
|
||||
a.justify_center,
|
||||
{
|
||||
right: a.px_lg.paddingRight,
|
||||
right: tokens.space.lg,
|
||||
opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {logger} from '#/logger'
|
||||
@@ -163,7 +162,6 @@ export function ModerationScreenInner({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {screen} = useAnalytics()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
const birthdateDialogControl = Dialog.useDialogControl()
|
||||
@@ -175,9 +173,8 @@ export function ModerationScreenInner({
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
screen('Moderation')
|
||||
setMinimalShellMode(false)
|
||||
}, [screen, setMinimalShellMode]),
|
||||
}, [setMinimalShellMode]),
|
||||
)
|
||||
|
||||
const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
|
||||
|
||||
@@ -7,27 +7,26 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {uploadBlob} from '#/lib/api'
|
||||
import {
|
||||
BSKY_APP_ACCOUNT_DID,
|
||||
DISCOVER_SAVED_FEED,
|
||||
TIMELINE_SAVED_FEED,
|
||||
} from '#/lib/constants'
|
||||
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
||||
import {getAllListMembers} from '#/state/queries/list-members'
|
||||
import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||
import {RQKEY as profileRQKey} from '#/state/queries/profile'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {useProgressGuideControls} from '#/state/shell/progress-guide'
|
||||
import {uploadBlob} from 'lib/api'
|
||||
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
||||
import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
|
||||
import {getAllListMembers} from 'state/queries/list-members'
|
||||
import {
|
||||
useActiveStarterPack,
|
||||
useSetActiveStarterPack,
|
||||
} from 'state/shell/starter-pack'
|
||||
} from '#/state/shell/starter-pack'
|
||||
import {
|
||||
DescriptionText,
|
||||
OnboardingControls,
|
||||
@@ -48,7 +47,6 @@ import {Text} from '#/components/Typography'
|
||||
export function StepFinished() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {track} = useAnalytics()
|
||||
const {state, dispatch} = React.useContext(Context)
|
||||
const onboardDispatch = useOnboardingDispatch()
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
@@ -190,8 +188,6 @@ export function StepFinished() {
|
||||
startProgressGuide('like-10-and-follow-7')
|
||||
dispatch({type: 'finish'})
|
||||
onboardDispatch({type: 'finish'})
|
||||
track('OnboardingV2:StepFinished:End')
|
||||
track('OnboardingV2:Complete')
|
||||
logEvent('onboarding:finished:nextPressed', {
|
||||
usedStarterPack: Boolean(starterPack),
|
||||
starterPackName: AppBskyGraphStarterpack.isRecord(starterPack?.record)
|
||||
@@ -214,7 +210,6 @@ export function StepFinished() {
|
||||
agent,
|
||||
dispatch,
|
||||
onboardDispatch,
|
||||
track,
|
||||
activeStarterPack,
|
||||
state,
|
||||
requestNotificationsPermission,
|
||||
@@ -223,10 +218,6 @@ export function StepFinished() {
|
||||
startProgressGuide,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
track('OnboardingV2:StepFinished:Start')
|
||||
}, [track])
|
||||
|
||||
return (
|
||||
<View style={[a.align_start]}>
|
||||
<IconCircle icon={Check} style={[a.mb_2xl]} />
|
||||
|
||||
@@ -4,7 +4,6 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {capitalize} from '#/lib/strings/capitalize'
|
||||
import {logger} from '#/logger'
|
||||
@@ -36,7 +35,6 @@ export function StepInterests() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {track} = useAnalytics()
|
||||
const interestsDisplayNames = useInterestsDisplayNames()
|
||||
|
||||
const {state, dispatch} = React.useContext(Context)
|
||||
@@ -90,7 +88,6 @@ export function StepInterests() {
|
||||
`onboarding: getTaggedSuggestions fetch or processing failed`,
|
||||
)
|
||||
logger.error(e)
|
||||
track('OnboardingV2:StepInterests:Error')
|
||||
|
||||
throw new Error(`a network error occurred`)
|
||||
}
|
||||
@@ -108,11 +105,6 @@ export function StepInterests() {
|
||||
selectedInterests: interests,
|
||||
})
|
||||
dispatch({type: 'next'})
|
||||
|
||||
track('OnboardingV2:StepInterests:End', {
|
||||
selectedInterests: interests,
|
||||
selectedInterestsLength: interests.length,
|
||||
})
|
||||
logEvent('onboarding:interests:nextPressed', {
|
||||
selectedInterests: interests,
|
||||
selectedInterestsLength: interests.length,
|
||||
@@ -121,18 +113,12 @@ export function StepInterests() {
|
||||
logger.info(`onboading: error saving interests`)
|
||||
logger.error(e)
|
||||
}
|
||||
}, [interests, data, setSaving, dispatch, track])
|
||||
}, [interests, data, setSaving, dispatch])
|
||||
|
||||
const skipOnboarding = React.useCallback(() => {
|
||||
onboardDispatch({type: 'finish'})
|
||||
dispatch({type: 'finish'})
|
||||
track('OnboardingV2:Skip')
|
||||
}, [onboardDispatch, dispatch, track])
|
||||
|
||||
React.useEffect(() => {
|
||||
track('OnboardingV2:Begin')
|
||||
track('OnboardingV2:StepInterests:Start')
|
||||
}, [track])
|
||||
}, [onboardDispatch, dispatch])
|
||||
|
||||
const title = isError ? (
|
||||
<Trans>Oh no! Something went wrong.</Trans>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import ViewShot from 'react-native-view-shot'
|
||||
import type ViewShot from 'react-native-view-shot'
|
||||
|
||||
import {useAvatar} from '#/screens/Onboarding/StepProfile/index'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
const LazyViewShot = React.lazy(
|
||||
// @ts-expect-error dynamic import
|
||||
() => import('react-native-view-shot/src/index'),
|
||||
)
|
||||
|
||||
const SIZE_MULTIPLIER = 5
|
||||
|
||||
export interface PlaceholderCanvasRef {
|
||||
capture: () => Promise<string>
|
||||
capture: () => Promise<string | undefined>
|
||||
}
|
||||
|
||||
// This component is supposed to be invisible to the user. We only need this for ViewShot to have something to
|
||||
@@ -16,7 +21,7 @@ export interface PlaceholderCanvasRef {
|
||||
export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
|
||||
function PlaceholderCanvas({}, ref) {
|
||||
const {avatar} = useAvatar()
|
||||
const viewshotRef = React.useRef()
|
||||
const viewshotRef = React.useRef<ViewShot>(null)
|
||||
const Icon = avatar.placeholder.component
|
||||
|
||||
const styles = React.useMemo(
|
||||
@@ -32,13 +37,16 @@ export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
|
||||
)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
// @ts-ignore this library doesn't have types
|
||||
capture: viewshotRef.current.capture,
|
||||
capture: async () => {
|
||||
if (viewshotRef.current?.capture) {
|
||||
return await viewshotRef.current.capture()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ViewShot
|
||||
<LazyViewShot
|
||||
// @ts-ignore this library doesn't have types
|
||||
ref={viewshotRef}
|
||||
options={{
|
||||
@@ -60,7 +68,7 @@ export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
</View>
|
||||
</ViewShot>
|
||||
</LazyViewShot>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -9,14 +9,13 @@ import {
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||
import {compressIfNeeded} from '#/lib/media/manip'
|
||||
import {openCropper} from '#/lib/media/picker'
|
||||
import {getDataUriSize} from '#/lib/media/util'
|
||||
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
|
||||
import {compressIfNeeded} from 'lib/media/manip'
|
||||
import {openCropper} from 'lib/media/picker'
|
||||
import {getDataUriSize} from 'lib/media/util'
|
||||
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
DescriptionText,
|
||||
OnboardingControls,
|
||||
@@ -68,7 +67,6 @@ export function StepProfile() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {track} = useAnalytics()
|
||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||
const gate = useGate()
|
||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||
@@ -87,10 +85,6 @@ export function StepProfile() {
|
||||
|
||||
const canvasRef = React.useRef<PlaceholderCanvasRef>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
track('OnboardingV2:StepProfile:Start')
|
||||
}, [track])
|
||||
|
||||
React.useEffect(() => {
|
||||
requestNotificationsPermission('StartOnboarding')
|
||||
}, [gate, requestNotificationsPermission])
|
||||
@@ -132,6 +126,10 @@ export function StepProfile() {
|
||||
|
||||
const onContinue = React.useCallback(async () => {
|
||||
let imageUri = avatar?.image?.path
|
||||
|
||||
// In the event that view-shot didn't load in time and the user pressed continue, this will just be undefined
|
||||
// and the default avatar will be used. We don't want to block getting through create if this fails for some
|
||||
// reason
|
||||
if (!imageUri || avatar.useCreatedAvatar) {
|
||||
imageUri = await canvasRef.current?.capture()
|
||||
}
|
||||
@@ -151,9 +149,8 @@ export function StepProfile() {
|
||||
}
|
||||
|
||||
dispatch({type: 'next'})
|
||||
track('OnboardingV2:StepProfile:End')
|
||||
logEvent('onboarding:profile:nextPressed', {})
|
||||
}, [avatar, dispatch, track])
|
||||
}, [avatar, dispatch])
|
||||
|
||||
const onDoneCreating = React.useCallback(() => {
|
||||
setAvatar(prev => ({
|
||||
|
||||
@@ -51,13 +51,15 @@ export type OnboardingAction =
|
||||
| {
|
||||
type: 'setProfileStepResults'
|
||||
isCreatedAvatar: boolean
|
||||
image?: OnboardingState['profileStepResults']['image']
|
||||
imageUri: string
|
||||
image: OnboardingState['profileStepResults']['image'] | undefined
|
||||
imageUri: string | undefined
|
||||
imageMime: string
|
||||
creatorState?: {
|
||||
emoji: Emoji
|
||||
backgroundColor: AvatarColor
|
||||
}
|
||||
creatorState:
|
||||
| {
|
||||
emoji: Emoji
|
||||
backgroundColor: AvatarColor
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
export type ApiResponseMap = {
|
||||
|
||||
@@ -12,18 +12,17 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import {MAX_LABELERS} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {isAppLabeler} from '#/lib/moderation'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
|
||||
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useRequireAuth, useSession} from '#/state/session'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {isIOS} from 'platform/detection'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
|
||||
@@ -66,7 +65,6 @@ let ProfileHeaderLabeler = ({
|
||||
const {_} = useLingui()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const {openModal} = useModalControls()
|
||||
const {track} = useAnalytics()
|
||||
const requireAuth = useRequireAuth()
|
||||
const playHaptic = useHaptics()
|
||||
const cantSubscribePrompt = Prompt.usePromptControl()
|
||||
@@ -102,12 +100,10 @@ let ProfileHeaderLabeler = ({
|
||||
|
||||
if (likeUri) {
|
||||
await unlikeMod({uri: likeUri})
|
||||
track('CustomFeed:Unlike')
|
||||
setLikeCount(c => c - 1)
|
||||
setLikeUri('')
|
||||
} else {
|
||||
const res = await likeMod({uri: labeler.uri, cid: labeler.cid})
|
||||
track('CustomFeed:Like')
|
||||
setLikeCount(c => c + 1)
|
||||
setLikeUri(res.uri)
|
||||
}
|
||||
@@ -120,15 +116,14 @@ let ProfileHeaderLabeler = ({
|
||||
)
|
||||
logger.error(`Failed to toggle labeler like`, {message: e.message})
|
||||
}
|
||||
}, [labeler, playHaptic, likeUri, unlikeMod, track, likeMod, _])
|
||||
}, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
|
||||
|
||||
const onPressEditProfile = React.useCallback(() => {
|
||||
track('ProfileHeader:EditProfileButtonClicked')
|
||||
openModal({
|
||||
name: 'edit-profile',
|
||||
profile,
|
||||
})
|
||||
}, [track, openModal, profile])
|
||||
}, [openModal, profile])
|
||||
|
||||
const onPressSubscribe = React.useCallback(
|
||||
() =>
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {
|
||||
@@ -18,9 +20,6 @@ import {
|
||||
useProfileFollowMutationQueue,
|
||||
} from '#/state/queries/profile'
|
||||
import {useRequireAuth, useSession} from '#/state/session'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a} from '#/alf'
|
||||
@@ -59,7 +58,6 @@ let ProfileHeaderStandard = ({
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
const {track} = useAnalytics()
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
@@ -77,17 +75,15 @@ let ProfileHeaderStandard = ({
|
||||
profile.viewer?.blockingByList
|
||||
|
||||
const onPressEditProfile = React.useCallback(() => {
|
||||
track('ProfileHeader:EditProfileButtonClicked')
|
||||
openModal({
|
||||
name: 'edit-profile',
|
||||
profile,
|
||||
})
|
||||
}, [track, openModal, profile])
|
||||
}, [openModal, profile])
|
||||
|
||||
const onPressFollow = () => {
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
track('ProfileHeader:FollowButtonClicked')
|
||||
await queueFollow()
|
||||
Toast.show(
|
||||
_(
|
||||
@@ -109,7 +105,6 @@ let ProfileHeaderStandard = ({
|
||||
const onPressUnfollow = () => {
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
track('ProfileHeader:UnfollowButtonClicked')
|
||||
await queueUnfollow()
|
||||
Toast.show(
|
||||
_(
|
||||
@@ -129,7 +124,6 @@ let ProfileHeaderStandard = ({
|
||||
}
|
||||
|
||||
const unblockAccount = React.useCallback(async () => {
|
||||
track('ProfileHeader:UnblockAccountButtonClicked')
|
||||
try {
|
||||
await queueUnblock()
|
||||
Toast.show(_(msg`Account unblocked`))
|
||||
@@ -139,7 +133,7 @@ let ProfileHeaderStandard = ({
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
|
||||
}
|
||||
}
|
||||
}, [_, queueUnblock, track])
|
||||
}, [_, queueUnblock])
|
||||
|
||||
const isMe = React.useMemo(
|
||||
() => currentAccount?.did === profile.did,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
|
||||
import {parseSearchQuery} from '#/screens/Search/utils'
|
||||
|
||||
describe(`parseSearchQuery`, () => {
|
||||
const tests = [
|
||||
{
|
||||
input: `bluesky`,
|
||||
output: {query: `bluesky`, params: {}},
|
||||
},
|
||||
{
|
||||
input: `bluesky from:esb.lol`,
|
||||
output: {query: `bluesky`, params: {from: `esb.lol`}},
|
||||
},
|
||||
{
|
||||
input: `bluesky "from:esb.lol"`,
|
||||
output: {query: `bluesky "from:esb.lol"`, params: {}},
|
||||
},
|
||||
{
|
||||
input: `bluesky mentions:@esb.lol`,
|
||||
output: {query: `bluesky`, params: {mentions: `@esb.lol`}},
|
||||
},
|
||||
{
|
||||
input: `bluesky since:2021-01-01:00:00:00`,
|
||||
output: {query: `bluesky`, params: {since: `2021-01-01:00:00:00`}},
|
||||
},
|
||||
{
|
||||
input: `bluesky lang:"en"`,
|
||||
output: {query: `bluesky`, params: {lang: `en`}},
|
||||
},
|
||||
{
|
||||
input: `bluesky "literal" lang:en "from:invalid"`,
|
||||
output: {query: `bluesky "literal" "from:invalid"`, params: {lang: `en`}},
|
||||
},
|
||||
]
|
||||
|
||||
it.each(tests)(
|
||||
`$input -> $output.query $output.params`,
|
||||
({input, output}) => {
|
||||
expect(parseSearchQuery(input)).toEqual(output)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
export type Params = Record<string, string>
|
||||
|
||||
export function parseSearchQuery(rawQuery: string) {
|
||||
let base = rawQuery
|
||||
const rawLiterals = rawQuery.match(/[^:\w\d]".+?"/gi) || []
|
||||
|
||||
// remove literals from base
|
||||
for (const literal of rawLiterals) {
|
||||
base = base.replace(literal.trim(), '')
|
||||
}
|
||||
|
||||
// find remaining params in base
|
||||
const rawParams = base.match(/[a-z]+:[a-z-\.@\d:"]+/gi) || []
|
||||
|
||||
for (const param of rawParams) {
|
||||
base = base.replace(param, '')
|
||||
}
|
||||
|
||||
base = base.trim()
|
||||
|
||||
const params = rawParams.reduce((params, param) => {
|
||||
const [name, ...value] = param.split(/:/)
|
||||
params[name] = value.join(':').replace(/"/g, '') // dates can contain additional colons
|
||||
return params
|
||||
}, {} as Params)
|
||||
const literals = rawLiterals.map(l => String(l).trim())
|
||||
|
||||
return {
|
||||
query: [base, literals.join(' ')].filter(Boolean).join(' '),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
export function makeSearchQuery(query: string, params: Params) {
|
||||
return [
|
||||
query,
|
||||
Object.entries(params)
|
||||
.map(([name, value]) => `${name}:${value}`)
|
||||
.join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import * as EmailValidator from 'email-validator'
|
||||
import type tldts from 'tldts'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {isEmailMaybeInvalid} from '#/lib/strings/email'
|
||||
import {logger} from '#/logger'
|
||||
import {isEmailMaybeInvalid} from 'lib/strings/email'
|
||||
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
|
||||
import {is13, is18, useSignupContext} from '#/screens/Signup/state'
|
||||
import {Policies} from '#/screens/Signup/StepInfo/Policies'
|
||||
@@ -59,6 +59,9 @@ export function StepInfo({
|
||||
import('tldts/dist/index.cjs.min.js').then(tldts => {
|
||||
tldtsRef.current = tldts
|
||||
})
|
||||
// This will get used in the avatar creator a few steps later, so lets preload it now
|
||||
// @ts-expect-error - valid path
|
||||
import('react-native-view-shot/src/index')
|
||||
}, [])
|
||||
|
||||
const onNextPress = () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {AppBskyGraphStarterpack} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {FEEDBACK_FORM_URL} from '#/lib/constants'
|
||||
import {useServiceQuery} from '#/state/queries/service'
|
||||
import {useStarterPackQuery} from '#/state/queries/starter-packs'
|
||||
@@ -31,7 +30,6 @@ import {Text} from '#/components/Typography'
|
||||
export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {screen} = useAnalytics()
|
||||
const [state, dispatch] = React.useReducer(reducer, initialState)
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const submit = useSubmitSignup()
|
||||
@@ -56,10 +54,6 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||
refetch,
|
||||
} = useServiceQuery(state.serviceUrl)
|
||||
|
||||
React.useEffect(() => {
|
||||
screen('CreateAccount')
|
||||
}, [screen])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isFetching) {
|
||||
dispatch({type: 'setIsLoading', value: true})
|
||||
|
||||
Vendored
+2
@@ -21,6 +21,7 @@ export interface PostShadow {
|
||||
repostUri: string | undefined
|
||||
isDeleted: boolean
|
||||
embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
|
||||
pinned: boolean
|
||||
}
|
||||
|
||||
export const POST_TOMBSTONE = Symbol('PostTombstone')
|
||||
@@ -113,6 +114,7 @@ function mergeShadow(
|
||||
...(post.viewer || {}),
|
||||
like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
|
||||
repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost,
|
||||
pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,23 +3,16 @@ import zod from 'zod'
|
||||
import {BaseNux} from '#/state/queries/nuxs/types'
|
||||
|
||||
export enum Nux {
|
||||
TenMillionDialog = 'TenMillionDialog',
|
||||
NeueTypography = 'NeueTypography',
|
||||
}
|
||||
|
||||
export const nuxNames = new Set(Object.values(Nux))
|
||||
|
||||
export type AppNux =
|
||||
| BaseNux<{
|
||||
id: Nux.TenMillionDialog
|
||||
data: undefined
|
||||
}>
|
||||
| BaseNux<{
|
||||
id: Nux.NeueTypography
|
||||
data: undefined
|
||||
}>
|
||||
export type AppNux = BaseNux<{
|
||||
id: Nux.NeueTypography
|
||||
data: undefined
|
||||
}>
|
||||
|
||||
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
||||
[Nux.TenMillionDialog]: undefined,
|
||||
[Nux.NeueTypography]: undefined,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {updatePostShadow} from '../cache/post-shadow'
|
||||
import {useAgent, useSession} from '../session'
|
||||
import {useProfileUpdateMutation} from './profile'
|
||||
|
||||
export function usePinnedPostMutation() {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const {mutateAsync: profileUpdateMutate} = useProfileUpdateMutation()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
postUri,
|
||||
postCid,
|
||||
action,
|
||||
}: {
|
||||
postUri: string
|
||||
postCid: string
|
||||
action: 'pin' | 'unpin'
|
||||
}) => {
|
||||
const pinCurrentPost = action === 'pin'
|
||||
let prevPinnedPost: string | undefined
|
||||
try {
|
||||
updatePostShadow(queryClient, postUri, {pinned: pinCurrentPost})
|
||||
|
||||
// get the currently pinned post so we can optimistically remove the pin from it
|
||||
if (!currentAccount) throw new Error('Not logged in')
|
||||
const {data: profile} = await agent.getProfile({
|
||||
actor: currentAccount.did,
|
||||
})
|
||||
prevPinnedPost = profile.pinnedPost?.uri
|
||||
if (prevPinnedPost && prevPinnedPost !== postUri) {
|
||||
updatePostShadow(queryClient, prevPinnedPost, {pinned: false})
|
||||
}
|
||||
|
||||
await profileUpdateMutate({
|
||||
profile,
|
||||
updates: existing => {
|
||||
existing.pinnedPost = pinCurrentPost
|
||||
? {uri: postUri, cid: postCid}
|
||||
: undefined
|
||||
return existing
|
||||
},
|
||||
checkCommitted: res =>
|
||||
pinCurrentPost
|
||||
? res.data.pinnedPost?.uri === postUri
|
||||
: !res.data.pinnedPost,
|
||||
})
|
||||
|
||||
if (pinCurrentPost) {
|
||||
Toast.show(_(msg`Post pinned`))
|
||||
} else {
|
||||
Toast.show(_(msg`Post unpinned`))
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: FEED_RQKEY(
|
||||
`author|${currentAccount.did}|posts_and_author_threads`,
|
||||
),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: FEED_RQKEY(
|
||||
`author|${currentAccount.did}|posts_with_replies`,
|
||||
),
|
||||
})
|
||||
} catch (e: any) {
|
||||
Toast.show(_(msg`Failed to pin post`))
|
||||
logger.error('Failed to pin post', {message: String(e)})
|
||||
// revert optimistic update
|
||||
updatePostShadow(queryClient, postUri, {
|
||||
pinned: !pinCurrentPost,
|
||||
})
|
||||
if (prevPinnedPost && prevPinnedPost !== postUri) {
|
||||
updatePostShadow(queryClient, prevPinnedPost, {pinned: true})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export interface FeedPostSlice {
|
||||
feedContext: string | undefined
|
||||
reason?:
|
||||
| AppBskyFeedDefs.ReasonRepost
|
||||
| AppBskyFeedDefs.ReasonPin
|
||||
| ReasonFeedSource
|
||||
| {[k: string]: unknown; $type: string}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {useCallback} from 'react'
|
||||
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
|
||||
import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
|
||||
import {updatePostShadow} from '#/state/cache/post-shadow'
|
||||
@@ -193,9 +192,6 @@ function usePostLikeMutation(
|
||||
})
|
||||
return agent.like(uri, cid)
|
||||
},
|
||||
onSuccess() {
|
||||
track('Post:Like')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -208,9 +204,6 @@ function usePostUnlikeMutation(
|
||||
logEvent('post:unlike:sampled', {logContext})
|
||||
return agent.deleteLike(likeUri)
|
||||
},
|
||||
onSuccess() {
|
||||
track('Post:Unlike')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -285,9 +278,6 @@ function usePostRepostMutation(
|
||||
logEvent('post:repost:sampled', {logContext})
|
||||
return agent.repost(post.uri, post.cid)
|
||||
},
|
||||
onSuccess() {
|
||||
track('Post:Repost')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -300,9 +290,6 @@ function usePostUnrepostMutation(
|
||||
logEvent('post:unrepost:sampled', {logContext})
|
||||
return agent.deleteRepost(repostUri)
|
||||
},
|
||||
onSuccess() {
|
||||
track('Post:Unrepost')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -313,9 +300,8 @@ export function usePostDeleteMutation() {
|
||||
mutationFn: async ({uri}) => {
|
||||
await agent.deletePost(uri)
|
||||
},
|
||||
onSuccess(data, variables) {
|
||||
onSuccess(_, variables) {
|
||||
updatePostShadow(queryClient, variables.uri, {isDeleted: true})
|
||||
track('Post:Delete')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||
import {replaceEqualDeep} from '#/lib/functions'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
@@ -218,7 +217,6 @@ export function useAddSavedFeedsMutation() {
|
||||
>({
|
||||
mutationFn: async savedFeeds => {
|
||||
await agent.addSavedFeeds(savedFeeds)
|
||||
track('CustomFeed:Save')
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
@@ -234,7 +232,6 @@ export function useRemoveFeedMutation() {
|
||||
return useMutation<void, unknown, Pick<AppBskyActorDefs.SavedFeed, 'id'>>({
|
||||
mutationFn: async savedFeed => {
|
||||
await agent.removeSavedFeeds([savedFeed.id])
|
||||
track('CustomFeed:Unsave')
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {uploadBlob} from '#/lib/api'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
|
||||
@@ -160,6 +159,9 @@ export function useProfileUpdateMutation() {
|
||||
} else {
|
||||
existing.displayName = updates.displayName
|
||||
existing.description = updates.description
|
||||
if ('pinnedPost' in updates) {
|
||||
existing.pinnedPost = updates.pinnedPost
|
||||
}
|
||||
}
|
||||
if (newUserAvatarPromise) {
|
||||
const res = await newUserAvatarPromise
|
||||
@@ -316,9 +318,6 @@ function useProfileFollowMutation(
|
||||
})
|
||||
return await agent.follow(did)
|
||||
},
|
||||
onSuccess(data, variables) {
|
||||
track('Profile:Follow', {username: variables.did})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -329,7 +328,6 @@ function useProfileUnfollowMutation(
|
||||
return useMutation<void, Error, {did: string; followUri: string}>({
|
||||
mutationFn: async ({followUri}) => {
|
||||
logEvent('profile:unfollow:sampled', {logContext})
|
||||
track('Profile:Unfollow', {username: followUri})
|
||||
return await agent.deleteFollow(followUri)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -105,17 +105,16 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
||||
|
||||
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
|
||||
return useQuery({
|
||||
queryKey: suggestedFollowsByActorQueryKey(did),
|
||||
queryFn: async () => {
|
||||
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
|
||||
actor: did,
|
||||
})
|
||||
const data = res.data.isFallback ? {suggestions: []} : res.data
|
||||
data.suggestions = data.suggestions.filter(profile => {
|
||||
return !profile.viewer?.following
|
||||
})
|
||||
return data
|
||||
const suggestions = res.data.isFallback
|
||||
? []
|
||||
: res.data.suggestions.filter(profile => !profile.viewer?.following)
|
||||
return {suggestions}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,17 +7,17 @@ import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {AbortError} from '#/lib/async/cancelable'
|
||||
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {
|
||||
ServerError,
|
||||
UploadLimitError,
|
||||
VideoTooLargeError,
|
||||
} from 'lib/media/video/errors'
|
||||
import {CompressedVideo} from 'lib/media/video/types'
|
||||
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
||||
import {useVideoAgent} from 'state/queries/video/util'
|
||||
import {useUploadVideoMutation} from 'state/queries/video/video-upload'
|
||||
} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useCompressVideoMutation} from '#/state/queries/video/compress-video'
|
||||
import {useVideoAgent} from '#/state/queries/video/util'
|
||||
import {useUploadVideoMutation} from '#/state/queries/video/video-upload'
|
||||
|
||||
type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
|
||||
|
||||
@@ -101,9 +101,11 @@ function reducer(queryClient: QueryClient) {
|
||||
|
||||
export function useUploadVideo({
|
||||
setStatus,
|
||||
initialVideoUri,
|
||||
}: {
|
||||
setStatus: (status: string) => void
|
||||
onSuccess: () => void
|
||||
initialVideoUri?: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -237,21 +239,24 @@ export function useUploadVideo({
|
||||
signal: state.abortController.signal,
|
||||
})
|
||||
|
||||
const selectVideo = (asset: ImagePickerAsset) => {
|
||||
// compression step on native converts to mp4, so no need to check there
|
||||
if (isWeb) {
|
||||
const mimeType = getMimeType(asset)
|
||||
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
|
||||
throw new Error(_(msg`Unsupported video type: ${mimeType}`))
|
||||
const selectVideo = React.useCallback(
|
||||
(asset: ImagePickerAsset) => {
|
||||
// compression step on native converts to mp4, so no need to check there
|
||||
if (isWeb) {
|
||||
const mimeType = getMimeType(asset)
|
||||
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
|
||||
throw new Error(_(msg`Unsupported video type: ${mimeType}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'SetAsset',
|
||||
asset,
|
||||
})
|
||||
onSelectVideo(asset)
|
||||
}
|
||||
dispatch({
|
||||
type: 'SetAsset',
|
||||
asset,
|
||||
})
|
||||
onSelectVideo(asset)
|
||||
},
|
||||
[_, onSelectVideo],
|
||||
)
|
||||
|
||||
const clearVideo = () => {
|
||||
dispatch({type: 'Reset'})
|
||||
@@ -265,6 +270,13 @@ export function useUploadVideo({
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Whenever we receive an initial video uri, we should immediately run compression if necessary
|
||||
useEffect(() => {
|
||||
if (initialVideoUri) {
|
||||
selectVideo({uri: initialVideoUri} as ImagePickerAsset)
|
||||
}
|
||||
}, [initialVideoUri, selectVideo])
|
||||
|
||||
return {
|
||||
state,
|
||||
dispatch,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import {AtpSessionEvent, BskyAgent} from '@atproto/api'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import * as persisted from '#/state/persisted'
|
||||
@@ -70,7 +69,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
async params => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
|
||||
const signal = cancelPendingTask()
|
||||
track('Try Create Account')
|
||||
logEvent('account:create:begin', {})
|
||||
const {agent, account} = await createAgentAndCreateAccount(
|
||||
params,
|
||||
@@ -85,7 +83,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
newAgent: agent,
|
||||
newAccount: account,
|
||||
})
|
||||
track('Create Account')
|
||||
logEvent('account:create:success', {})
|
||||
addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
|
||||
},
|
||||
@@ -109,7 +106,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
newAgent: agent,
|
||||
newAccount: account,
|
||||
})
|
||||
track('Sign In', {resumedSession: false})
|
||||
logEvent('account:loggedIn', {logContext, withPassword: true})
|
||||
addSessionDebugLog({type: 'method:end', method: 'login', account})
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ComposerOpts {
|
||||
openEmojiPicker?: (pos: DOMRect | undefined) => void
|
||||
text?: string
|
||||
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
|
||||
videoUri?: string
|
||||
}
|
||||
|
||||
type StateContext = ComposerOpts | undefined
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
export const OnboardingScreenSteps = {
|
||||
@@ -55,17 +54,14 @@ function reducer(state: StateContext, action: Action): StateContext {
|
||||
return compute({...state, step: nextStep})
|
||||
}
|
||||
case 'start': {
|
||||
track('Onboarding:Begin')
|
||||
persisted.write('onboarding', {step: 'Welcome'})
|
||||
return compute({...state, step: 'Welcome'})
|
||||
}
|
||||
case 'finish': {
|
||||
track('Onboarding:Complete')
|
||||
persisted.write('onboarding', {step: 'Home'})
|
||||
return compute({...state, step: 'Home'})
|
||||
}
|
||||
case 'skip': {
|
||||
track('Onboarding:Skipped')
|
||||
persisted.write('onboarding', {step: 'Home'})
|
||||
return compute({...state, step: 'Home'})
|
||||
}
|
||||
|
||||
+8
-88
@@ -7,101 +7,21 @@
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter-Regular';
|
||||
src: local('Inter-Regular'),
|
||||
url(/assets/fonts/inter/Inter-Regular.otf) format('font/otf');
|
||||
font-weight: 400;
|
||||
font-family: 'InterVariable';
|
||||
src: local('InterVariable'),
|
||||
url(/assets/fonts/inter/InterVariable.ttf) format('truetype');
|
||||
font-weight: 300 1000;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter-Italic';
|
||||
src: local('Inter-Italic'),
|
||||
url(/assets/fonts/inter/Inter-Italic.otf) format('font/otf');
|
||||
font-weight: 400;
|
||||
font-family: 'InterVariableItalic';
|
||||
src: local('InterVariableItalic'),
|
||||
url(/assets/fonts/inter/InterVariable-Italic.ttf) format('truetype');
|
||||
font-weight: 300 1000;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
/*
|
||||
@font-face {
|
||||
font-family: "Inter-Medium";
|
||||
src: local("Inter-Medium"), url(/assets/fonts/inter/Inter-Medium.otf) format("font/otf");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter-MediumItalic";
|
||||
src: local("Inter-MediumItalic"), url(/assets/fonts/inter/Inter-MediumItalic.otf) format("font/otf");
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Inter-SemiBold';
|
||||
src: local('Inter-SemiBold'),
|
||||
url(/assets/fonts/inter/Inter-SemiBold.otf) format('font/otf');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter-SemiBoldItalic';
|
||||
src: local('Inter-SemiBoldItalic'),
|
||||
url(/assets/fonts/inter/Inter-SemiBoldItalic.otf) format('font/otf');
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
/*
|
||||
@font-face {
|
||||
font-family: "Inter-Bold";
|
||||
src: local("Inter-Bold"), url(/assets/fonts/inter/Inter-Bold.otf) format("font/otf");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter-BoldItalic";
|
||||
src: local("Inter-BoldItalic"), url(/assets/fonts/inter/Inter-BoldItalic.otf) format("font/otf");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Inter-ExtraBold';
|
||||
src: local('Inter-ExtraBold'),
|
||||
url(/assets/fonts/inter/Inter-ExtraBold.otf) format('font/otf');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter-ExtraBoldItalic';
|
||||
src: local('Inter-ExtraBoldItalic'),
|
||||
url(/assets/fonts/inter/Inter-ExtraBoldItalic.otf) format('font/otf');
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
/*
|
||||
@font-face {
|
||||
font-family: "Inter-Black";
|
||||
src: local("Inter-Black"), url(/assets/fonts/inter/Inter-Black.otf) format("font/otf");
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter-BlackItalic";
|
||||
src: local("Inter-BlackItalic"), url(/assets/fonts/inter/Inter-BlackItalic.otf) format("font/otf");
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* BEGIN STYLES
|
||||
|
||||
@@ -4,7 +4,6 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {s} from '#/lib/styles'
|
||||
@@ -32,7 +31,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {screen} = useAnalytics()
|
||||
const {requestedAccountSwitchTo} = useLoggedOutView()
|
||||
const [screenState, setScreenState] = React.useState<ScreenState>(() => {
|
||||
if (requestedAccountSwitchTo === 'new') {
|
||||
@@ -48,9 +46,8 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
const {clearRequestedAccount} = useLoggedOutViewControls()
|
||||
|
||||
React.useEffect(() => {
|
||||
screen('Login')
|
||||
setMinimalShellMode(true)
|
||||
}, [screen, setMinimalShellMode])
|
||||
}, [setMinimalShellMode])
|
||||
|
||||
const onPressDismiss = React.useCallback(() => {
|
||||
if (onDismiss) {
|
||||
|
||||
@@ -39,21 +39,21 @@ export const SplashScreen = ({
|
||||
<Trans>What's up?</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View testID="signinOrCreateAccount">
|
||||
<View
|
||||
testID="signinOrCreateAccount"
|
||||
style={[a.px_xl, a.gap_md, a.pb_2xl]}>
|
||||
<Button
|
||||
testID="createAccountButton"
|
||||
onPress={onPressCreateAccount}
|
||||
accessibilityRole="button"
|
||||
label={_(msg`Create new account`)}
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to create a new Bluesky account`,
|
||||
)}
|
||||
style={[a.mx_xl, a.mb_xl]}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
<Trans>Create a new account</Trans>
|
||||
<Trans>Create account</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -63,7 +63,6 @@ export const SplashScreen = ({
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to sign into your existing Bluesky account`,
|
||||
)}
|
||||
style={[a.mx_xl, a.mb_xl]}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
|
||||
@@ -85,21 +85,19 @@ export const SplashScreen = ({
|
||||
|
||||
<View
|
||||
testID="signinOrCreateAccount"
|
||||
style={[a.w_full, {maxWidth: 320}]}>
|
||||
style={[a.w_full, a.px_xl, a.gap_md, a.pb_2xl, {maxWidth: 320}]}>
|
||||
<Button
|
||||
testID="createAccountButton"
|
||||
onPress={onPressCreateAccount}
|
||||
accessibilityRole="button"
|
||||
label={_(msg`Create new account`)}
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to create a new Bluesky account`,
|
||||
)}
|
||||
style={[a.mx_xl, a.mb_xl]}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
<Trans>Create a new account</Trans>
|
||||
<Trans>Create account</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -109,7 +107,6 @@ export const SplashScreen = ({
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to sign into your existing Bluesky account`,
|
||||
)}
|
||||
style={[a.mx_xl, a.mb_xl]}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
|
||||
@@ -45,7 +45,6 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import * as apilib from '#/lib/api/index'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
@@ -137,6 +136,7 @@ export const ComposePost = ({
|
||||
openEmojiPicker,
|
||||
text: initText,
|
||||
imageUris: initImageUris,
|
||||
videoUri: initVideoUri,
|
||||
cancelRef,
|
||||
}: Props & {
|
||||
cancelRef?: React.RefObject<CancelRef>
|
||||
@@ -146,7 +146,6 @@ export const ComposePost = ({
|
||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||
const {isModalActive} = useModals()
|
||||
const {closeComposer} = useComposerControls()
|
||||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const {_} = useLingui()
|
||||
@@ -199,6 +198,7 @@ export const ComposePost = ({
|
||||
onPressPublish(true)
|
||||
}
|
||||
},
|
||||
initialVideoUri: initVideoUri,
|
||||
})
|
||||
const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
|
||||
|
||||
@@ -308,7 +308,6 @@ export const ComposePost = ({
|
||||
|
||||
const onPhotoPasted = useCallback(
|
||||
async (uri: string) => {
|
||||
track('Composer:PastedPhotos')
|
||||
if (uri.startsWith('data:video/')) {
|
||||
selectVideo({uri, type: 'video', height: 0, width: 0})
|
||||
} else {
|
||||
@@ -316,7 +315,7 @@ export const ComposePost = ({
|
||||
onImageAdd([res])
|
||||
}
|
||||
},
|
||||
[track, selectVideo, onImageAdd],
|
||||
[selectVideo, onImageAdd],
|
||||
)
|
||||
|
||||
const isAltTextRequiredAndMissing = useMemo(() => {
|
||||
@@ -444,10 +443,6 @@ export const ComposePost = ({
|
||||
logContext: 'Composer',
|
||||
})
|
||||
}
|
||||
track('Create Post', {
|
||||
imageCount: images.length,
|
||||
})
|
||||
if (replyTo && replyTo.uri) track('Post:Reply')
|
||||
}
|
||||
if (postUri && !replyTo) {
|
||||
emitPostCreated()
|
||||
@@ -497,7 +492,6 @@ export const ComposePost = ({
|
||||
setExtLink,
|
||||
setLangPrefs,
|
||||
threadgateAllowUISettings,
|
||||
track,
|
||||
videoAltText,
|
||||
videoUploadState.asset,
|
||||
videoUploadState.pendingPublish,
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {ComposerOptsPostRef} from 'state/shell/composer'
|
||||
import {QuoteEmbed} from 'view/com/util/post-embeds/QuoteEmbed'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {ComposerOptsPostRef} from '#/state/shell/composer'
|
||||
import {QuoteEmbed} from '#/view/com/util/post-embeds/QuoteEmbed'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
@@ -91,7 +91,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={styles.replyToPost}>
|
||||
<Text type="xl-medium" style={t.atoms.text} numberOfLines={1}>
|
||||
<Text type="xl-medium" style={t.atoms.text} numberOfLines={1} emoji>
|
||||
{sanitizeDisplayName(
|
||||
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
|
||||
)}
|
||||
@@ -101,7 +101,8 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
<Text
|
||||
type="post-text"
|
||||
style={t.atoms.text}
|
||||
numberOfLines={!showFull ? 6 : undefined}>
|
||||
numberOfLines={!showFull ? 6 : undefined}
|
||||
emoji>
|
||||
{replyTo.text}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as MediaLibrary from 'expo-media-library'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {useCameraPermission} from '#/lib/hooks/usePermissions'
|
||||
import {openCamera} from '#/lib/media/picker'
|
||||
@@ -20,7 +19,6 @@ type Props = {
|
||||
}
|
||||
|
||||
export function OpenCameraBtn({disabled, onAdd}: Props) {
|
||||
const {track} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||
const [mediaPermissionRes, requestMediaPermission] =
|
||||
@@ -28,7 +26,6 @@ export function OpenCameraBtn({disabled, onAdd}: Props) {
|
||||
const t = useTheme()
|
||||
|
||||
const onPressTakePicture = useCallback(async () => {
|
||||
track('Composer:CameraOpened')
|
||||
try {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
@@ -58,7 +55,6 @@ export function OpenCameraBtn({disabled, onAdd}: Props) {
|
||||
}
|
||||
}, [
|
||||
onAdd,
|
||||
track,
|
||||
requestCameraAccessIfNeeded,
|
||||
mediaPermissionRes,
|
||||
requestMediaPermission,
|
||||
|
||||
@@ -3,7 +3,6 @@ import React, {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||
import {openPicker} from '#/lib/media/picker'
|
||||
import {isNative} from '#/platform/detection'
|
||||
@@ -19,14 +18,11 @@ type Props = {
|
||||
}
|
||||
|
||||
export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
|
||||
const {track} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||
const t = useTheme()
|
||||
|
||||
const onPressSelectPhotos = useCallback(async () => {
|
||||
track('Composer:GalleryOpened')
|
||||
|
||||
if (isNative && !(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
@@ -41,7 +37,7 @@ export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
|
||||
)
|
||||
|
||||
onAdd(results)
|
||||
}, [track, requestPhotoAccessIfNeeded, size, onAdd])
|
||||
}, [requestPhotoAccessIfNeeded, size, onAdd])
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -245,7 +245,11 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
multiline
|
||||
scrollEnabled={false}
|
||||
numberOfLines={4}
|
||||
style={[inputTextStyle, a.w_full, {textAlignVertical: 'top'}]}
|
||||
style={[
|
||||
inputTextStyle,
|
||||
a.w_full,
|
||||
{textAlignVertical: 'top', minHeight: 60},
|
||||
]}
|
||||
{...props}>
|
||||
{textDecorated}
|
||||
</PasteInput>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {useGrapheme} from '../hooks/useGrapheme'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import React, {useRef} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {FadeInDown, FadeOut} from 'react-native-reanimated'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useGrapheme} from '../hooks/useGrapheme'
|
||||
|
||||
export function Autocomplete({
|
||||
prefix,
|
||||
@@ -16,8 +20,8 @@ export function Autocomplete({
|
||||
prefix: string
|
||||
onSelect: (item: string) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const positionInterp = useAnimatedValue(0)
|
||||
const t = useTheme()
|
||||
|
||||
const {getGraphemeString} = useGrapheme()
|
||||
const isActive = !!prefix
|
||||
const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix)
|
||||
@@ -28,108 +32,85 @@ export function Autocomplete({
|
||||
suggestionsRef.current = suggestions
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(positionInterp, {
|
||||
toValue: isActive ? 1 : 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}, [positionInterp, isActive])
|
||||
|
||||
const topAnimStyle = {
|
||||
transform: [
|
||||
{
|
||||
translateY: positionInterp.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [200, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
if (!isActive) return null
|
||||
|
||||
return (
|
||||
<Animated.View style={topAnimStyle}>
|
||||
{isActive ? (
|
||||
<View style={[pal.view, styles.container, pal.border]}>
|
||||
{suggestionsRef.current?.length ? (
|
||||
suggestionsRef.current.slice(0, 5).map(item => {
|
||||
// Eventually use an average length
|
||||
const MAX_CHARS = 40
|
||||
const MAX_HANDLE_CHARS = 20
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(200)}
|
||||
exiting={FadeOut.duration(100)}
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
a.mt_sm,
|
||||
a.border,
|
||||
a.rounded_sm,
|
||||
t.atoms.border_contrast_high,
|
||||
{marginLeft: -62},
|
||||
]}>
|
||||
{suggestionsRef.current?.length ? (
|
||||
suggestionsRef.current.slice(0, 5).map((item, index, arr) => {
|
||||
// Eventually use an average length
|
||||
const MAX_CHARS = 40
|
||||
const MAX_HANDLE_CHARS = 20
|
||||
|
||||
// Using this approach because styling is not respecting
|
||||
// bounding box wrapping (before converting to ellipsis)
|
||||
const {name: displayHandle, remainingCharacters} =
|
||||
getGraphemeString(item.handle, MAX_HANDLE_CHARS)
|
||||
// Using this approach because styling is not respecting
|
||||
// bounding box wrapping (before converting to ellipsis)
|
||||
const {name: displayHandle, remainingCharacters} = getGraphemeString(
|
||||
item.handle,
|
||||
MAX_HANDLE_CHARS,
|
||||
)
|
||||
|
||||
const {name: displayName} = getGraphemeString(
|
||||
item.displayName ?? item.handle,
|
||||
MAX_CHARS -
|
||||
MAX_HANDLE_CHARS +
|
||||
(remainingCharacters > 0 ? remainingCharacters : 0),
|
||||
)
|
||||
const {name: displayName} = getGraphemeString(
|
||||
item.displayName || item.handle,
|
||||
MAX_CHARS -
|
||||
MAX_HANDLE_CHARS +
|
||||
(remainingCharacters > 0 ? remainingCharacters : 0),
|
||||
)
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
testID="autocompleteButton"
|
||||
key={item.handle}
|
||||
style={[pal.border, styles.item]}
|
||||
onPress={() => onSelect(item.handle)}
|
||||
accessibilityLabel={`Select ${item.handle}`}
|
||||
accessibilityHint="">
|
||||
<View style={styles.avatarAndHandle}>
|
||||
<UserAvatar
|
||||
avatar={item.avatar ?? null}
|
||||
size={24}
|
||||
type={item.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<Text type="md-medium" style={pal.text}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
<Text type="sm" style={pal.textLight} numberOfLines={1}>
|
||||
@{displayHandle}
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
index !== arr.length - 1 && a.border_b,
|
||||
t.atoms.border_contrast_high,
|
||||
a.px_sm,
|
||||
a.py_md,
|
||||
]}
|
||||
key={item.handle}>
|
||||
<PressableScale
|
||||
testID="autocompleteButton"
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
]}
|
||||
onPress={() => onSelect(item.handle)}
|
||||
accessibilityLabel={`Select ${item.handle}`}
|
||||
accessibilityHint="">
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<UserAvatar
|
||||
avatar={item.avatar ?? null}
|
||||
size={24}
|
||||
type={item.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold]}
|
||||
emoji={true}
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(displayName)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<Text type="sm" style={[pal.text, pal.border, styles.noResults]}>
|
||||
{isFetching ? (
|
||||
<Trans>Loading...</Trans>
|
||||
) : (
|
||||
<Trans>No result</Trans>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[t.atoms.text_contrast_medium]} numberOfLines={1}>
|
||||
{sanitizeHandle(displayHandle, '@')}
|
||||
</Text>
|
||||
</PressableScale>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<Text style={[a.text_md, a.px_sm, a.py_md]}>
|
||||
{isFetching ? <Trans>Loading...</Trans> : <Trans>No result</Trans>}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginLeft: -50, // Composer avatar width
|
||||
top: 10,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
item: {
|
||||
borderBottomWidth: 1,
|
||||
paddingVertical: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 6,
|
||||
},
|
||||
avatarAndHandle: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
alignItems: 'center',
|
||||
},
|
||||
noResults: {
|
||||
paddingVertical: 12,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
@@ -30,13 +29,11 @@ export function ThreadgateBtn({
|
||||
|
||||
style?: StyleProp<AnimatedStyle<ViewStyle>>
|
||||
}) {
|
||||
const {track} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
const onPress = () => {
|
||||
track('Composer:ThreadgateOpened')
|
||||
if (isNative && Keyboard.isVisible()) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import {useLingui} from '@lingui/react'
|
||||
import {NavigationProp, useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {ComposeIcon2} from '#/lib/icons'
|
||||
import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
|
||||
import {AllNavigatorParams} from '#/lib/routes/types'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {s} from '#/lib/styles'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {listenSoftReset} from '#/state/events'
|
||||
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
||||
@@ -17,10 +20,6 @@ import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {ComposeIcon2} from 'lib/icons'
|
||||
import {AllNavigatorParams} from 'lib/routes/types'
|
||||
import {s} from 'lib/styles'
|
||||
import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
|
||||
import {Feed} from '../posts/Feed'
|
||||
import {FAB} from '../util/fab/FAB'
|
||||
@@ -54,7 +53,6 @@ export function FeedPage({
|
||||
const {openComposer} = useComposerControls()
|
||||
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {screen, track} = useAnalytics()
|
||||
const headerOffset = useHeaderOffset()
|
||||
const feedFeedback = useFeedFeedback(feed, hasSession)
|
||||
const scrollElRef = React.useRef<ListMethods>(null)
|
||||
@@ -89,14 +87,12 @@ export function FeedPage({
|
||||
if (!isPageFocused) {
|
||||
return
|
||||
}
|
||||
screen('Feed')
|
||||
return listenSoftReset(onSoftReset)
|
||||
}, [onSoftReset, screen, isPageFocused])
|
||||
}, [onSoftReset, isPageFocused])
|
||||
|
||||
const onPressCompose = React.useCallback(() => {
|
||||
track('HomeScreen:PressCompose')
|
||||
openComposer({})
|
||||
}, [openComposer, track])
|
||||
}, [openComposer])
|
||||
|
||||
const onPressLoadLatest = React.useCallback(() => {
|
||||
scrollToTop()
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useReducedMotion,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {useKawaiiMode} from '#/state/preferences/kawaii'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useMinimalShellHeaderTransform} from 'lib/hooks/useMinimalShellTransform'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
// import {Logo} from '#/view/icons/Logo'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Icon, Trigger} from '#/components/dialogs/nuxs/TenMillion/Trigger'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag'
|
||||
import {Link} from '#/components/Link'
|
||||
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
|
||||
@@ -48,43 +39,7 @@ function HomeHeaderLayoutDesktopAndTablet({
|
||||
const {headerHeight} = useShellLayout()
|
||||
const {hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
|
||||
// TEMPORARY - REMOVE AFTER MILLY
|
||||
// This will just cause the icon to shake a bit when the user first opens the app, drawing attention to the celebration
|
||||
// 🎉
|
||||
const rotate = useSharedValue(0)
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
// Run this a single time on app mount.
|
||||
React.useEffect(() => {
|
||||
if (reducedMotion) return
|
||||
|
||||
// Waits 1500ms, then rotates 10 degrees with a spring animation. Repeats once.
|
||||
rotate.value = withDelay(
|
||||
1000,
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(10, {duration: 100}),
|
||||
withSpring(0, {
|
||||
mass: 1,
|
||||
damping: 1,
|
||||
stiffness: 200,
|
||||
overshootClamping: false,
|
||||
}),
|
||||
),
|
||||
2,
|
||||
false,
|
||||
),
|
||||
)
|
||||
}, [rotate, reducedMotion])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
rotateZ: `${rotate.value}deg`,
|
||||
},
|
||||
],
|
||||
}))
|
||||
const kawaii = useKawaiiMode()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -101,30 +56,21 @@ function HomeHeaderLayoutDesktopAndTablet({
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
styles.bar,
|
||||
kawaii && {paddingTop: 22, paddingBottom: 16},
|
||||
]}>
|
||||
<Animated.View
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.pt_lg,
|
||||
a.m_auto,
|
||||
kawaii && {paddingTop: 4, paddingBottom: 0},
|
||||
{
|
||||
width: 28,
|
||||
width: kawaii ? 84 : 28,
|
||||
},
|
||||
animatedStyle,
|
||||
]}>
|
||||
<Trigger>
|
||||
{ctx => (
|
||||
<Icon
|
||||
width={28}
|
||||
style={{
|
||||
opacity: ctx.hovered || ctx.pressed ? 0.8 : 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Trigger>
|
||||
{/* <Logo width={28} /> */}
|
||||
</Animated.View>
|
||||
<Logo width={kawaii ? 60 : 28} />
|
||||
</View>
|
||||
|
||||
<Link
|
||||
to="/feeds"
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useReducedMotion,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useSetDrawerOpen} from '#/state/shell/drawer-open'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {useMinimalShellHeaderTransform} from 'lib/hooks/useMinimalShellTransform'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
// import {Logo} from '#/view/icons/Logo'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {atoms} from '#/alf'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Icon, Trigger} from '#/components/dialogs/nuxs/TenMillion/Trigger'
|
||||
import {ColorPalette_Stroke2_Corner0_Rounded as ColorPalette} from '#/components/icons/ColorPalette'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag'
|
||||
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||
@@ -49,43 +39,6 @@ export function HomeHeaderLayoutMobile({
|
||||
setDrawerOpen(true)
|
||||
}, [setDrawerOpen])
|
||||
|
||||
// TEMPORARY - REMOVE AFTER MILLY
|
||||
// This will just cause the icon to shake a bit when the user first opens the app, drawing attention to the celebration
|
||||
// 🎉
|
||||
const rotate = useSharedValue(0)
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
// Run this a single time on app mount.
|
||||
React.useEffect(() => {
|
||||
if (reducedMotion) return
|
||||
|
||||
// Waits 1500ms, then rotates 10 degrees with a spring animation. Repeats once.
|
||||
rotate.value = withDelay(
|
||||
1000,
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(10, {duration: 100}),
|
||||
withSpring(0, {
|
||||
mass: 1,
|
||||
damping: 1,
|
||||
stiffness: 200,
|
||||
overshootClamping: false,
|
||||
}),
|
||||
),
|
||||
2,
|
||||
false,
|
||||
),
|
||||
)
|
||||
}, [rotate, reducedMotion])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
rotateZ: `${rotate.value}deg`,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
|
||||
@@ -106,19 +59,9 @@ export function HomeHeaderLayoutMobile({
|
||||
<Menu size="lg" fill={t.atoms.text_contrast_medium.color} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Animated.View style={animatedStyle}>
|
||||
<Trigger>
|
||||
{ctx => (
|
||||
<Icon
|
||||
width={28}
|
||||
style={{
|
||||
opacity: ctx.pressed ? 0.8 : 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Trigger>
|
||||
{/* <Logo width={30} /> */}
|
||||
</Animated.View>
|
||||
<View>
|
||||
<Logo width={30} />
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
atoms.flex_row,
|
||||
|
||||
@@ -7,21 +7,21 @@ import {
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||
import {List, ListRef} from '../util/List'
|
||||
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
import {ProfileCard} from '../profile/ProfileCard'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {useListMembersQuery} from '#/state/queries/list-members'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useListMembersQuery} from '#/state/queries/list-members'
|
||||
import {useSession} from '#/state/session'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {ProfileCard} from '../profile/ProfileCard'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {List, ListRef} from '../util/List'
|
||||
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
|
||||
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
const EMPTY_ITEM = {_reactKey: '__empty__'}
|
||||
@@ -51,7 +51,6 @@ export function ListMembers({
|
||||
headerOffset?: number
|
||||
desktopFixedHeightOffset?: number
|
||||
}) {
|
||||
const {track} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
@@ -98,7 +97,6 @@ export function ListMembers({
|
||||
// =
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
track('Lists:onRefresh')
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await refetch()
|
||||
@@ -106,17 +104,16 @@ export function ListMembers({
|
||||
logger.error('Failed to refresh lists', {message: err})
|
||||
}
|
||||
setIsRefreshing(false)
|
||||
}, [refetch, track, setIsRefreshing])
|
||||
}, [refetch, setIsRefreshing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
track('Lists:onEndReached')
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more lists', {message: err})
|
||||
}
|
||||
}, [isFetching, hasNextPage, isError, fetchNextPage, track])
|
||||
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
const onPressRetryLoadMore = React.useCallback(() => {
|
||||
fetchNextPage()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user