Merge branch 'main' into rn-0.75

This commit is contained in:
Hailey
2024-09-28 10:05:43 -07:00
153 changed files with 2284 additions and 4265 deletions
-47
View File
@@ -1,47 +0,0 @@
export const exampleComHtml = `<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta name="description" content="An example website">
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>`
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
+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)
})
}
})
-134
View File
@@ -1,134 +0,0 @@
import {extractHtmlMeta} from '../../src/lib/link-meta/html'
import {exampleComHtml} from './__mocks__/exampleComHtml'
import {youtubeHTML} from './__mocks__/youtubeHtml'
import {tiktokHtml} from './__mocks__/tiktokHtml'
import {youtubeChannelHtml} from './__mocks__/youtubeChannelHtml'
describe('extractHtmlMeta', () => {
const cases = [
['', {}],
['nothing', {}],
['<title>title</title>', {title: 'title'}],
['<title> aSd!@#AC </title>', {title: 'aSd!@#AC'}],
['<title>\n title\n </title>', {title: 'title'}],
['<meta name="title" content="meta title">', {title: 'meta title'}],
[
'<meta name="description" content="meta description">',
{description: 'meta description'},
],
['<meta property="og:title" content="og title">', {title: 'og title'}],
[
'<meta property="og:description" content="og description">',
{description: 'og description'},
],
[
'<meta property="og:image" content="https://ogimage.com/foo.png">',
{image: 'https://ogimage.com/foo.png'},
],
[
'<meta property="twitter:title" content="twitter title">',
{title: 'twitter title'},
],
[
'<meta property="twitter:description" content="twitter description">',
{description: 'twitter description'},
],
[
'<meta property="twitter:image" content="https://twitterimage.com/foo.png">',
{image: 'https://twitterimage.com/foo.png'},
],
['<meta\n name="title"\n content="meta title"\n>', {title: 'meta title'}],
]
it.each(cases)(
'given the html tag %p, returns %p',
// @ts-ignore not worth fixing -prf
(input, expectedResult) => {
const output = extractHtmlMeta({html: input as string, hostname: ''})
expect(output).toEqual(expectedResult)
},
)
it('extracts title and description from a generic HTML page', () => {
const input = exampleComHtml
const expectedOutput = {
title: 'Example Domain',
description: 'An example website',
}
const output = extractHtmlMeta({html: input, hostname: 'example.com'})
expect(output).toEqual(expectedOutput)
})
it('extracts title and description from a Tiktok HTML page', () => {
const input = tiktokHtml
const expectedOutput = {
title:
'Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #bal... | TikTok',
description:
'5.5M Likes, 20.8K Comments. TikTok video from Power Vision Tests (@_powervision_): &quot;Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp&quot;. оригинальный звук - Power Vision Tests.',
}
const output = extractHtmlMeta({html: input, hostname: 'tiktok.com'})
expect(output).toEqual(expectedOutput)
})
it('extracts title and description from a generic youtube page', () => {
const input = youtubeHTML
const expectedOutput = {
title: 'HD Video (1080p) with Relaxing Music of Native American Shamans',
description:
'Stunning HD Video ( 1080p ) of Patagonian Nature with Relaxing Native American Shamanic Music. HD footage used from ',
image: 'https://i.ytimg.com/vi/x6UITRjhijI/sddefault.jpg',
}
const output = extractHtmlMeta({html: input, hostname: 'youtube.com'})
expect(output).toEqual(expectedOutput)
})
it('extracts avatar from a youtube channel', () => {
const input = youtubeChannelHtml
const expectedOutput = {
title: 'penguinz0',
description:
'Clips channel: https://www.youtube.com/channel/UC4EQHfzIbkL_Skit_iKt1aA\n\nTwitter: https://twitter.com/MoistCr1TiKaL\n\nInstagram: https://www.instagram.com/bigmoistcr1tikal/?hl=en\n\nTwitch: https://www.twitch.tv/moistcr1tikal\n\nSnapchat: Hugecharles\n\nTik Tok: Hugecharles\n\nI don&#39;t have any other public accounts.',
image:
'https://yt3.googleusercontent.com/ytc/AL5GRJWOhJOuUC6C2b7gP-5D2q6ypXbcOOckyAE1En4RUQ=s176-c-k-c0x00ffffff-no-rj',
}
const output = extractHtmlMeta({html: input, hostname: 'youtube.com'})
expect(output).toEqual(expectedOutput)
})
it('extracts username from the url a twitter profile page', () => {
const expectedOutput = {
title: '@bluesky on Twitter',
}
const output = extractHtmlMeta({
html: '',
hostname: 'twitter.com',
pathname: '/bluesky',
})
expect(output).toEqual(expectedOutput)
})
it('extracts username from the url a tweet', () => {
const expectedOutput = {
title: 'Tweet by @bluesky',
}
const output = extractHtmlMeta({
html: '',
hostname: 'twitter.com',
pathname: '/bluesky/status/1582437529969917953',
})
expect(output).toEqual(expectedOutput)
})
it("does not extract username from the url when it's not a tweet or profile page", () => {
const expectedOutput = {
title: 'Twitter',
}
const output = extractHtmlMeta({
html: '',
hostname: 'twitter.com',
pathname: '/i/articles/follows/-1675653703?time_window=24',
})
expect(output).toEqual(expectedOutput)
})
})
-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 {
+2 -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",
@@ -121,6 +117,7 @@
"expo": "^52.0.0-canary-20240912-1059f85",
"expo-modules-core": "2.0.0-canary-20240912-1059f85",
"expo-application": "^6.0.0-canary-20240912-1059f85",
"expo-blur": "^14.0.0-canary-20240927-ab8a962",
"expo-build-properties": "^1.0.0-canary-20240912-1059f85",
"expo-camera": "~16.0.0-canary-20240912-1059f85",
"expo-clipboard": "^7.0.0-canary-20240912-1059f85",
@@ -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: [
{
+2 -2
View File
@@ -1,5 +1,5 @@
import 'lib/sentry' // must be near top
import 'view/icons'
import '#/lib/sentry' // must be near top
import '#/view/icons'
import './style.css'
import React, {useEffect, useState} from 'react'
-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()
+6 -3
View File
@@ -45,15 +45,18 @@ export function applyFonts(
// '100': 'Inter-Thin',
// '200': 'Inter-ExtraLight',
// '300': 'Inter-Light',
// '500': 'Inter-Medium',
// '700': 'Inter-Bold',
// '900': 'Inter-Black',
'100': 'Inter-Regular',
'200': 'Inter-Regular',
'300': 'Inter-Regular',
'400': 'Inter-Regular',
'500': 'Inter-Medium',
'500': 'Inter-SemiBold',
'600': 'Inter-SemiBold',
'700': 'Inter-Bold',
'700': 'Inter-SemiBold',
'800': 'Inter-ExtraBold',
'900': 'Inter-Black',
'900': 'Inter-ExtraBold',
}[style.fontWeight as string] || 'Inter-Regular'
if (style.fontStyle === 'italic') {
+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)
}
+28 -8
View File
@@ -9,6 +9,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions, useLinkProps} from '@react-navigation/native'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {AllNavigatorParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {
@@ -17,11 +18,10 @@ import {
isExternalUrl,
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {isNative, isWeb} from '#/platform/detection'
import {shouldClickOpenNewTab} from '#/platform/urls'
import {useModalControls} from '#/state/modals'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'
@@ -244,7 +244,10 @@ export function Link({
export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
> &
Pick<ButtonProps, 'label'>
Pick<ButtonProps, 'label'> & {
disableUnderline?: boolean
title?: TextProps['title']
}
export function InlineLinkText({
children,
@@ -257,6 +260,7 @@ export function InlineLinkText({
selectable,
label,
shareOnLongPress,
disableUnderline,
...rest
}: InlineLinkProps) {
const t = useTheme()
@@ -290,11 +294,12 @@ export function InlineLinkText({
{...rest}
style={[
{color: t.palette.primary_500},
(hovered || focused || pressed) && {
...web({outline: 0}),
textDecorationLine: 'underline',
textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
},
(hovered || focused || pressed) &&
!disableUnderline && {
...web({outline: 0}),
textDecorationLine: 'underline',
textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
},
flattenedStyle,
]}
role="link"
@@ -365,3 +370,18 @@ export function BaseLink({
</Pressable>
)
}
export function WebOnlyInlineLinkText({
children,
to,
onPress,
...props
}: InlineLinkProps) {
return isWeb ? (
<InlineLinkText {...props} to={to} onPress={onPress}>
{children}
</InlineLinkText>
) : (
<Text {...props}>{children}</Text>
)
}
@@ -12,15 +12,15 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
import {parseStarterPackUri} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {useGenerateStarterPackMutation} from 'lib/generate-starterpack'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {parseStarterPackUri} from 'lib/strings/starter-pack'
import {List, ListRef} from 'view/com/util/List'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
import {List, ListRef} from '#/view/com/util/List'
import {Text} from '#/view/com/util/text/Text'
import {atoms as a, ios, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
@@ -132,6 +132,7 @@ export const ProfileStarterPacks = React.forwardRef<
keyExtractor={keyExtractor}
refreshing={isPTRing}
headerOffset={headerOffset}
progressViewOffset={ios(0)}
contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}}
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true}
+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)
})
}
@@ -0,0 +1,59 @@
import React from 'react'
import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'
import Animated, {
cancelAnimation,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {isTouchDevice} from '#/lib/browser'
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,
style,
onPressIn,
onPressOut,
...rest
}: {
targetScale?: number
style?: StyleProp<ViewStyle>
} & Exclude<PressableProps, 'onPressIn' | 'onPressOut' | 'style'>) {
const scale = useSharedValue(1)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{scale: scale.value}],
}))
return (
<AnimatedPressable
accessibilityRole="button"
onPressIn={e => {
'worklet'
if (onPressIn) {
runOnJS(onPressIn)(e)
}
cancelAnimation(scale)
scale.value = withTiming(targetScale, {duration: 100})
}}
onPressOut={e => {
'worklet'
if (onPressOut) {
runOnJS(onPressOut)(e)
}
cancelAnimation(scale)
scale.value = withTiming(1, {duration: 100})
}}
style={[animatedStyle, style]}
{...rest}>
{children}
</AnimatedPressable>
)
}
+7
View File
@@ -1,8 +1,10 @@
import React from 'react'
import * as Device from 'expo-device'
import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
import {isIOS, isWeb} from '#/platform/detection'
import {useHapticsDisabled} from '#/state/preferences/disable-haptics'
import * as Toast from '#/view/com/util/Toast'
export function useHaptics() {
const isHapticsDisabled = useHapticsDisabled()
@@ -18,6 +20,11 @@ export function useHaptics() {
? ImpactFeedbackStyle[strength]
: ImpactFeedbackStyle.Light
impactAsync(style)
// DEV ONLY - show a toast when a haptic is meant to fire on simulator
if (__DEV__ && !Device.isDevice) {
Toast.show(`Buzzz!`)
}
},
[isHapticsDisabled],
)
+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())
-71
View File
@@ -1,71 +0,0 @@
import {extractTwitterMeta} from './twitter'
import {extractYoutubeMeta} from './youtube'
interface ExtractHtmlMetaInput {
html: string
hostname?: string
pathname?: string
}
export const extractHtmlMeta = ({
html,
hostname,
pathname,
}: ExtractHtmlMetaInput): Record<string, string> => {
const htmlTitleRegex = /<title.*>([^<]+)<\/title>/i
let res: Record<string, string> = {}
const match = htmlTitleRegex.exec(html)
if (match) {
res.title = match[1].trim()
}
let metaMatch
let propMatch
const metaRe = /<meta[\s]([^>]+)>/gis
while ((metaMatch = metaRe.exec(html))) {
let propName
let propValue
const propRe = /(name|property|content)="([^"]+)"/gis
while ((propMatch = propRe.exec(metaMatch[1]))) {
if (propMatch[1] === 'content') {
propValue = propMatch[2]
} else {
propName = propMatch[2]
}
}
if (!propName || !propValue) {
continue
}
switch (propName?.trim()) {
case 'title':
case 'og:title':
case 'twitter:title':
res.title = propValue?.trim()
break
case 'description':
case 'og:description':
case 'twitter:description':
res.description = propValue?.trim()
break
case 'og:image':
case 'twitter:image':
res.image = propValue?.trim()
break
}
}
const isYoutubeUrl =
hostname?.includes('youtube.') || hostname?.includes('youtu.be')
const isTwitterUrl = hostname?.includes('twitter.')
// Workaround for some websites not having a title or description in the meta tags in the initial serve
if (isYoutubeUrl) {
res = {...res, ...extractYoutubeMeta(html)}
} else if (isTwitterUrl && pathname) {
res = {...extractTwitterMeta({pathname})}
}
return res
}
-20
View File
@@ -1,20 +0,0 @@
export const extractTwitterMeta = ({
pathname,
}: {
pathname: string
}): Record<string, string> => {
const res = {title: 'Twitter'}
const parsedPathname = pathname.split('/')
if (parsedPathname.length <= 1 || parsedPathname[1].length <= 1) {
// Excluding one letter usernames as they're reserved by twitter for things like cases like twitter.com/i/articles/follows/-1675653703
return res
}
const username = parsedPathname?.[1]
const isUserProfile = parsedPathname?.length === 2
res.title = isUserProfile
? `@${username} on Twitter`
: `Tweet by @${username}`
return res
}
-31
View File
@@ -1,31 +0,0 @@
export const extractYoutubeMeta = (html: string): Record<string, string> => {
const res: Record<string, string> = {}
const youtubeTitleRegex = /"videoDetails":.*"title":"([^"]*)"/i
const youtubeDescriptionRegex =
/"videoDetails":.*"shortDescription":"([^"]*)"/i
const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i
const youtubeAvatarRegex =
/"avatar":{"thumbnails":\[{.*?url.*?url.*?url":"([^"]*)"/i
const youtubeTitleMatch = youtubeTitleRegex.exec(html)
const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html)
const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html)
const youtubeAvatarMatch = youtubeAvatarRegex.exec(html)
if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) {
res.title = decodeURI(youtubeTitleMatch[1])
}
if (youtubeDescriptionMatch && youtubeDescriptionMatch.length >= 1) {
res.description = decodeURI(youtubeDescriptionMatch[1]).replace(
/\\n/g,
'\n',
)
}
if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) {
res.image = youtubeThumbnailMatch[1] + 'default.jpg'
}
if (!res.image && youtubeAvatarMatch && youtubeAvatarMatch.length >= 1) {
res.image = youtubeAvatarMatch[1]
}
return res
}
+3 -1
View File
@@ -1,3 +1,5 @@
export type Gate =
// Keep this alphabetic please.
'debug_show_feedcontext' | 'suggested_feeds_interstitial'
| 'debug_show_feedcontext'
| 'post_feed_lang_window'
| 'suggested_feeds_interstitial'
+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)
}
@@ -174,7 +174,6 @@ export function MessageInputEmbed({
showAvatar
author={post.author}
moderation={moderation}
authorHasWarning={!!post.author.labels?.length}
timestamp={post.indexedAt}
postHref={itemHref}
style={a.flex_0}
+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 = {
+9 -2
View File
@@ -5,7 +5,7 @@ import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {Shadow} from '#/state/cache/types'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
export function ProfileHeaderDisplayName({
@@ -16,12 +16,19 @@ export function ProfileHeaderDisplayName({
moderation: ModerationDecision
}) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<View pointerEvents="none">
<Text
emoji
testID="profileHeaderDisplayName"
style={[t.atoms.text, a.text_4xl, a.self_start, {fontWeight: '600'}]}>
style={[
t.atoms.text,
gtMobile ? a.text_4xl : a.text_3xl,
a.self_start,
{fontWeight: '600'},
]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
@@ -0,0 +1,61 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import Animated, {
Extrapolation,
interpolate,
SharedValue,
useAnimatedStyle,
} from 'react-native-reanimated'
import {isIOS} from '#/platform/detection'
import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
export function GrowableAvatar({
children,
style,
}: {
children: React.ReactNode
style?: StyleProp<ViewStyle>
}) {
const pagerContext = usePagerHeaderContext()
// pagerContext should only be present on iOS, but better safe than sorry
if (!pagerContext || !isIOS) {
return <View style={style}>{children}</View>
}
const {scrollY} = pagerContext
return (
<GrowableAvatarInner scrollY={scrollY} style={style}>
{children}
</GrowableAvatarInner>
)
}
function GrowableAvatarInner({
scrollY,
children,
style,
}: {
scrollY: SharedValue<number>
children: React.ReactNode
style?: StyleProp<ViewStyle>
}) {
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: interpolate(scrollY.value, [-150, 0], [1.2, 1], {
extrapolateRight: Extrapolation.CLAMP,
}),
},
],
}))
return (
<Animated.View
style={[style, {transformOrigin: 'bottom left'}, animatedStyle]}>
{children}
</Animated.View>
)
}
@@ -0,0 +1,212 @@
import React, {useEffect, useState} from 'react'
import {View} from 'react-native'
import {ActivityIndicator} from 'react-native'
import Animated, {
Extrapolation,
interpolate,
runOnJS,
SharedValue,
useAnimatedProps,
useAnimatedReaction,
useAnimatedStyle,
} from 'react-native-reanimated'
import {BlurView} from 'expo-blur'
import {useIsFetching} from '@tanstack/react-query'
import {isIOS} from '#/platform/detection'
import {RQKEY_ROOT as STARTERPACK_RQKEY_ROOT} from '#/state/queries/actor-starter-packs'
import {RQKEY_ROOT as FEED_RQKEY_ROOT} from '#/state/queries/post-feed'
import {RQKEY_ROOT as FEEDGEN_RQKEY_ROOT} from '#/state/queries/profile-feedgens'
import {RQKEY_ROOT as LIST_RQKEY_ROOT} from '#/state/queries/profile-lists'
import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
import {atoms as a} from '#/alf'
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
export function GrowableBanner({
backButton,
children,
}: {
backButton?: React.ReactNode
children: React.ReactNode
}) {
const pagerContext = usePagerHeaderContext()
// pagerContext should only be present on iOS, but better safe than sorry
if (!pagerContext || !isIOS) {
return (
<View style={[a.w_full, a.h_full]}>
{backButton}
{children}
</View>
)
}
const {scrollY} = pagerContext
return (
<GrowableBannerInner scrollY={scrollY} backButton={backButton}>
{children}
</GrowableBannerInner>
)
}
function GrowableBannerInner({
scrollY,
backButton,
children,
}: {
scrollY: SharedValue<number>
backButton?: React.ReactNode
children: React.ReactNode
}) {
const isFetching = useIsProfileFetching()
const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY})
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: interpolate(scrollY.value, [-150, 0], [2, 1], {
extrapolateRight: Extrapolation.CLAMP,
}),
},
],
}))
const animatedBlurViewProps = useAnimatedProps(() => {
return {
intensity: interpolate(
scrollY.value,
[-300, -65, -15],
[50, 40, 0],
Extrapolation.CLAMP,
),
}
})
const animatedSpinnerStyle = useAnimatedStyle(() => {
return {
display: scrollY.value < 0 ? 'flex' : 'none',
opacity: interpolate(
scrollY.value,
[-60, -15],
[1, 0],
Extrapolation.CLAMP,
),
transform: [
{translateY: interpolate(scrollY.value, [-150, 0], [-75, 0])},
{rotate: '90deg'},
],
}
})
const animatedBackButtonStyle = useAnimatedStyle(() => ({
transform: [
{
translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], {
extrapolateRight: Extrapolation.CLAMP,
}),
},
],
}))
return (
<>
<Animated.View
style={[
a.absolute,
{left: 0, right: 0, bottom: 0},
{height: 150},
{transformOrigin: 'bottom'},
animatedStyle,
]}>
{children}
<AnimatedBlurView
style={[a.absolute, a.inset_0]}
tint="dark"
animatedProps={animatedBlurViewProps}
/>
</Animated.View>
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<Animated.View style={[animatedSpinnerStyle]}>
<ActivityIndicator
key={animateSpinner ? 'spin' : 'stop'}
size="large"
color="white"
animating={animateSpinner}
hidesWhenStopped={false}
/>
</Animated.View>
</View>
<Animated.View style={[animatedBackButtonStyle]}>
{backButton}
</Animated.View>
</>
)
}
function useIsProfileFetching() {
// are any of the profile-related queries fetching?
return [
useIsFetching({queryKey: [FEED_RQKEY_ROOT]}),
useIsFetching({queryKey: [FEEDGEN_RQKEY_ROOT]}),
useIsFetching({queryKey: [LIST_RQKEY_ROOT]}),
useIsFetching({queryKey: [STARTERPACK_RQKEY_ROOT]}),
].some(isFetching => isFetching)
}
function useShouldAnimateSpinner({
isFetching,
scrollY,
}: {
isFetching: boolean
scrollY: SharedValue<number>
}) {
const [isOverscrolled, setIsOverscrolled] = useState(false)
// HACK: it reports a scroll pos of 0 for a tick when fetching finishes
// so paper over that by keeping it true for a bit -sfn
const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10)
useAnimatedReaction(
() => scrollY.value < -5,
(value, prevValue) => {
if (value !== prevValue) {
runOnJS(setIsOverscrolled)(value)
}
},
[scrollY],
)
const [isAnimating, setIsAnimating] = useState(isFetching)
if (isFetching && !isAnimating) {
setIsAnimating(true)
}
if (!isFetching && isAnimating && !stickyIsOverscrolled) {
setIsAnimating(false)
}
return isAnimating
}
// stayed true for at least `delay` ms before returning to false
function useStickyToggle(value: boolean, delay: number) {
const [prevValue, setPrevValue] = useState(value)
const [isSticking, setIsSticking] = useState(false)
useEffect(() => {
if (isSticking) {
const timeout = setTimeout(() => setIsSticking(false), delay)
return () => clearTimeout(timeout)
}
}, [isSticking, delay])
if (value !== prevValue) {
setIsSticking(prevValue) // Going true -> false should stick.
setPrevValue(value)
return prevValue ? true : value
}
return isSticking ? true : value
}
@@ -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,
+73 -52
View File
@@ -6,19 +6,21 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {BACK_HITSLOP} from '#/lib/constants'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox'
import {useSession} from '#/state/session'
import {BACK_HITSLOP} from 'lib/constants'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {isIOS} from 'platform/detection'
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {UserBanner} from 'view/com/util/UserBanner'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {UserBanner} from '#/view/com/util/UserBanner'
import {atoms as a, useTheme} from '#/alf'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
import {GrowableAvatar} from './GrowableAvatar'
import {GrowableBanner} from './GrowableBanner'
interface Props {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
@@ -63,20 +65,45 @@ let ProfileHeaderShell = ({
return (
<View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}>
<View pointerEvents={isIOS ? 'auto' : 'none'}>
{isPlaceholderProfile ? (
<LoadingPlaceholder
width="100%"
height={150}
style={{borderRadius: 0}}
/>
) : (
<UserBanner
type={profile.associated?.labeler ? 'labeler' : 'default'}
banner={profile.banner}
moderation={moderation.ui('banner')}
/>
)}
<View
pointerEvents={isIOS ? 'auto' : 'none'}
style={[a.relative, {height: 150}]}>
<GrowableBanner
backButton={
<>
{!isDesktop && !hideBackButton && (
<TouchableWithoutFeedback
testID="profileHeaderBackBtn"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
<View style={styles.backBtnWrapper}>
<FontAwesomeIcon
size={18}
icon="angle-left"
color="white"
/>
</View>
</TouchableWithoutFeedback>
)}
</>
}>
{isPlaceholderProfile ? (
<LoadingPlaceholder
width="100%"
height="100%"
style={{borderRadius: 0}}
/>
) : (
<UserBanner
type={profile.associated?.labeler ? 'labeler' : 'default'}
banner={profile.banner}
moderation={moderation.ui('banner')}
/>
)}
</GrowableBanner>
</View>
{children}
@@ -93,40 +120,29 @@ let ProfileHeaderShell = ({
</View>
)}
{!isDesktop && !hideBackButton && (
<GrowableAvatar style={styles.aviPosition}>
<TouchableWithoutFeedback
testID="profileHeaderBackBtn"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
testID="profileHeaderAviButton"
onPress={onPressAvi}
accessibilityRole="image"
accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
accessibilityHint="">
<View style={styles.backBtnWrapper}>
<FontAwesomeIcon size={18} icon="angle-left" color="white" />
<View
style={[
t.atoms.bg,
{borderColor: t.atoms.bg.backgroundColor},
styles.avi,
profile.associated?.labeler && styles.aviLabeler,
]}>
<UserAvatar
type={profile.associated?.labeler ? 'labeler' : 'user'}
size={90}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</View>
</TouchableWithoutFeedback>
)}
<TouchableWithoutFeedback
testID="profileHeaderAviButton"
onPress={onPressAvi}
accessibilityRole="image"
accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
accessibilityHint="">
<View
style={[
t.atoms.bg,
{borderColor: t.atoms.bg.backgroundColor},
styles.avi,
profile.associated?.labeler && styles.aviLabeler,
]}>
<UserAvatar
type={profile.associated?.labeler ? 'labeler' : 'user'}
size={90}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</View>
</TouchableWithoutFeedback>
</GrowableAvatar>
</View>
)
}
@@ -144,6 +160,9 @@ const styles = StyleSheet.create({
borderRadius: 15,
// @ts-ignore web only
cursor: 'pointer',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
alignItems: 'center',
justifyContent: 'center',
},
backBtn: {
width: 30,
@@ -152,10 +171,12 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
avi: {
aviPosition: {
position: 'absolute',
top: 110,
left: 10,
},
avi: {
width: 94,
height: 94,
borderRadius: 47,
+9 -5
View File
@@ -7,18 +7,22 @@ import {
RichText as RichTextAPI,
} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette'
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {useTheme} from '#/alf'
import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
import {ProfileHeaderStandard} from './ProfileHeaderStandard'
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
const pal = usePalette('default')
const t = useTheme()
return (
<View style={pal.view}>
<View style={t.atoms.bg}>
<LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
<View
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
style={[
t.atoms.bg,
{borderColor: t.atoms.bg.backgroundColor},
styles.avi,
]}>
<LoadingPlaceholder width={90} height={90} style={styles.br45} />
</View>
<View style={styles.content}>
+8 -6
View File
@@ -4,17 +4,18 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {usePalette} from '#/lib/hooks/usePalette'
import {isNative} from '#/platform/detection'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {usePalette} from 'lib/hooks/usePalette'
import {Feed} from '#/view/com/posts/Feed'
import {EmptyState} from '#/view/com/util/EmptyState'
import {ListRef} from '#/view/com/util/List'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {Text} from '#/view/com/util/text/Text'
import {Feed} from 'view/com/posts/Feed'
import {EmptyState} from 'view/com/util/EmptyState'
import {ListRef} from 'view/com/util/List'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {ios} from '#/alf'
import {SectionRef} from './types'
interface FeedSectionProps {
@@ -82,6 +83,7 @@ export const ProfileFeedSection = React.forwardRef<
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
progressViewOffset={ios(0)}
renderEndOfFeed={ProfileEndOfFeed}
ignoreFilterFor={ignoreFilterFor}
initialNumToRender={
@@ -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,
},
})
}
+2 -2
View File
@@ -6,9 +6,9 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {useAgent} from 'state/session'
import {useAgent} from '#/state/session'
const RQKEY_ROOT = 'actor-starter-packs'
export const RQKEY_ROOT = 'actor-starter-packs'
export const RQKEY = (did?: string) => [RQKEY_ROOT, did]
export function useActorStarterPacksQuery({did}: {did?: string}) {
+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})
}
}
},
})
}
+29 -14
View File
@@ -15,24 +15,25 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {AuthorFeedAPI} from '#/lib/api/feed/author'
import {CustomFeedAPI} from '#/lib/api/feed/custom'
import {FollowingFeedAPI} from '#/lib/api/feed/following'
import {HomeFeedAPI} from '#/lib/api/feed/home'
import {LikesFeedAPI} from '#/lib/api/feed/likes'
import {ListFeedAPI} from '#/lib/api/feed/list'
import {MergeFeedAPI} from '#/lib/api/feed/merge'
import {FeedAPI, ReasonFeedSource} from '#/lib/api/feed/types'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner, FeedTunerFn} from '#/lib/api/feed-manip'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {useAgent} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {AuthorFeedAPI} from 'lib/api/feed/author'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {FollowingFeedAPI} from 'lib/api/feed/following'
import {LikesFeedAPI} from 'lib/api/feed/likes'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip'
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners'
import {useModerationOpts} from '../preferences/moderation-opts'
@@ -65,7 +66,7 @@ export interface FeedParams {
type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
const RQKEY_ROOT = 'post-feed'
export const RQKEY_ROOT = 'post-feed'
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
return [RQKEY_ROOT, feedDesc, params || {}]
}
@@ -90,6 +91,7 @@ export interface FeedPostSlice {
feedContext: string | undefined
reason?:
| AppBskyFeedDefs.ReasonRepost
| AppBskyFeedDefs.ReasonPin
| ReasonFeedSource
| {[k: string]: unknown; $type: string}
}
@@ -109,13 +111,19 @@ export interface FeedPage {
fetchedAt: number
}
const PAGE_SIZE = 30
/**
* The minimum number of posts we want in a single "page" of results. Since we
* filter out unwanted content, we may fetch more than this number to ensure
* that we get _at least_ this number.
*/
const MIN_POSTS = 30
export function usePostFeedQuery(
feedDesc: FeedDescriptor,
params?: FeedParams,
opts?: {enabled?: boolean; ignoreFilterFor?: string},
) {
const gate = useGate()
const feedTuners = useFeedTuners(feedDesc)
const moderationOpts = useModerationOpts()
const {data: preferences} = usePreferencesQuery()
@@ -135,6 +143,13 @@ export function usePostFeedQuery(
} | null>(null)
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
/**
* The number of posts to fetch in a single request. Because we filter
* unwanted content, we may over-fetch here to try and fill pages by
* `MIN_POSTS`.
*/
const fetchLimit = gate('post_feed_lang_window') ? 100 : MIN_POSTS
// Make sure this doesn't invalidate unless really needed.
const selectArgs = React.useMemo(
() => ({
@@ -175,7 +190,7 @@ export function usePostFeedQuery(
}
try {
const res = await api.fetch({cursor, limit: PAGE_SIZE})
const res = await api.fetch({cursor, limit: fetchLimit})
/*
* If this is a public view, we need to check if posts fail moderation.
@@ -373,13 +388,13 @@ export function usePostFeedQuery(
// Now track how many items we really want, and fetch more if needed.
if (isLoading || isRefetching) {
// During the initial fetch, we want to get an entire page's worth of items.
wantedItemCount.current = PAGE_SIZE
wantedItemCount.current = MIN_POSTS
} else if (isFetchingNextPage) {
if (itemCount > wantedItemCount.current) {
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
// Some other code must have called fetchNextPage(), for example, from onEndReached.
// Adjust the wantedItemCount to reflect that we want one more full page of items.
wantedItemCount.current = itemCount + PAGE_SIZE
wantedItemCount.current = itemCount + MIN_POSTS
}
} else if (hasNextPage) {
// At this point we're not fetching anymore, so it's time to make a decision.
+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,
+1 -1
View File
@@ -8,7 +8,7 @@ const PAGE_SIZE = 50
type RQPageParam = string | undefined
// TODO refactor invalidate on mutate?
const RQKEY_ROOT = 'profile-feedgens'
export const RQKEY_ROOT = 'profile-feedgens'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileFeedgensQuery(
+1 -1
View File
@@ -7,7 +7,7 @@ import {useModerationOpts} from '../preferences/moderation-opts'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
const RQKEY_ROOT = 'profile-lists'
export const RQKEY_ROOT = 'profile-lists'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
+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'})
}
+12 -12
View File
@@ -9,7 +9,7 @@
@font-face {
font-family: 'Inter-Regular';
src: local('Inter-Regular'),
url(/assets/fonts/inter/Inter-Regular.otf) format('font/otf');
url(/assets/fonts/inter/Inter-Regular.otf) format('opentype');
font-weight: 400;
font-style: normal;
font-display: swap;
@@ -17,7 +17,7 @@
@font-face {
font-family: 'Inter-Italic';
src: local('Inter-Italic'),
url(/assets/fonts/inter/Inter-Italic.otf) format('font/otf');
url(/assets/fonts/inter/Inter-Italic.otf) format('opentype');
font-weight: 400;
font-style: italic;
font-display: swap;
@@ -25,14 +25,14 @@
/*
@font-face {
font-family: "Inter-Medium";
src: local("Inter-Medium"), url(/assets/fonts/inter/Inter-Medium.otf) format("font/otf");
src: local("Inter-Medium"), url(/assets/fonts/inter/Inter-Medium.otf) format("opentype");
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");
src: local("Inter-MediumItalic"), url(/assets/fonts/inter/Inter-MediumItalic.otf) format("opentype");
font-weight: 500;
font-style: italic;
font-display: swap;
@@ -41,7 +41,7 @@
@font-face {
font-family: 'Inter-SemiBold';
src: local('Inter-SemiBold'),
url(/assets/fonts/inter/Inter-SemiBold.otf) format('font/otf');
url(/assets/fonts/inter/Inter-SemiBold.otf) format('opentype');
font-weight: 600;
font-style: normal;
font-display: swap;
@@ -49,7 +49,7 @@
@font-face {
font-family: 'Inter-SemiBoldItalic';
src: local('Inter-SemiBoldItalic'),
url(/assets/fonts/inter/Inter-SemiBoldItalic.otf) format('font/otf');
url(/assets/fonts/inter/Inter-SemiBoldItalic.otf) format('opentype');
font-weight: 600;
font-style: italic;
font-display: swap;
@@ -57,14 +57,14 @@
/*
@font-face {
font-family: "Inter-Bold";
src: local("Inter-Bold"), url(/assets/fonts/inter/Inter-Bold.otf) format("font/otf");
src: local("Inter-Bold"), url(/assets/fonts/inter/Inter-Bold.otf) format("opentype");
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");
src: local("Inter-BoldItalic"), url(/assets/fonts/inter/Inter-BoldItalic.otf) format("opentype");
font-weight: 700;
font-style: italic;
font-display: swap;
@@ -73,7 +73,7 @@
@font-face {
font-family: 'Inter-ExtraBold';
src: local('Inter-ExtraBold'),
url(/assets/fonts/inter/Inter-ExtraBold.otf) format('font/otf');
url(/assets/fonts/inter/Inter-ExtraBold.otf) format('opentype');
font-weight: 800;
font-style: normal;
font-display: swap;
@@ -81,7 +81,7 @@
@font-face {
font-family: 'Inter-ExtraBoldItalic';
src: local('Inter-ExtraBoldItalic'),
url(/assets/fonts/inter/Inter-ExtraBoldItalic.otf) format('font/otf');
url(/assets/fonts/inter/Inter-ExtraBoldItalic.otf) format('opentype');
font-weight: 800;
font-style: italic;
font-display: swap;
@@ -89,14 +89,14 @@
/*
@font-face {
font-family: "Inter-Black";
src: local("Inter-Black"), url(/assets/fonts/inter/Inter-Black.otf) format("font/otf");
src: local("Inter-Black"), url(/assets/fonts/inter/Inter-Black.otf) format("opentype");
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");
src: local("Inter-BlackItalic"), url(/assets/fonts/inter/Inter-BlackItalic.otf) format("opentype");
font-weight: 900;
font-style: italic;
font-display: swap;
+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,
},
})

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