Compare commits

...

33 Commits

Author SHA1 Message Date
Eric Bailey 90dc3edc5b Use Inter variable font 2024-09-30 12:40:16 -05:00
Hailey 587c0c6257 Rework native autocomplete (#5521)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2024-09-27 15:26:28 -07:00
Samuel Newman 4b5d6e6efb Pinned posts (#5055)
* add to dropdown menu

* use normal profile mutation hook

* add pin as reason

* request pins

* shadow update

* rm logs

* get prev pinned from getProfile

* fix toasts

* invalidate after appview ready

* don't mutate params

* rm log

* use checkCommited rather than manual whenAppViewReady

* move to mutation

* even more optimistic optimistic update

* allow pins in `posts_and_author_threads`

* update @atproto/api

* add reasonPin type

* fix strange type error in unrelated query

* another missing type
2024-09-28 00:50:32 +03:00
Hailey f68b15219f Remove Segment (#5518) 2024-09-27 14:01:57 -07:00
Hailey bcd096b85a Fix alignment of cancel button on search (#5520) 2024-09-27 09:55:47 -07:00
Hailey d8f72c1ee1 [Share Extension] Support on Android for sharing videos to app (#5466) 2024-09-27 09:54:37 -07:00
Samuel Newman 4553e6b649 Ignore bogus onScroll values (#5499) 2024-09-27 10:09:00 +01:00
Hailey dd2fedb2e6 add podcasts to spotify embeds (#5514) 2024-09-27 16:19:12 +09:00
Hailey c7b48cbdca Tweak font size of "Write your reply" (#5513) 2024-09-26 21:01:57 -07:00
Hailey 389e6f1509 Lazy load ViewShot (#5517)
* lazy one spot

* lazy signup

* fix type

* tweak type, fix missing viewshot type

* only import type oops
2024-09-27 12:52:32 +09:00
Hailey dd1944e9b8 [Share Extension] Support images/movies from other apps like iMessage (#5515) 2024-09-26 19:57:16 -07:00
Samuel Newman 77c4a9922f tab change on press rather than pressIn (#5508) 2024-09-26 21:05:12 +01:00
Hailey a61b75be57 Add margin to bottom of search input (#5510) 2024-09-26 12:57:54 -07:00
Hailey 7ee67e4e7e [Share Extension] Move away from deprecated API, implement JS side of things (#5509) 2024-09-26 12:37:51 -07:00
Eric Bailey 175df72972 Fix weird button wrapping on splash (#5507)
* Fix weird button wrapping on splash

* Web
2024-09-26 12:58:47 -05:00
Hailey 863764b3fe [Share Extension] Use the proper identifier for if statement on iOS (#5505) 2024-09-26 10:51:22 -07:00
Eric Bailey 702dfa8536 Fix handle collapse on Android (#5504) 2024-09-26 12:47:27 -05:00
Eric Bailey 1ae7fa6363 Adjust header offset (#5501) 2024-09-27 01:59:04 +09:00
Eric Bailey 960f4450ee Adjust dialog timing (#5502) 2024-09-27 01:58:47 +09:00
Eric Bailey 5a142161cb Remove 10milly dialog, revert header logo changes (#5503) 2024-09-26 11:47:13 -05:00
Eric Bailey 27e09e7adc Fix font loading (#5500) 2024-09-27 01:30:00 +09:00
Eric Bailey 23dd638f6a Clean up left nav on web (#5488)
* Tweak nav item count style

* Fix nav item icon width and alignment

* Just refactor the thing

* New compose button

* Z index

* Rounded

* Tweak gradient

* Tweak gradient

* Tweak gradient

* Solid

* Adjust position of counter

* Always a circle
2024-09-26 10:57:45 -05:00
Eric Bailey 1895ae2e69 Adjust line height to not cut off emoji (#5496) 2024-09-26 23:59:47 +09:00
Eric Bailey 179a913f20 Emoji in account list (#5497) 2024-09-26 23:59:32 +09:00
Igor Adrov 625d7460d8 Make the counter more rounded (#5083) 2024-09-26 09:48:12 -05:00
Samuel Newman f2a69c4528 add emoji prop to composer reply to text (#5495) 2024-09-26 14:18:48 +01:00
Samuel Newman a9765fd23f Fix banner height in edit profile modal (#5494)
* fix banner height

* fix user banner, it's not edit profile's fault
2024-09-26 22:09:02 +09:00
Samuel Newman 78fb666020 Messages list - make avatars link to profile (#5484) 2024-09-26 01:32:54 +01:00
Eric Bailey 2e5f95c8dd Add back empty placeholder (#5489) 2024-09-25 17:41:03 -05:00
Hailey 58036ffb52 Filter errors that get sent to Sentry (#5247) 2024-09-25 15:05:33 -07:00
Eric Bailey b1ca2503de Add language filtering UI to search (#5459)
* Use new TextField for search bar

* Add lang dropdown

* Dialog

* Revert "Dialog"

This reverts commit 257573cd9c.

* Extract util, test, cleanup

* Fix formatting

* Pass through other params

* Fix sticky header

* Fix stale data, hide/show

* Improve query parsing

* Replace memo

* Couple tweaks

* Revert cancel change

* Remove unused placeholder
2024-09-25 15:54:43 -05:00
Eric Bailey 6bc001a30e Support emojis in settings account cards (#5487) 2024-09-25 15:48:40 -05:00
Eric Bailey fd15bfec40 Ensure notifications align with new post alignment (#5486) 2024-09-25 15:39:03 -05:00
139 changed files with 1502 additions and 3800 deletions
+7 -7
View File
@@ -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
View File
@@ -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.
+3 -19
View File
@@ -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;
-15
View File
@@ -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]),
+1 -1
View File
@@ -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
}
}
}
@@ -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
View File
@@ -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: [
{
-3
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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: {
+9
View File
@@ -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'],
+1
View File
@@ -156,6 +156,7 @@ export type ThemedAtoms = {
}
}
export type Theme = {
scheme: 'light' | 'dark' // for library support
name: ThemeName
palette: Palette
atoms: ThemedAtoms
+4 -2
View File
@@ -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
+5 -4
View File
@@ -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)
}
+11 -6
View File
@@ -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>
)
})
+49 -41
View File
@@ -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>
)
}
+4 -7
View File
@@ -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>
)
+16 -7
View File
@@ -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,
]}
+3 -3
View File
@@ -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
}
-158
View File
@@ -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
}
-80
View File
@@ -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()
}
}
}
-181
View File
@@ -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: {}
}
+10 -3
View File
@@ -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)
})
}
+9 -8
View File
@@ -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>
)
}
+1 -4
View File
@@ -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}
+18 -6
View File
@@ -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 => {
+11 -13
View File
@@ -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())
+16 -2
View File
@@ -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}`,
}
}
}
}
+13 -5
View File
@@ -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
View File
@@ -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
-8
View File
@@ -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 -7
View File
@@ -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()
}, [])
+3 -6
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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>('')
-3
View File
@@ -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)
}
+18 -7
View File
@@ -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,
},
]}
+1 -4
View File
@@ -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} =
+5 -14
View File
@@ -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]} />
+2 -16
View File
@@ -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>
)
},
+11 -14
View File
@@ -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 => ({
+8 -6
View File
@@ -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)
},
)
})
+43
View File
@@ -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(' ')
}
+4 -1
View File
@@ -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 = () => {
-6
View File
@@ -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})
+2
View File
@@ -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,
},
})
}
+4 -11
View File
@@ -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,
}
+87
View File
@@ -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})
}
}
},
})
}
+1
View File
@@ -91,6 +91,7 @@ export interface FeedPostSlice {
feedContext: string | undefined
reason?:
| AppBskyFeedDefs.ReasonRepost
| AppBskyFeedDefs.ReasonPin
| ReasonFeedSource
| {[k: string]: unknown; $type: string}
}
+1 -15
View File
@@ -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')
},
})
}
-3
View File
@@ -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,
+3 -5
View File
@@ -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)
},
})
+5 -6
View File
@@ -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}
},
})
}
+32 -20
View File
@@ -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,
-4
View File
@@ -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})
},
+1
View File
@@ -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
-4
View File
@@ -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
View File
@@ -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
+1 -4
View File
@@ -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) {
+4 -5
View File
@@ -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">
+2 -5
View File
@@ -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">
+3 -9
View File
@@ -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,
+9 -8
View File
@@ -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()
}
+5 -9
View File
@@ -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()
+12 -66
View File
@@ -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"
+9 -66
View File
@@ -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,
+14 -17
View File
@@ -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