Merge branch 'main' into hailey/use-eas-version-code

This commit is contained in:
Hailey
2024-02-15 13:32:10 -08:00
committed by GitHub
84 changed files with 12103 additions and 6270 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ Get the app itself:
- **Web: [bsky.app](https://bsky.app)** - **Web: [bsky.app](https://bsky.app)**
- **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)** - **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)**
- **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)** - **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app)**
## Development Resources ## Development Resources
@@ -21,7 +21,7 @@ The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentrali
- [Overview and Guides](https://atproto.com/guides/overview) - [Overview and Guides](https://atproto.com/guides/overview)
- [Github Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions - [Github Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions
- [Protocol Specifications](https://atproto.com/specs/atp) - [Protocol Specifications](https://atproto.com/specs/atp)
- [Blogpost on self-authenticating data structures](https://blueskyweb.xyz/blog/3-6-2022-a-self-authenticating-social-protocol) - [Blogpost on self-authenticating data structures](https://bsky.social/about/blog/3-6-2022-a-self-authenticating-social-protocol)
The Bluesky Social application encompasses a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`. The Bluesky Social application encompasses a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`.
+3 -8
View File
@@ -18,9 +18,10 @@ describe('Create account', () => {
await device.takeScreenshot('1- opened create account screen') await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap() await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server') await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL') await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('example@test.com') await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2') await element(by.id('passwordInput')).typeText('hunter2')
@@ -33,12 +34,6 @@ describe('Create account', () => {
await element(by.id('nextBtn')).tap() await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible() await expect(element(by.id('onboardingInterests'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
}) })
}) })
+4 -4
View File
@@ -36,7 +36,7 @@ describe('Home screen', () => {
const carlaPosts = by.id('feedItem-by-carla.test') const carlaPosts = by.id('feedItem-by-carla.test')
await expect( await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0), element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0), element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
@@ -44,14 +44,14 @@ describe('Home screen', () => {
await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0), element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can repost posts', async () => { it('Can repost posts', async () => {
const carlaPosts = by.id('feedItem-by-carla.test') const carlaPosts = by.id('feedItem-by-carla.test')
await expect( await expect(
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0), element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('repostBtn').withAncestor(carlaPosts)).atIndex(0).tap() await element(by.id('repostBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible() await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap() await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
@@ -65,7 +65,7 @@ describe('Home screen', () => {
await expect(element(by.id('repostModal'))).not.toBeVisible() await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect( await expect(
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0), element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can report posts', async () => { it('Can report posts', async () => {
+3 -8
View File
@@ -28,9 +28,10 @@ describe('invite-codes', () => {
await device.takeScreenshot('1- opened create account screen') await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap() await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server') await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL') await device.takeScreenshot('3- input test server URL')
await element(by.id('inviteCodeInput')).typeText(inviteCode) await element(by.id('inviteCodeInput')).typeText(inviteCode)
await element(by.id('emailInput')).typeText('example@test.com') await element(by.id('emailInput')).typeText('example@test.com')
@@ -40,12 +41,6 @@ describe('invite-codes', () => {
await element(by.id('handleInput')).typeText('e2e-test') await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('4- entered handle') await device.takeScreenshot('4- entered handle')
await element(by.id('nextBtn')).tap() await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible() await expect(element(by.id('onboardingInterests'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
}) })
}) })
@@ -28,16 +28,17 @@ describe('invite-codes', () => {
await device.takeScreenshot('1- opened create account screen') await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap() await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server') await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL') await device.takeScreenshot('3- input test server URL')
await element(by.id('inviteCodeInput')).typeText(inviteCode) await element(by.id('inviteCodeInput')).typeText(inviteCode)
await element(by.id('emailInput')).typeText('example@test.com') await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2') await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details') await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap() await element(by.id('nextBtn')).tap()
await element(by.id('phoneInput')).typeText('5558675309') await element(by.id('phoneInput')).typeText('2345551234')
await element(by.id('requestCodeBtn')).tap() await element(by.id('requestCodeBtn')).tap()
await device.takeScreenshot('5- requested code') await device.takeScreenshot('5- requested code')
await element(by.id('codeInput')).typeText('000000') await element(by.id('codeInput')).typeText('000000')
@@ -46,12 +47,6 @@ describe('invite-codes', () => {
await element(by.id('handleInput')).typeText('e2e-test') await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('7- entered handle') await device.takeScreenshot('7- entered handle')
await element(by.id('nextBtn')).tap() await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible() await expect(element(by.id('onboardingInterests'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
}) })
}) })
+33 -32
View File
@@ -2,7 +2,7 @@
import {describe, beforeAll, it} from '@jest/globals' import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox' import {expect} from 'detox'
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util' import {openApp, loginAsAlice, loginAsBob, createServer} from '../util'
describe('Mod lists', () => { describe('Mod lists', () => {
beforeAll(async () => { beforeAll(async () => {
@@ -62,38 +62,39 @@ describe('Mod lists', () => {
.withTimeout(5000) .withTimeout(5000)
}) })
it('Set avi via the edit modlist modal', async () => { // DISABLED e2e environment is real finicky about avatar uploads -prf
await expect(element(by.id('userAvatarFallback'))).toExist() // it('Set avi via the edit modlist modal', async () => {
await element(by.id('headerDropdownBtn')).tap() // await expect(element(by.id('userAvatarFallback'))).toExist()
await element(by.text('Edit list details')).tap() // await element(by.id('headerDropdownBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible() // await element(by.text('Edit list details')).tap()
await element(by.id('changeAvatarBtn')).tap() // await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.text('Library')).tap() // await element(by.id('changeAvatarBtn')).tap()
await sleep(3e3) // await element(by.text('Library')).tap()
await element(by.id('saveBtn')).tap() // await sleep(3e3)
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() // await element(by.id('saveBtn')).tap()
await expect(element(by.id('userAvatarImage'))).toExist() // await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
// have to wait for the toast to clear // await expect(element(by.id('userAvatarImage'))).toExist()
await waitFor(element(by.id('headerDropdownBtn'))) // // have to wait for the toast to clear
.toBeVisible() // await waitFor(element(by.id('headerDropdownBtn')))
.withTimeout(5000) // .toBeVisible()
}) // .withTimeout(5000)
// })
it('Remove avi via the edit modlist modal', async () => { // it('Remove avi via the edit modlist modal', async () => {
await expect(element(by.id('userAvatarImage'))).toExist() // await expect(element(by.id('userAvatarImage'))).toExist()
await element(by.id('headerDropdownBtn')).tap() // await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit list details')).tap() // await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible() // await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap() // await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Remove')).tap() // await element(by.text('Remove')).tap()
await element(by.id('saveBtn')).tap() // await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() // await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('userAvatarFallback'))).toExist() // await expect(element(by.id('userAvatarFallback'))).toExist()
// have to wait for the toast to clear // // have to wait for the toast to clear
await waitFor(element(by.id('headerDropdownBtn'))) // await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible() // .toBeVisible()
.withTimeout(5000) // .withTimeout(5000)
}) // })
it('Delete the modlist', async () => { it('Delete the modlist', async () => {
await element(by.id('headerDropdownBtn')).tap() await element(by.id('headerDropdownBtn')).tap()
+4 -4
View File
@@ -147,7 +147,7 @@ describe('Profile screen', () => {
const posts = by.id('feedItem-by-bob.test') const posts = by.id('feedItem-by-bob.test')
await expect( await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0), element(by.id('likeCount').withAncestor(posts)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0), element(by.id('likeCount').withAncestor(posts)).atIndex(0),
@@ -155,14 +155,14 @@ describe('Profile screen', () => {
await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0), element(by.id('likeCount').withAncestor(posts)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can repost posts', async () => { it('Can repost posts', async () => {
const posts = by.id('feedItem-by-bob.test') const posts = by.id('feedItem-by-bob.test')
await expect( await expect(
element(by.id('repostCount').withAncestor(posts)).atIndex(0), element(by.id('repostCount').withAncestor(posts)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('repostBtn').withAncestor(posts)).atIndex(0).tap() await element(by.id('repostBtn').withAncestor(posts)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible() await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap() await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
@@ -176,7 +176,7 @@ describe('Profile screen', () => {
await expect(element(by.id('repostModal'))).not.toBeVisible() await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect( await expect(
element(by.id('repostCount').withAncestor(posts)).atIndex(0), element(by.id('repostCount').withAncestor(posts)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can report posts', async () => { it('Can report posts', async () => {
+1
View File
@@ -28,6 +28,7 @@ describe('Self-labeling', () => {
await element(by.id('composerPublishBtn')).tap() await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible() await expect(element(by.id('composeFAB'))).toBeVisible()
const posts = by.id('feedItem-by-alice.test') const posts = by.id('feedItem-by-alice.test')
await element(by.id('e2eRefreshHome')).tap()
await expect( await expect(
element(by.id('contentHider-embed').withAncestor(posts)).atIndex(0), element(by.id('contentHider-embed').withAncestor(posts)).atIndex(0),
).toExist() ).toExist()
+5 -9
View File
@@ -18,9 +18,10 @@ describe('Create account', () => {
await device.takeScreenshot('1- opened create account screen') await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap() await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server') await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL') await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('text-verification@test.com') await element(by.id('emailInput')).typeText('text-verification@test.com')
await element(by.id('passwordInput')).typeText('hunter2') await element(by.id('passwordInput')).typeText('hunter2')
@@ -40,13 +41,7 @@ describe('Create account', () => {
await element(by.id('nextBtn')).tap() await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible() await expect(element(by.id('onboardingInterests'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
}) })
it('failed text verification correctly goes back to the code input screen', async () => { it('failed text verification correctly goes back to the code input screen', async () => {
@@ -57,9 +52,10 @@ describe('Create account', () => {
await device.takeScreenshot('1- opened create account screen') await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap() await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server') await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL') await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('text-verification2@test.com') await element(by.id('emailInput')).typeText('text-verification2@test.com')
await element(by.id('passwordInput')).typeText('hunter2') await element(by.id('passwordInput')).typeText('hunter2')
+4 -4
View File
@@ -49,7 +49,7 @@ describe('Thread screen', () => {
const post = by.id('postThreadItem-by-carla.test') const post = by.id('postThreadItem-by-carla.test')
await expect( await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0), element(by.id('likeCount').withAncestor(post)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0), element(by.id('likeCount').withAncestor(post)).atIndex(0),
@@ -57,7 +57,7 @@ describe('Thread screen', () => {
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0), element(by.id('likeCount').withAncestor(post)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can repost the root post', async () => { it('Can repost the root post', async () => {
@@ -85,7 +85,7 @@ describe('Thread screen', () => {
const post = by.id('postThreadItem-by-carla.test') const post = by.id('postThreadItem-by-carla.test')
await expect( await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0), element(by.id('repostCount').withAncestor(post)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap() await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible() await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap() await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
@@ -99,7 +99,7 @@ describe('Thread screen', () => {
await expect(element(by.id('repostModal'))).not.toBeVisible() await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect( await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0), element(by.id('repostCount').withAncestor(post)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can report the root post', async () => { it('Can report the root post', async () => {
+2 -1
View File
@@ -56,9 +56,10 @@ export async function login(
if (takeScreenshots) { if (takeScreenshots) {
await device.takeScreenshot('2- opened service selector') await device.takeScreenshot('2- opened service selector')
} }
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
if (takeScreenshots) { if (takeScreenshots) {
await device.takeScreenshot('3- input custom service') await device.takeScreenshot('3- input custom service')
} }
+6 -1
View File
@@ -4,6 +4,11 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
<meta name="referrer" content="origin-when-cross-origin"> <meta name="referrer" content="origin-when-cross-origin">
<!--
Preconnect to essential domains
-->
<link rel="preconnect" href="https://bsky.social">
<link rel="preconnect" href="https://bsky.network">
<title>{%- block head_title -%}Bluesky{%- endblock -%}</title> <title>{%- block head_title -%}Bluesky{%- endblock -%}</title>
<!-- Hello Humans! API docs at https://atproto.com --> <!-- Hello Humans! API docs at https://atproto.com -->
@@ -219,7 +224,7 @@
<noscript> <noscript>
<h1 lang="en">JavaScript Required</h1> <h1 lang="en">JavaScript Required</h1>
<p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is. <p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
<p lang="en">Learn more about Bluesky at <a href="https://blueskyweb.xyz">blueskyweb.xyz</a> and <a href="https://atproto.com">atproto.com</a>. <p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
{% block noscript_extra %}{% endblock %} {% block noscript_extra %}{% endblock %}
</noscript> </noscript>
{% endblock -%} {% endblock -%}
+11
View File
@@ -26,11 +26,22 @@
- Start the dev servers - Start the dev servers
- `git clone git@github.com:bluesky-social/atproto.git` - `git clone git@github.com:bluesky-social/atproto.git`
- `cd atproto` - `cd atproto`
- `brew install pnpm`
- `brew install jq`
- `pnpm i` - `pnpm i`
- `pnpm build` - `pnpm build`
- Start the docker daemon (on MacOS this entails starting the Docker Desktop app)
- Launch a Postgres database on port 5432
- `cd packages/dev-env && pnpm start` - `cd packages/dev-env && pnpm start`
- Run the dev app - Run the dev app
- iOS: `yarn ios` - iOS: `yarn ios`
- Xcode must be installed for this to run.
- A simulator must be preconfigured in Xcode settings.
- if no iOS versions are available, install the iOS runtime at `Xcode > Settings > Platforms`.
- In addition, ensure Xcode Command Line Tools are installed using `xcode-select --install`.
- Pods must be installed:
- From the project directory root: `cd ios && pod install`.
- Expo will require you to configure Xcode Signing. Follow the linked instructions. Error messages in Xcode related to the signing process can be safely ignored when installing on the iOS Simulator; Expo merely requires the profile to exist in order to install the app on the Simulator.
- Android: `yarn android` - Android: `yarn android`
- Web: `yarn web` - Web: `yarn web`
- If you are cloning or forking this repo as an open-source developer, please check the tips below as well - If you are cloning or forking this repo as an open-source developer, please check the tips below as well
+1
View File
@@ -13,6 +13,7 @@ module.exports = {
'uk', 'uk',
'ca', 'ca',
'zh-CN', 'zh-CN',
'it',
], ],
catalogs: [ catalogs: [
{ {
+1
View File
@@ -52,6 +52,7 @@ export type ButtonProps = React.PropsWithChildren<
Pick<PressableProps, 'disabled' | 'onPress'> & Pick<PressableProps, 'disabled' | 'onPress'> &
AccessibilityProps & AccessibilityProps &
VariantProps & { VariantProps & {
testID?: string
label: string label: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
} }
+20 -8
View File
@@ -34,6 +34,7 @@ export function Outer({
const sheet = React.useRef<BottomSheet>(null) const sheet = React.useRef<BottomSheet>(null)
const sheetOptions = nativeOptions?.sheet || {} const sheetOptions = nativeOptions?.sheet || {}
const hasSnapPoints = !!sheetOptions.snapPoints const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets()
const open = React.useCallback<DialogControlProps['open']>((i = 0) => { const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
sheet.current?.snapToIndex(i) sheet.current?.snapToIndex(i)
@@ -41,8 +42,7 @@ export function Outer({
const close = React.useCallback(() => { const close = React.useCallback(() => {
sheet.current?.close() sheet.current?.close()
onClose?.() }, [])
}, [onClose])
useImperativeHandle( useImperativeHandle(
control.ref, control.ref,
@@ -53,6 +53,15 @@ export function Outer({
[open, close], [open, close],
) )
const onChange = React.useCallback(
(index: number) => {
if (index === -1) {
onClose?.()
}
},
[onClose],
)
const context = React.useMemo(() => ({close}), [close]) const context = React.useMemo(() => ({close}), [close])
return ( return (
@@ -63,6 +72,7 @@ export function Outer({
keyboardBehavior="interactive" keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize" android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions} {...sheetOptions}
ref={sheet} ref={sheet}
index={-1} index={-1}
@@ -77,7 +87,7 @@ export function Outer({
)} )}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}} handleStyle={{display: 'none'}}
onClose={onClose}> onChange={onChange}>
<Context.Provider value={context}> <Context.Provider value={context}>
<View <View
style={[ style={[
@@ -105,8 +115,8 @@ export function Inner(props: DialogInnerProps) {
<BottomSheetView <BottomSheetView
style={[ style={[
a.p_lg, a.p_lg,
a.pt_3xl,
{ {
paddingTop: 40,
borderTopLeftRadius: 40, borderTopLeftRadius: 40,
borderTopRightRadius: 40, borderTopRightRadius: 40,
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom, paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
@@ -121,11 +131,13 @@ export function ScrollableInner(props: DialogInnerProps) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
return ( return (
<BottomSheetScrollView <BottomSheetScrollView
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
style={[ style={[
a.flex_1, // main diff is this a.flex_1, // main diff is this
a.p_lg, a.p_xl,
a.pt_3xl,
{ {
paddingTop: 40,
borderTopLeftRadius: 40, borderTopLeftRadius: 40,
borderTopRightRadius: 40, borderTopRightRadius: 40,
}, },
@@ -139,11 +151,10 @@ export function ScrollableInner(props: DialogInnerProps) {
export function Handle() { export function Handle() {
const t = useTheme() const t = useTheme()
return ( return (
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
<View <View
style={[ style={[
a.absolute,
a.rounded_sm, a.rounded_sm,
a.z_10,
{ {
top: a.pt_lg.paddingTop, top: a.pt_lg.paddingTop,
width: 35, width: 35,
@@ -154,6 +165,7 @@ export function Handle() {
}, },
]} ]}
/> />
</View>
) )
} }
@@ -30,8 +30,8 @@ export function IconCircle({
a.align_center, a.align_center,
a.rounded_full, a.rounded_full,
{ {
width: 64, width: size === 'lg' ? 52 : 64,
height: 64, height: size === 'lg' ? 52 : 64,
backgroundColor: backgroundColor:
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950, t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
}, },
+12 -1
View File
@@ -148,6 +148,10 @@ export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
* Label for a11y. Defaults to the href. * Label for a11y. Defaults to the href.
*/ */
label?: string label?: string
/**
* Web-only attribute. Sets `download` attr on web.
*/
download?: string
} }
/** /**
@@ -158,7 +162,13 @@ export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
* Intended to behave as a web anchor tag. For more complex routing, use a * Intended to behave as a web anchor tag. For more complex routing, use a
* `Button`. * `Button`.
*/ */
export function Link({children, to, action = 'push', ...rest}: LinkProps) { export function Link({
children,
to,
action = 'push',
download,
...rest
}: LinkProps) {
const {href, isExternal, onPress} = useLink({ const {href, isExternal, onPress} = useLink({
to, to,
displayText: typeof children === 'string' ? children : '', displayText: typeof children === 'string' ? children : '',
@@ -177,6 +187,7 @@ export function Link({children, to, action = 'push', ...rest}: LinkProps) {
hrefAttrs: { hrefAttrs: {
target: isExternal ? 'blank' : undefined, target: isExternal ? 'blank' : undefined,
rel: isExternal ? 'noopener noreferrer' : undefined, rel: isExternal ? 'noopener noreferrer' : undefined,
download,
}, },
dataSet: { dataSet: {
// default to no underline, apply this ourselves // default to no underline, apply this ourselves
+20 -36
View File
@@ -5,13 +5,11 @@ import {
TextInputProps, TextInputProps,
TextStyle, TextStyle,
ViewStyle, ViewStyle,
Pressable,
StyleSheet, StyleSheet,
AccessibilityProps, AccessibilityProps,
} from 'react-native' } from 'react-native'
import {HITSLOP_20} from 'lib/constants' import {HITSLOP_20} from 'lib/constants'
import {isWeb} from '#/platform/detection'
import {useTheme, atoms as a, web, tokens, android} from '#/alf' import {useTheme, atoms as a, web, tokens, android} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
@@ -41,7 +39,6 @@ export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
export function Root({children, isInvalid = false}: RootProps) { export function Root({children, isInvalid = false}: RootProps) {
const inputRef = React.useRef<TextInput>(null) const inputRef = React.useRef<TextInput>(null)
const rootRef = React.useRef<View>(null)
const { const {
state: hovered, state: hovered,
onIn: onHoverIn, onIn: onHoverIn,
@@ -72,35 +69,17 @@ export function Root({children, isInvalid = false}: RootProps) {
], ],
) )
React.useLayoutEffect(() => {
const root = rootRef.current
if (!root || !isWeb) return
// @ts-ignore web only
root.tabIndex = -1
}, [])
return ( return (
<Context.Provider value={context}> <Context.Provider value={context}>
<Pressable <View
accessibilityRole="button" style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
ref={rootRef} {...web({
role="none" onClick: () => inputRef.current?.focus(),
style={[ onMouseOver: onHoverIn,
a.flex_row, onMouseOut: onHoverOut,
a.align_center, })}>
a.relative,
a.w_full,
a.px_md,
{
paddingVertical: 14,
},
]}
// onPressIn/out don't work on android web
onPress={() => inputRef.current?.focus()}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}>
{children} {children}
</Pressable> </View>
</Context.Provider> </Context.Provider>
) )
} }
@@ -187,7 +166,6 @@ export function createInput(Component: typeof TextInput) {
<Component <Component
accessibilityHint={undefined} accessibilityHint={undefined}
{...rest} {...rest}
aria-label={label}
accessibilityLabel={label} accessibilityLabel={label}
ref={ctx.inputRef} ref={ctx.inputRef}
value={value} value={value}
@@ -204,14 +182,17 @@ export function createInput(Component: typeof TextInput) {
a.text_md, a.text_md,
t.atoms.text, t.atoms.text,
a.px_xs, a.px_xs,
android({
paddingBottom: 2,
}),
{ {
// paddingVertical doesn't work w/multiline - esb
paddingTop: 14,
paddingBottom: 14,
lineHeight: a.text_md.fontSize * 1.1875, lineHeight: a.text_md.fontSize * 1.1875,
textAlignVertical: rest.multiline ? 'top' : undefined, textAlignVertical: rest.multiline ? 'top' : undefined,
minHeight: rest.multiline ? 60 : undefined, minHeight: rest.multiline ? 80 : undefined,
}, },
android({
paddingBottom: 16,
}),
]} ]}
/> />
@@ -238,10 +219,14 @@ export function createInput(Component: typeof TextInput) {
export const Input = createInput(TextInput) export const Input = createInput(TextInput)
export function Label({children}: React.PropsWithChildren<{}>) { export function Label({
nativeID,
children,
}: React.PropsWithChildren<{nativeID?: string}>) {
const t = useTheme() const t = useTheme()
return ( return (
<Text <Text
nativeID={nativeID}
style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium, a.mb_sm]}> style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium, a.mb_sm]}>
{children} {children}
</Text> </Text>
@@ -309,7 +294,6 @@ export function Suffix({
const ctx = React.useContext(Context) const ctx = React.useContext(Context)
return ( return (
<Text <Text
aria-label={label}
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
style={[ style={[
+1 -1
View File
@@ -8,7 +8,7 @@ import * as Toggle from '#/components/forms/Toggle'
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> & export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
AccessibilityProps & AccessibilityProps &
React.PropsWithChildren<{}> React.PropsWithChildren<{testID?: string}>
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & { export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
multiple?: boolean multiple?: boolean
-60
View File
@@ -1,60 +0,0 @@
/**
* APP-700
*
* This is a temporary debug setting we're running on the Web build to
* help the protocol team test some changes.
*
* It should be removed in ~2 weeks. It should only be used on the Web
* version of the app.
*/
import {useState, useCallback, useEffect} from 'react'
import {BskyAgent} from '@atproto/api'
import * as Storage from 'lib/storage'
export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] {
const [enabled, setEnabled] = useState<boolean>(false)
useEffect(() => {
async function check() {
if (await isEnabled()) {
setEnabled(true)
}
}
check()
}, [])
const toggle = useCallback(() => {
if (!enabled) {
Storage.saveString('set-header-x-appview-proxy', 'yes')
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
setEnabled(true)
} else {
Storage.remove('set-header-x-appview-proxy')
agent.api.xrpc.unsetHeader('x-appview-proxy')
setEnabled(false)
}
}, [setEnabled, enabled, agent])
return [enabled, toggle]
}
export function setDebugHeader(agent: BskyAgent, enabled: boolean) {
if (enabled) {
Storage.saveString('set-header-x-appview-proxy', 'yes')
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
} else {
Storage.remove('set-header-x-appview-proxy')
agent.api.xrpc.unsetHeader('x-appview-proxy')
}
}
export async function applyDebugHeader(agent: BskyAgent) {
if (await isEnabled()) {
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
}
}
async function isEnabled() {
return (await Storage.loadString('set-header-x-appview-proxy')) === 'yes'
}
+2 -1
View File
@@ -5,12 +5,13 @@ import {compressIfNeeded} from './manip'
let _imageCounter = 0 let _imageCounter = 0
async function getFile() { async function getFile() {
const files = await RNFS.readDir( let files = await RNFS.readDir(
RNFS.LibraryDirectoryPath.split('/') RNFS.LibraryDirectoryPath.split('/')
.slice(0, -5) .slice(0, -5)
.concat(['Media', 'DCIM', '100APPLE']) .concat(['Media', 'DCIM', '100APPLE'])
.join('/'), .join('/'),
) )
files = files.filter(file => file.path.endsWith('.JPG'))
const file = files[_imageCounter++ % files.length] const file = files[_imageCounter++ % files.length]
return await compressIfNeeded({ return await compressIfNeeded({
path: file.path, path: file.path,
+2
View File
@@ -140,6 +140,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.ca return AppLanguage.ca
case 'zh-CN': case 'zh-CN':
return AppLanguage.zh_CN return AppLanguage.zh_CN
case 'it':
return AppLanguage.it
default: default:
continue continue
} }
+6
View File
@@ -14,6 +14,7 @@ import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
import {messages as messagesUk} from '#/locale/locales/uk/messages' import {messages as messagesUk} from '#/locale/locales/uk/messages'
import {messages as messagesCa} from '#/locale/locales/ca/messages' import {messages as messagesCa} from '#/locale/locales/ca/messages'
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages' import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
import {messages as messagesIt} from '#/locale/locales/it/messages'
import {sanitizeAppLanguageSetting} from '#/locale/helpers' import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages' import {AppLanguage} from '#/locale/languages'
@@ -65,6 +66,11 @@ export async function dynamicActivate(locale: AppLanguage) {
} }
case AppLanguage.zh_CN: { case AppLanguage.zh_CN: {
i18n.loadAndActivate({locale, messages: messagesZh_CN}) i18n.loadAndActivate({locale, messages: messagesZh_CN})
break
}
case AppLanguage.it: {
i18n.loadAndActivate({locale, messages: messagesIt})
break
} }
default: { default: {
i18n.loadAndActivate({locale, messages: messagesEn}) i18n.loadAndActivate({locale, messages: messagesEn})
+4
View File
@@ -56,6 +56,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/zh-CN/messages`) mod = await import(`./locales/zh-CN/messages`)
break break
} }
case AppLanguage.it: {
mod = await import(`./locales/it/messages`)
break
}
default: { default: {
mod = await import(`./locales/en/messages`) mod = await import(`./locales/en/messages`)
break break
+2
View File
@@ -17,6 +17,7 @@ export enum AppLanguage {
uk = 'uk', uk = 'uk',
ca = 'ca', ca = 'ca',
zh_CN = 'zh-CN', zh_CN = 'zh-CN',
it = 'it',
} }
interface AppLanguageConfig { interface AppLanguageConfig {
@@ -37,6 +38,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.uk, name: 'Українська – Ukrainian'}, {code2: AppLanguage.uk, name: 'Українська – Ukrainian'},
{code2: AppLanguage.ca, name: 'Català – Catalan'}, {code2: AppLanguage.ca, name: 'Català – Catalan'},
{code2: AppLanguage.zh_CN, name: '简体中文(中国) – Chinese (Simplified)'}, {code2: AppLanguage.zh_CN, name: '简体中文(中国) – Chinese (Simplified)'},
{code2: AppLanguage.it, name: 'Italiano - Italian'},
] ]
export const LANGUAGES: Language[] = [ export const LANGUAGES: Language[] = [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -17,7 +17,7 @@ import {
flatten, flatten,
TextStyleProp, TextStyleProp,
} from '#/alf' } from '#/alf'
import {H2, P, leading} from '#/components/Typography' import {P, leading, Text} from '#/components/Typography'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {ScrollView} from '#/view/com/util/Views' import {ScrollView} from '#/view/com/util/Views'
@@ -209,16 +209,18 @@ export function Title({
style, style,
}: React.PropsWithChildren<TextStyleProp>) { }: React.PropsWithChildren<TextStyleProp>) {
return ( return (
<H2 <Text
style={[ style={[
a.pb_sm, a.pb_sm,
a.text_4xl,
a.font_bold,
{ {
lineHeight: leading(a.text_4xl, a.leading_tight), lineHeight: leading(a.text_4xl, a.leading_tight),
}, },
flatten(style), flatten(style),
]}> ]}>
{children} {children}
</H2> </Text>
) )
} }
@@ -20,7 +20,7 @@ import {
OnboardingControls, OnboardingControls,
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
export type FeedConfig = { export type FeedConfig = {
default: boolean default: boolean
+1 -1
View File
@@ -23,7 +23,7 @@ import {
Description, Description,
OnboardingControls, OnboardingControls,
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
import { import {
bulkWriteFollows, bulkWriteFollows,
sortPrimaryAlgorithmFeeds, sortPrimaryAlgorithmFeeds,
+1 -1
View File
@@ -22,7 +22,7 @@ import {
usePreferencesQuery, usePreferencesQuery,
useSetFeedViewPreferencesMutation, useSetFeedViewPreferencesMutation,
} from 'state/queries/preferences' } from 'state/queries/preferences'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
export function StepFollowingFeed() { export function StepFollowingFeed() {
const {_} = useLingui() const {_} = useLingui()
@@ -26,7 +26,7 @@ import {
OnboardingControls, OnboardingControls,
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton' import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
export function StepInterests() { export function StepInterests() {
const {_} = useLingui() const {_} = useLingui()
@@ -139,7 +139,7 @@ export function StepInterests() {
) )
return ( return (
<View style={[a.align_start]}> <View style={[a.align_start]} testID="onboardingInterests">
<IconCircle <IconCircle
icon={isError ? EmojiSad : Hashtag} icon={isError ? EmojiSad : Hashtag}
style={[ style={[
@@ -26,7 +26,7 @@ import {
import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption' import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref' import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
import {Context} from '#/screens/Onboarding/state' import {Context} from '#/screens/Onboarding/state'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
function AnimatedDivider() { function AnimatedDivider() {
return ( return (
@@ -27,7 +27,7 @@ import {
SuggestedAccountCardPlaceholder, SuggestedAccountCardPlaceholder,
} from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard' } from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util' import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
export function Inner({ export function Inner({
profiles, profiles,
@@ -175,7 +175,11 @@ export function StepSuggestedAccounts() {
)} )}
onPress={handleContinue}> onPress={handleContinue}>
<ButtonText> <ButtonText>
{dids.length === 20 ? (
<Trans>Follow All</Trans> <Trans>Follow All</Trans>
) : (
<Trans>Follow</Trans>
)}
</ButtonText> </ButtonText>
<ButtonIcon icon={saving ? Loader : Plus} position="right" /> <ButtonIcon icon={saving ? Loader : Plus} position="right" />
</Button> </Button>
+1 -1
View File
@@ -21,7 +21,7 @@ import {
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util' import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
export function StepTopicalFeeds() { export function StepTopicalFeeds() {
const {_} = useLingui() const {_} = useLingui()
-7
View File
@@ -26,12 +26,6 @@ export interface EditProfileModal {
onUpdate?: () => void onUpdate?: () => void
} }
export interface ServerInputModal {
name: 'server-input'
initialService: string
onSelect: (url: string) => void
}
export interface ModerationDetailsModal { export interface ModerationDetailsModal {
name: 'moderation-details' name: 'moderation-details'
context: 'account' | 'content' context: 'account' | 'content'
@@ -222,7 +216,6 @@ export type Modal =
| AltTextImageModal | AltTextImageModal
| CropImageModal | CropImageModal
| EditImageModal | EditImageModal
| ServerInputModal
| RepostModal | RepostModal
| SelfLabelModal | SelfLabelModal
| ThreadgateModal | ThreadgateModal
+146
View File
@@ -0,0 +1,146 @@
import {Image as RNImage} from 'react-native-image-crop-picker'
import {makeAutoObservable} from 'mobx'
import {POST_IMG_MAX} from 'lib/constants'
import {ActionCrop} from 'expo-image-manipulator'
import {Position} from 'react-avatar-editor'
import {Dimensions} from 'lib/media/types'
export interface ImageManipulationAttributes {
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
rotate?: number
scale?: number
position?: Position
flipHorizontal?: boolean
flipVertical?: boolean
}
export class ImageModel implements Omit<RNImage, 'size'> {
path: string
mime = 'image/jpeg'
width: number
height: number
altText = ''
cropped?: RNImage = undefined
compressed?: RNImage = undefined
// Web manipulation
prev?: RNImage
attributes: ImageManipulationAttributes = {
aspectRatio: 'None',
scale: 1,
flipHorizontal: false,
flipVertical: false,
rotate: 0,
}
prevAttributes: ImageManipulationAttributes = {}
constructor(image: Omit<RNImage, 'size'>) {
makeAutoObservable(this)
this.path = image.path
this.width = image.width
this.height = image.height
}
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
this.attributes.aspectRatio = aspectRatio
}
setRotate(degrees: number) {
this.attributes.rotate = degrees
this.manipulate({})
}
flipVertical() {
this.attributes.flipVertical = !this.attributes.flipVertical
this.manipulate({})
}
flipHorizontal() {
this.attributes.flipHorizontal = !this.attributes.flipHorizontal
this.manipulate({})
}
get ratioMultipliers() {
return {
'4:3': 4 / 3,
'1:1': 1,
'3:4': 3 / 4,
None: this.width / this.height,
}
}
getUploadDimensions(
dimensions: Dimensions,
maxDimensions: Dimensions = POST_IMG_MAX,
as: ImageManipulationAttributes['aspectRatio'] = 'None',
) {
const {width, height} = dimensions
const {width: maxWidth, height: maxHeight} = maxDimensions
return width < maxWidth && height < maxHeight
? {
width,
height,
}
: this.getResizedDimensions(as, POST_IMG_MAX.width)
}
getResizedDimensions(
as: ImageManipulationAttributes['aspectRatio'] = 'None',
maxSide: number,
) {
const ratioMultiplier = this.ratioMultipliers[as]
if (ratioMultiplier === 1) {
return {
height: maxSide,
width: maxSide,
}
}
if (ratioMultiplier < 1) {
return {
width: maxSide * ratioMultiplier,
height: maxSide,
}
}
return {
width: maxSide,
height: maxSide / ratioMultiplier,
}
}
setAltText(altText: string) {
this.altText = altText.trim()
}
// Only compress prior to upload
async compress() {
// do nothing
}
// Mobile
async crop() {
// do nothing
}
// Web manipulation
async manipulate(
_attributes: {
crop?: ActionCrop['crop']
} & ImageManipulationAttributes,
) {
// do nothing
}
resetCropped() {
this.manipulate({})
}
previous() {
this.cropped = this.prev
this.attributes = this.prevAttributes
}
}
+1
View File
@@ -112,6 +112,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
hiddenPosts: defaults.hiddenPosts, hiddenPosts: defaults.hiddenPosts,
externalEmbeds: defaults.externalEmbeds, externalEmbeds: defaults.externalEmbeds,
lastSelectedHomeFeed: defaults.lastSelectedHomeFeed, lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
pdsAddressHistory: defaults.pdsAddressHistory,
} }
} }
+2
View File
@@ -57,6 +57,7 @@ export const schema = z.object({
hiddenPosts: z.array(z.string()).optional(), // should move to server hiddenPosts: z.array(z.string()).optional(), // should move to server
useInAppBrowser: z.boolean().optional(), useInAppBrowser: z.boolean().optional(),
lastSelectedHomeFeed: z.string().optional(), lastSelectedHomeFeed: z.string().optional(),
pdsAddressHistory: z.array(z.string()).optional(),
}) })
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
@@ -91,4 +92,5 @@ export const defaults: Schema = {
hiddenPosts: [], hiddenPosts: [],
useInAppBrowser: undefined, useInAppBrowser: undefined,
lastSelectedHomeFeed: undefined, lastSelectedHomeFeed: undefined,
pdsAddressHistory: [],
} }
+3 -3
View File
@@ -83,19 +83,19 @@ export function HomeLoggedOutCTA() {
<View style={[styles.footer, pal.view, pal.border]}> <View style={[styles.footer, pal.view, pal.border]}>
<TextLink <TextLink
type="2xl" type="2xl"
href="https://blueskyweb.xyz" href="https://bsky.social"
text={_(msg`Business`)} text={_(msg`Business`)}
style={[styles.footerLink, pal.link]} style={[styles.footerLink, pal.link]}
/> />
<TextLink <TextLink
type="2xl" type="2xl"
href="https://blueskyweb.xyz/blog" href="https://bsky.social/about/blog"
text={_(msg`Blog`)} text={_(msg`Blog`)}
style={[styles.footerLink, pal.link]} style={[styles.footerLink, pal.link]}
/> />
<TextLink <TextLink
type="2xl" type="2xl"
href="https://blueskyweb.xyz/join" href="https://bsky.social/about/join"
text={_(msg`Jobs`)} text={_(msg`Jobs`)}
style={[styles.footerLink, pal.link]} style={[styles.footerLink, pal.link]}
/> />
+78 -1
View File
@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
@@ -9,6 +10,14 @@ import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype' import {Logotype} from '#/view/icons/Logotype'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {APP_LANGUAGES} from '#/locale/languages'
export const SplashScreen = ({ export const SplashScreen = ({
onPressSignin, onPressSignin,
@@ -20,6 +29,22 @@ export const SplashScreen = ({
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const insets = useSafeAreaInsets()
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
const onChangeAppLanguage = React.useCallback(
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
if (!value) return
if (sanitizedLang !== value) {
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
}
},
[sanitizedLang, setLangPrefs],
)
return ( return (
<CenteredView style={[styles.container, pal.view]}> <CenteredView style={[styles.container, pal.view]}>
<ErrorBoundary> <ErrorBoundary>
@@ -58,6 +83,51 @@ export const SplashScreen = ({
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.footer}>
<View style={{position: 'relative'}}>
<RNPickerSelect
placeholder={{}}
value={sanitizedLang}
onValueChange={onChangeAppLanguage}
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
label: l.name,
value: l.code2,
key: l.code2,
}))}
useNativeAndroidPickerStyle={false}
style={{
inputAndroid: {
color: pal.textLight.color,
fontSize: 16,
paddingRight: 10 + 4,
},
inputIOS: {
color: pal.text.color,
fontSize: 16,
paddingRight: 10 + 4,
},
}}
/>
<View
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
}}>
<FontAwesomeIcon
icon="chevron-down"
size={10}
style={pal.textLight as FontAwesomeIconStyle}
/>
</View>
</View>
</View>
<View style={{height: insets.bottom}} />
</ErrorBoundary> </ErrorBoundary>
</CenteredView> </CenteredView>
) )
@@ -73,7 +143,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
btns: { btns: {
paddingBottom: 40, paddingBottom: 0,
}, },
title: { title: {
textAlign: 'center', textAlign: 'center',
@@ -95,4 +165,11 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
fontSize: 21, fontSize: 21,
}, },
footer: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 24,
justifyContent: 'center',
alignItems: 'center',
},
}) })
+75 -10
View File
@@ -9,9 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette'
import {CenteredView} from '../util/Views' import {CenteredView} from '../util/Views'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Trans} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype' import {Logotype} from '#/view/icons/Logotype'
import {useLingui} from '@lingui/react'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {APP_LANGUAGES} from '#/locale/languages'
export const SplashScreen = ({ export const SplashScreen = ({
onDismiss, onDismiss,
@@ -98,24 +102,84 @@ export const SplashScreen = ({
function Footer({styles}: {styles: ReturnType<typeof useStyles>}) { function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
const onChangeAppLanguage = React.useCallback(
(ev: React.ChangeEvent<HTMLSelectElement>) => {
const value = ev.target.value
if (!value) return
if (sanitizedLang !== value) {
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
}
},
[sanitizedLang, setLangPrefs],
)
return ( return (
<View style={[styles.footer, pal.view, pal.border]}> <View style={[styles.footer, pal.view, pal.border]}>
<TextLink <TextLink
href="https://blueskyweb.xyz" href="https://bsky.social"
text="Business" text={_(msg`Business`)}
style={[styles.footerLink, pal.link]} style={[styles.footerLink, pal.link]}
/> />
<TextLink <TextLink
href="https://blueskyweb.xyz/blog" href="https://bsky.social/about/blog"
text="Blog" text={_(msg`Blog`)}
style={[styles.footerLink, pal.link]} style={[styles.footerLink, pal.link]}
/> />
<TextLink <TextLink
href="https://blueskyweb.xyz/join" href="https://bsky.social/about/join"
text="Jobs" text={_(msg`Jobs`)}
style={[styles.footerLink, pal.link]} style={[styles.footerLink, pal.link]}
/> />
<View style={styles.footerDivider} />
<View
style={{
flexDirection: 'row',
gap: 8,
alignItems: 'center',
flexShrink: 1,
}}>
<Text aria-hidden={true} style={[pal.textLight]}>
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
</Text>
<FontAwesomeIcon
icon="chevron-down"
size={12}
style={[pal.textLight, {flexShrink: 0}]}
/>
<select
value={sanitizedLang}
onChange={onChangeAppLanguage}
style={{
cursor: 'pointer',
MozAppearance: 'none',
WebkitAppearance: 'none',
appearance: 'none',
position: 'absolute',
inset: 0,
width: '100%',
color: 'transparent',
background: 'transparent',
border: 0,
padding: 0,
}}>
{APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => (
<option key={l.code2} value={l.code2}>
{l.name}
</option>
))}
</select>
</View>
</View> </View>
) )
} }
@@ -188,9 +252,10 @@ const useStyles = () => {
padding: 20, padding: 20,
borderTopWidth: 1, borderTopWidth: 1,
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap',
gap: 20,
}, },
footerLink: { footerDivider: {flexGrow: 1},
marginRight: 20, footerLink: {},
},
}) })
} }
+72 -20
View File
@@ -3,6 +3,7 @@ import {
ActivityIndicator, ActivityIndicator,
Keyboard, Keyboard,
StyleSheet, StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback, TouchableWithoutFeedback,
View, View,
} from 'react-native' } from 'react-native'
@@ -13,7 +14,6 @@ import {StepHeader} from './StepHeader'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput' import {TextInput} from '../util/TextInput'
import {Button} from '../../util/forms/Button'
import {Policies} from './Policies' import {Policies} from './Policies'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
@@ -21,7 +21,14 @@ import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {logger} from '#/logger' import {logger} from '#/logger'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useDialogControl} from '#/components/Dialog'
import {ServerInputDialog} from '../server-input'
import {toNiceDomain} from '#/lib/strings/url-helpers'
function sanitizeDate(date: Date): Date { function sanitizeDate(date: Date): Date {
if (!date || date.toString() === 'Invalid Date') { if (!date || date.toString() === 'Invalid Date') {
@@ -43,16 +50,12 @@ export function Step1({
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const serverInputControl = useDialogControl()
const onPressSelectService = React.useCallback(() => { const onPressSelectService = React.useCallback(() => {
openModal({ serverInputControl.open()
name: 'server-input',
initialService: uiState.serviceUrl,
onSelect: (url: string) =>
uiDispatch({type: 'set-service-url', value: url}),
})
Keyboard.dismiss() Keyboard.dismiss()
}, [uiDispatch, uiState.serviceUrl, openModal]) }, [serverInputControl])
const onPressWaitlist = React.useCallback(() => { const onPressWaitlist = React.useCallback(() => {
openModal({name: 'waitlist'}) openModal({name: 'waitlist'})
@@ -64,23 +67,72 @@ export function Step1({
return ( return (
<View> <View>
<StepHeader uiState={uiState} title={_(msg`Your account`)}> <ServerInputDialog
<View> control={serverInputControl}
<Button onSelect={url => uiDispatch({type: 'set-service-url', value: url})}
/>
<StepHeader uiState={uiState} title={_(msg`Your account`)} />
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
<Trans>Hosting provider</Trans>
</Text>
<View style={[pal.border, {borderWidth: 1, borderRadius: 6}]}>
<View
style={[
pal.borderDark,
{flexDirection: 'row', alignItems: 'center'},
]}>
<FontAwesomeIcon
icon="globe"
style={[pal.textLight, {marginLeft: 14}]}
/>
<TouchableOpacity
testID="selectServiceButton" testID="selectServiceButton"
type="default"
style={{ style={{
aspectRatio: 1, flexDirection: 'row',
justifyContent: 'center', flex: 1,
alignItems: 'center', alignItems: 'center',
}} }}
onPress={onPressSelectService}
accessibilityRole="button"
accessibilityLabel={_(msg`Select service`)} accessibilityLabel={_(msg`Select service`)}
accessibilityHint={_(msg`Sets server for the Bluesky client`)} accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
onPress={onPressSelectService}> <Text
<FontAwesomeIcon icon="server" size={21} color={pal.colors.text} /> type="xl"
</Button> style={[
pal.text,
{
flex: 1,
paddingVertical: 10,
paddingRight: 12,
paddingLeft: 10,
},
]}>
{toNiceDomain(uiState.serviceUrl)}
</Text>
<View
style={[
pal.btn,
{
flexDirection: 'row',
alignItems: 'center',
borderRadius: 6,
paddingVertical: 6,
paddingHorizontal: 8,
marginHorizontal: 6,
},
]}>
<FontAwesomeIcon
icon="pen"
size={12}
style={pal.textLight as FontAwesomeIconStyle}
/>
</View>
</TouchableOpacity>
</View>
</View>
</View> </View>
</StepHeader>
{!uiState.serviceDescription ? ( {!uiState.serviceDescription ? (
<ActivityIndicator /> <ActivityIndicator />
+13 -9
View File
@@ -1,6 +1,7 @@
import React, {useState, useEffect} from 'react' import React, {useState, useEffect} from 'react'
import { import {
ActivityIndicator, ActivityIndicator,
Keyboard,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
@@ -24,7 +25,9 @@ import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {styles} from './styles' import {styles} from './styles'
import {useModalControls} from '#/state/modals' import {useDialogControl} from '#/components/Dialog'
import {ServerInputDialog} from '../server-input'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -51,19 +54,16 @@ export const ForgotPasswordForm = ({
const [email, setEmail] = useState<string>('') const [email, setEmail] = useState<string>('')
const {screen} = useAnalytics() const {screen} = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const {openModal} = useModalControls() const serverInputControl = useDialogControl()
useEffect(() => { useEffect(() => {
screen('Signin:ForgotPassword') screen('Signin:ForgotPassword')
}, [screen]) }, [screen])
const onPressSelectService = () => { const onPressSelectService = React.useCallback(() => {
openModal({ serverInputControl.open()
name: 'server-input', Keyboard.dismiss()
initialService: serviceUrl, }, [serverInputControl])
onSelect: setServiceUrl,
})
}
const onPressNext = async () => { const onPressNext = async () => {
if (!EmailValidator.validate(email)) { if (!EmailValidator.validate(email)) {
@@ -96,6 +96,10 @@ export const ForgotPasswordForm = ({
return ( return (
<> <>
<View> <View>
<ServerInputDialog
control={serverInputControl}
onSelect={setServiceUrl}
/>
<Text type="title-lg" style={[pal.text, styles.screenTitle]}> <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
<Trans>Reset password</Trans> <Trans>Reset password</Trans>
</Text> </Text>
+10 -7
View File
@@ -25,7 +25,9 @@ import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {styles} from './styles' import {styles} from './styles'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals' import {useDialogControl} from '#/components/Dialog'
import {ServerInputDialog} from '../server-input'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -58,15 +60,11 @@ export const LoginForm = ({
const [password, setPassword] = useState<string>('') const [password, setPassword] = useState<string>('')
const passwordInputRef = useRef<TextInput>(null) const passwordInputRef = useRef<TextInput>(null)
const {_} = useLingui() const {_} = useLingui()
const {openModal} = useModalControls()
const {login} = useSessionApi() const {login} = useSessionApi()
const serverInputControl = useDialogControl()
const onPressSelectService = () => { const onPressSelectService = () => {
openModal({ serverInputControl.open()
name: 'server-input',
initialService: serviceUrl,
onSelect: setServiceUrl,
})
Keyboard.dismiss() Keyboard.dismiss()
track('Signin:PressedSelectService') track('Signin:PressedSelectService')
} }
@@ -130,6 +128,11 @@ export const LoginForm = ({
const isReady = !!serviceDescription && !!identifier && !!password const isReady = !!serviceDescription && !!identifier && !!password
return ( return (
<View testID="loginForm"> <View testID="loginForm">
<ServerInputDialog
control={serverInputControl}
onSelect={setServiceUrl}
/>
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}> <Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
<Trans>Sign into</Trans> <Trans>Sign into</Trans>
</Text> </Text>
+173
View File
@@ -0,0 +1,173 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {PROD_SERVICE} from 'lib/constants'
import * as persisted from '#/state/persisted'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Text, P} from '#/components/Typography'
import {Button, ButtonText} from '#/components/Button'
import * as ToggleButton from '#/components/forms/ToggleButton'
import * as TextField from '#/components/forms/TextField'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
export function ServerInputDialog({
control,
onSelect,
}: {
control: Dialog.DialogOuterProps['control']
onSelect: (url: string) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const [pdsAddressHistory, setPdsAddressHistory] = React.useState<string[]>(
persisted.get('pdsAddressHistory') || [],
)
const [fixedOption, setFixedOption] = React.useState([PROD_SERVICE])
const [customAddress, setCustomAddress] = React.useState('')
const onClose = React.useCallback(() => {
let url
if (fixedOption[0] === 'custom') {
url = customAddress.trim().toLowerCase()
if (!url) {
return
}
} else {
url = fixedOption[0]
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
if (url === 'localhost' || url.startsWith('localhost:')) {
url = `http://${url}`
} else {
url = `https://${url}`
}
}
if (fixedOption[0] === 'custom') {
if (!pdsAddressHistory.includes(url)) {
const newHistory = [url, ...pdsAddressHistory.slice(0, 4)]
setPdsAddressHistory(newHistory)
persisted.write('pdsAddressHistory', newHistory)
}
}
onSelect(url)
}, [
fixedOption,
customAddress,
onSelect,
pdsAddressHistory,
setPdsAddressHistory,
])
return (
<Dialog.Outer
control={control}
nativeOptions={{sheet: {snapPoints: ['100%']}}}
onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
<Trans>Choose Service</Trans>
</Text>
<P nativeID="dialog-description" style={[a.text_sm]}>
<Trans>Select the service that hosts your data.</Trans>
</P>
<ToggleButton.Group
label="Preferences"
values={fixedOption}
onChange={setFixedOption}>
<ToggleButton.Button name={PROD_SERVICE} label={_(msg`Bluesky`)}>
{_(msg`Bluesky`)}
</ToggleButton.Button>
<ToggleButton.Button
testID="customSelectBtn"
name="custom"
label={_(msg`Custom`)}>
{_(msg`Custom`)}
</ToggleButton.Button>
</ToggleButton.Group>
{fixedOption[0] === 'custom' && (
<View
style={[
a.border,
t.atoms.border_contrast_low,
a.rounded_sm,
a.px_md,
a.py_md,
]}>
<TextField.Label nativeID="address-input-label">
<Trans>Server address</Trans>
</TextField.Label>
<TextField.Root>
<TextField.Icon icon={Globe} />
<Dialog.Input
testID="customServerTextInput"
value={customAddress}
onChangeText={setCustomAddress}
label={_(msg`my-server.com`)}
accessibilityLabelledBy="address-input-label"
autoCapitalize="none"
keyboardType="url"
/>
</TextField.Root>
{pdsAddressHistory.length > 0 && (
<View style={[a.flex_row, a.flex_wrap, a.mt_xs]}>
{pdsAddressHistory.map(uri => (
<Button
key={uri}
variant="ghost"
color="primary"
label={uri}
style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]}
onPress={() => setCustomAddress(uri)}>
<ButtonText>{uri}</ButtonText>
</Button>
))}
</View>
)}
</View>
)}
<View style={[a.py_xs]}>
<P
style={[
t.atoms.text_contrast_medium,
a.text_sm,
a.leading_snug,
a.flex_1,
]}>
<Trans>
Bluesky is an open network where you can choose your hosting
provider. Custom hosting is now available in beta for
developers.
</Trans>
</P>
</View>
<View style={gtMobile && [a.flex_row, a.justify_end]}>
<Button
testID="doneBtn"
variant="outline"
color="primary"
size="small"
onPress={() => control.close()}
label={_(msg`Done`)}>
{_(msg`Done`)}
</Button>
</View>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
@@ -28,6 +28,7 @@ import {useTheme} from 'lib/ThemeContext'
import {isUriImage} from 'lib/media/util' import {isUriImage} from 'lib/media/util'
import {downloadAndResize} from 'lib/media/manip' import {downloadAndResize} from 'lib/media/manip'
import {POST_IMG_MAX} from 'lib/constants' import {POST_IMG_MAX} from 'lib/constants'
import {isIOS} from 'platform/detection'
export interface TextInputRef { export interface TextInputRef {
focus: () => void focus: () => void
@@ -252,6 +253,7 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
letterSpacing: 0.2, letterSpacing: 0.2,
fontWeight: '400', fontWeight: '400',
lineHeight: 23.4, // 1.3*16 // This is broken on ios right now, so don't set it there.
lineHeight: isIOS ? undefined : 23.4, // 1.3*16
}, },
}) })
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {LabelPreference} from '@atproto/api' import {LabelPreference} from '@atproto/api'
import {StyleSheet, Pressable, View} from 'react-native' import {StyleSheet, Pressable, View, Linking} from 'react-native'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {ScrollView} from './util' import {ScrollView} from './util'
import {s, colors, gradients} from 'lib/styles' import {s, colors, gradients} from 'lib/styles'
@@ -129,6 +129,10 @@ function AdultContentEnabledPref() {
} }
}, [variables, preferences, mutate, _]) }, [variables, preferences, mutate, _])
const onAdultContentLinkPress = React.useCallback(() => {
Linking.openURL('https://bsky.app/')
}, [])
return ( return (
<View style={s.mb10}> <View style={s.mb10}>
{isIOS ? ( {isIOS ? (
@@ -138,8 +142,9 @@ function AdultContentEnabledPref() {
Adult content can only be enabled via the Web at{' '} Adult content can only be enabled via the Web at{' '}
<TextLink <TextLink
style={pal.link} style={pal.link}
href="https://bsky.app" href=""
text="bsky.app" text="bsky.app"
onPress={onAdultContentLinkPress}
/> />
. .
</Trans> </Trans>
-4
View File
@@ -8,7 +8,6 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useModals, useModalControls} from '#/state/modals' import {useModals, useModalControls} from '#/state/modals'
import * as ConfirmModal from './Confirm' import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile' import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as RepostModal from './Repost' import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel' import * as SelfLabelModal from './SelfLabel'
import * as ThreadgateModal from './Threadgate' import * as ThreadgateModal from './Threadgate'
@@ -74,9 +73,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'edit-profile') { } else if (activeModal?.name === 'edit-profile') {
snapPoints = EditProfileModal.snapPoints snapPoints = EditProfileModal.snapPoints
element = <EditProfileModal.Component {...activeModal} /> element = <EditProfileModal.Component {...activeModal} />
} else if (activeModal?.name === 'server-input') {
snapPoints = ServerInputModal.snapPoints
element = <ServerInputModal.Component {...activeModal} />
} else if (activeModal?.name === 'report') { } else if (activeModal?.name === 'report') {
snapPoints = ReportModal.snapPoints snapPoints = ReportModal.snapPoints
element = <ReportModal.Component {...activeModal} /> element = <ReportModal.Component {...activeModal} />
-3
View File
@@ -9,7 +9,6 @@ import {useModals, useModalControls} from '#/state/modals'
import type {Modal as ModalIface} from '#/state/modals' import type {Modal as ModalIface} from '#/state/modals'
import * as ConfirmModal from './Confirm' import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile' import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as ReportModal from './report/Modal' import * as ReportModal from './report/Modal'
import * as AppealLabelModal from './AppealLabel' import * as AppealLabelModal from './AppealLabel'
import * as CreateOrEditListModal from './CreateOrEditList' import * as CreateOrEditListModal from './CreateOrEditList'
@@ -84,8 +83,6 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ConfirmModal.Component {...modal} /> element = <ConfirmModal.Component {...modal} />
} else if (modal.name === 'edit-profile') { } else if (modal.name === 'edit-profile') {
element = <EditProfileModal.Component {...modal} /> element = <EditProfileModal.Component {...modal} />
} else if (modal.name === 'server-input') {
element = <ServerInputModal.Component {...modal} />
} else if (modal.name === 'report') { } else if (modal.name === 'report') {
element = <ReportModal.Component {...modal} /> element = <ReportModal.Component {...modal} />
} else if (modal.name === 'appeal-label') { } else if (modal.name === 'appeal-label') {
-189
View File
@@ -1,189 +0,0 @@
import React, {useState} from 'react'
import {Platform, StyleSheet, TouchableOpacity, View} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {ScrollView, TextInput} from './util'
import {Text} from '../util/text/Text'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants'
import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
export const snapPoints = ['80%']
export function Component({onSelect}: {onSelect: (url: string) => void}) {
const theme = useTheme()
const pal = usePalette('default')
const [customUrl, setCustomUrl] = useState<string>('')
const {_} = useLingui()
const {closeModal} = useModalControls()
const doSelect = (url: string) => {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `https://${url}`
}
closeModal()
onSelect(url)
}
return (
<View style={[pal.view, s.flex1]} testID="serverInputModal">
<Text type="2xl-bold" style={[pal.text, s.textCenter]}>
<Trans>Choose Service</Trans>
</Text>
<ScrollView style={styles.inner}>
<View style={styles.group}>
{LOGIN_INCLUDE_DEV_SERVERS ? (
<>
<TouchableOpacity
testID="localDevServerButton"
style={styles.btn}
onPress={() => doSelect(LOCAL_DEV_SERVICE)}
accessibilityRole="button">
<Text style={styles.btnText}>
<Trans>Local dev server</Trans>
</Text>
<FontAwesomeIcon
icon="arrow-right"
style={s.white as FontAwesomeIconStyle}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.btn}
onPress={() => doSelect(STAGING_SERVICE)}
accessibilityRole="button">
<Text style={styles.btnText}>
<Trans>Staging</Trans>
</Text>
<FontAwesomeIcon
icon="arrow-right"
style={s.white as FontAwesomeIconStyle}
/>
</TouchableOpacity>
</>
) : undefined}
<TouchableOpacity
style={styles.btn}
onPress={() => doSelect(PROD_SERVICE)}
accessibilityRole="button"
accessibilityLabel={_(msg`Select Bluesky Social`)}
accessibilityHint="Sets Bluesky Social as your service provider">
<Text style={styles.btnText}>
<Trans>Bluesky.Social</Trans>
</Text>
<FontAwesomeIcon
icon="arrow-right"
style={s.white as FontAwesomeIconStyle}
/>
</TouchableOpacity>
</View>
<View style={styles.group}>
<Text style={[pal.text, styles.label]}>
<Trans>Other service</Trans>
</Text>
<View style={s.flexRow}>
<TextInput
testID="customServerTextInput"
style={[pal.borderDark, pal.text, styles.textInput]}
placeholder="e.g. https://bsky.app"
placeholderTextColor={colors.gray4}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
keyboardAppearance={theme.colorScheme}
value={customUrl}
onChangeText={setCustomUrl}
accessibilityLabel={_(msg`Custom domain`)}
// TODO: Simplify this wording further to be understandable by everyone
accessibilityHint={_(
msg`Use your domain as your Bluesky client service provider`,
)}
/>
<TouchableOpacity
testID="customServerSelectBtn"
style={[pal.borderDark, pal.text, styles.textInputBtn]}
onPress={() => doSelect(customUrl)}
accessibilityRole="button"
accessibilityLabel={`Confirm service. ${
customUrl === ''
? _(msg`Button disabled. Input custom domain to proceed.`)
: ''
}`}
accessibilityHint=""
// TODO - accessibility: Need to inform state change on failure
disabled={customUrl === ''}>
<FontAwesomeIcon
icon="check"
style={[pal.text as FontAwesomeIconStyle, styles.checkIcon]}
size={18}
/>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
inner: {
padding: 14,
},
group: {
marginBottom: 20,
},
label: {
fontWeight: 'bold',
paddingHorizontal: 4,
paddingBottom: 4,
},
textInput: {
flex: 1,
borderWidth: 1,
borderTopLeftRadius: 6,
borderBottomLeftRadius: 6,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
},
textInputBtn: {
borderWidth: 1,
borderLeftWidth: 0,
borderTopRightRadius: 6,
borderBottomRightRadius: 6,
paddingHorizontal: 14,
paddingVertical: 10,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.blue3,
borderRadius: 6,
paddingHorizontal: 14,
paddingVertical: 10,
marginBottom: 6,
},
btnText: {
flex: 1,
fontSize: 18,
fontWeight: '500',
color: colors.white,
},
checkIcon: {
position: 'relative',
...Platform.select({
android: {
top: 8,
},
ios: {
top: 2,
},
}),
},
})
+1 -1
View File
@@ -18,7 +18,7 @@ import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {getAgent} from '#/state/session' import {getAgent} from '#/state/session'
const DMCA_LINK = 'https://blueskyweb.xyz/support/copyright' const DMCA_LINK = 'https://bsky.social/about/support/copyright'
export const snapPoints = [575] export const snapPoints = [575]
+14 -21
View File
@@ -233,36 +233,29 @@ let PagerTabBar = ({
}, },
], ],
})) }))
const pendingHeaderHeight = React.useRef<null | number>(null) const headerRef = React.useRef(null)
return ( return (
<Animated.View <Animated.View
pointerEvents="box-none" pointerEvents="box-none"
style={[styles.tabBarMobile, headerTransform]}> style={[styles.tabBarMobile, headerTransform]}>
<View <View ref={headerRef} pointerEvents="box-none" collapsable={false}>
pointerEvents="box-none"
collapsable={false}
onLayout={e => {
if (isHeaderReady) {
onHeaderOnlyLayout(e.nativeEvent.layout.height)
pendingHeaderHeight.current = null
} else {
// Stash it away for when `isHeaderReady` turns `true` later.
pendingHeaderHeight.current = e.nativeEvent.layout.height
}
}}>
{renderHeader?.()} {renderHeader?.()}
{ {
// When `isHeaderReady` turns `true`, we want to send the parent layout. // It wouldn't be enough to place `onLayout` on the parent node because
// However, if that didn't lead to a layout change, parent `onLayout` wouldn't get called again. // this would risk measuring before `isHeaderReady` has turned `true`.
// We're conditionally rendering an empty view so that we can send the last measurement. // Instead, we'll render a brand node conditionally and get fresh layout.
isHeaderReady && ( isHeaderReady && (
<View <View
// It wouldn't be enough to do this in a `ref` of an effect because,
// even if `isHeaderReady` might have turned `true`, the associated
// layout might not have been performed yet on the native side.
onLayout={() => { onLayout={() => {
// We're assuming the parent `onLayout` already ran (parent -> child ordering). // @ts-ignore
if (pendingHeaderHeight.current !== null) { headerRef.current?.measure(
onHeaderOnlyLayout(pendingHeaderHeight.current) (_x: number, _y: number, _width: number, height: number) => {
pendingHeaderHeight.current = null onHeaderOnlyLayout(height)
} },
)
}} }}
/> />
) )
+127 -76
View File
@@ -25,6 +25,8 @@ import {useSetTitle} from 'lib/hooks/useSetTitle'
import { import {
ThreadNode, ThreadNode,
ThreadPost, ThreadPost,
ThreadNotFound,
ThreadBlocked,
usePostThreadQuery, usePostThreadQuery,
sortThread, sortThread,
} from '#/state/queries/post-thread' } from '#/state/queries/post-thread'
@@ -45,22 +47,33 @@ import {isAndroid, isNative} from '#/platform/detection'
import {logger} from '#/logger' import {logger} from '#/logger'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 1} const MAINTAIN_VISIBLE_CONTENT_POSITION = {
// We don't insert any elements before the root row while loading.
// So the row we want to use as the scroll anchor is the first row.
minIndexForVisible: 0,
}
const TOP_COMPONENT = {_reactKey: '__top_component__'} const TOP_COMPONENT = {_reactKey: '__top_component__'}
const REPLY_PROMPT = {_reactKey: '__reply__'} const REPLY_PROMPT = {_reactKey: '__reply__'}
const DELETED = {_reactKey: '__deleted__'}
const BLOCKED = {_reactKey: '__blocked__'}
const CHILD_SPINNER = {_reactKey: '__child_spinner__'} const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
const LOAD_MORE = {_reactKey: '__load_more__'} const LOAD_MORE = {_reactKey: '__load_more__'}
const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'} const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
type YieldedItem = type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound
| ThreadPost type RowItem =
| YieldedItem
// TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
| typeof TOP_COMPONENT | typeof TOP_COMPONENT
| typeof REPLY_PROMPT | typeof REPLY_PROMPT
| typeof DELETED | typeof CHILD_SPINNER
| typeof BLOCKED | typeof LOAD_MORE
| typeof BOTTOM_COMPONENT
type ThreadSkeletonParts = {
parents: YieldedItem[]
highlightedPost: ThreadNode
replies: YieldedItem[]
}
export function PostThread({ export function PostThread({
uri, uri,
@@ -152,10 +165,6 @@ function PostThreadLoaded({
const {isMobile, isTabletOrMobile} = useWebMediaQueries() const {isMobile, isTabletOrMobile} = useWebMediaQueries()
const ref = useRef<ListMethods>(null) const ref = useRef<ListMethods>(null)
const highlightedPostRef = useRef<View | null>(null) const highlightedPostRef = useRef<View | null>(null)
const needsScrollAdjustment = useRef<boolean>(
!isNative || // web always uses scroll adjustment
(thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder
)
const [maxVisible, setMaxVisible] = React.useState(100) const [maxVisible, setMaxVisible] = React.useState(100)
const [isPTRing, setIsPTRing] = React.useState(false) const [isPTRing, setIsPTRing] = React.useState(false)
const treeView = React.useMemo( const treeView = React.useMemo(
@@ -163,50 +172,74 @@ function PostThreadLoaded({
[threadViewPrefs, thread], [threadViewPrefs, thread],
) )
// construct content // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
const posts = React.useMemo(() => { // This ensures that the first render contains no parents--even if they are already available in the cache.
let arr = Array.from( // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen.
flattenThreadSkeleton( // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
const [deferParents, setDeferParents] = React.useState(isNative)
const skeleton = React.useMemo(
() =>
createThreadSkeleton(
sortThread(thread, threadViewPrefs), sortThread(thread, threadViewPrefs),
hasSession, hasSession,
treeView, treeView,
), ),
[thread, threadViewPrefs, hasSession, treeView],
) )
// construct content
const posts = React.useMemo(() => {
const {parents, highlightedPost, replies} = skeleton
let arr: RowItem[] = []
if (highlightedPost.type === 'post') {
const isRoot =
!highlightedPost.parent && !highlightedPost.ctx.isParentLoading
if (isRoot) {
// No parents to load.
arr.push(TOP_COMPONENT)
} else {
if (highlightedPost.ctx.isParentLoading || deferParents) {
// We're loading parents of the highlighted post.
// In this case, we don't render anything above the post.
// If you add something here, you'll need to update both
// maintainVisibleContentPosition and onContentSizeChange
// to "hold onto" the correct row instead of the first one.
} else {
// Everything is loaded.
arr.push(TOP_COMPONENT)
for (const parent of parents) {
arr.push(parent)
}
}
}
arr.push(highlightedPost)
if (!highlightedPost.post.viewer?.replyDisabled) {
arr.push(REPLY_PROMPT)
}
if (highlightedPost.ctx.isChildLoading) {
arr.push(CHILD_SPINNER)
} else {
for (const reply of replies) {
arr.push(reply)
}
arr.push(BOTTOM_COMPONENT)
}
}
if (arr.length > maxVisible) { if (arr.length > maxVisible) {
arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
} }
if (arr.indexOf(CHILD_SPINNER) === -1) {
arr.push(BOTTOM_COMPONENT)
}
return arr return arr
}, [thread, treeView, maxVisible, threadViewPrefs, hasSession]) }, [skeleton, maxVisible, deferParents])
/** // This is only used on the web to keep the post in view when its parents load.
* NOTE // On native, we rely on `maintainVisibleContentPosition` instead.
* Scroll positioning const didAdjustScrollWeb = useRef<boolean>(false)
* const onContentSizeChangeWeb = React.useCallback(() => {
* This callback is run if needsScrollAdjustment.current == true, which is...
* - On web: always
* - On native: when the placeholder cache is not being used
*
* It then only runs when viewing a reply, and the goal is to scroll the
* reply into view.
*
* On native, if the placeholder cache is being used then maintainVisibleContentPosition
* is a more effective solution, so we use that. Otherwise, typically we're loading from
* the react-query cache, so we just need to immediately scroll down to the post.
*
* On desktop, maintainVisibleContentPosition isn't supported so we just always use
* this technique.
*
* -prf
*/
const onContentSizeChange = React.useCallback(() => {
// only run once // only run once
if (!needsScrollAdjustment.current) { if (didAdjustScrollWeb.current) {
return return
} }
// wait for loading to finish // wait for loading to finish
if (thread.type === 'post' && !!thread.parent) { if (thread.type === 'post' && !!thread.parent) {
function onMeasure(pageY: number) { function onMeasure(pageY: number) {
@@ -215,21 +248,13 @@ function PostThreadLoaded({
offset: pageY, offset: pageY,
}) })
} }
if (isNative) {
highlightedPostRef.current?.measure(
(_x, _y, _width, _height, _pageX, pageY) => {
onMeasure(pageY)
},
)
} else {
// Measure synchronously to avoid a layout jump. // Measure synchronously to avoid a layout jump.
const domNode = highlightedPostRef.current const domNode = highlightedPostRef.current
if (domNode) { if (domNode) {
const pageY = (domNode as any as Element).getBoundingClientRect().top const pageY = (domNode as any as Element).getBoundingClientRect().top
onMeasure(pageY) onMeasure(pageY)
} }
} didAdjustScrollWeb.current = true
needsScrollAdjustment.current = false
} }
}, [thread]) }, [thread])
@@ -244,7 +269,7 @@ function PostThreadLoaded({
}, [setIsPTRing, onRefresh]) }, [setIsPTRing, onRefresh])
const renderItem = React.useCallback( const renderItem = React.useCallback(
({item, index}: {item: YieldedItem; index: number}) => { ({item, index}: {item: RowItem; index: number}) => {
if (item === TOP_COMPONENT) { if (item === TOP_COMPONENT) {
return isTabletOrMobile ? ( return isTabletOrMobile ? (
<ViewHeader <ViewHeader
@@ -257,7 +282,7 @@ function PostThreadLoaded({
{!isMobile && <ComposePrompt onPressCompose={onPressReply} />} {!isMobile && <ComposePrompt onPressCompose={onPressReply} />}
</View> </View>
) )
} else if (item === DELETED) { } else if (isThreadNotFound(item)) {
return ( return (
<View style={[pal.border, pal.viewLight, styles.itemContainer]}> <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
<Text type="lg-bold" style={pal.textLight}> <Text type="lg-bold" style={pal.textLight}>
@@ -265,7 +290,7 @@ function PostThreadLoaded({
</Text> </Text>
</View> </View>
) )
} else if (item === BLOCKED) { } else if (isThreadBlocked(item)) {
return ( return (
<View style={[pal.border, pal.viewLight, styles.itemContainer]}> <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
<Text type="lg-bold" style={pal.textLight}> <Text type="lg-bold" style={pal.textLight}>
@@ -322,7 +347,8 @@ function PostThreadLoaded({
: undefined : undefined
return ( return (
<View <View
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}> ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
onLayout={deferParents ? () => setDeferParents(false) : undefined}>
<PostThreadItem <PostThreadItem
post={item.post} post={item.post}
record={item.record} record={item.record}
@@ -355,6 +381,7 @@ function PostThreadLoaded({
pal.colors.border, pal.colors.border,
posts, posts,
onRefresh, onRefresh,
deferParents,
treeView, treeView,
_, _,
], ],
@@ -364,17 +391,14 @@ function PostThreadLoaded({
<List <List
ref={ref} ref={ref}
data={posts} data={posts}
initialNumToRender={!isNative ? posts.length : undefined}
maintainVisibleContentPosition={
!needsScrollAdjustment.current
? MAINTAIN_VISIBLE_CONTENT_POSITION
: undefined
}
keyExtractor={item => item._reactKey} keyExtractor={item => item._reactKey}
renderItem={renderItem} renderItem={renderItem}
refreshing={isPTRing} refreshing={isPTRing}
onRefresh={onPTR} onRefresh={onPTR}
onContentSizeChange={onContentSizeChange} onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
maintainVisibleContentPosition={
isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
}
style={s.hContentRegion} style={s.hContentRegion}
// @ts-ignore our .web version only -prf // @ts-ignore our .web version only -prf
desktopFixedHeight desktopFixedHeight
@@ -486,41 +510,68 @@ function isThreadPost(v: unknown): v is ThreadPost {
return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
} }
function* flattenThreadSkeleton( function isThreadNotFound(v: unknown): v is ThreadNotFound {
return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'
}
function isThreadBlocked(v: unknown): v is ThreadBlocked {
return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
}
function createThreadSkeleton(
node: ThreadNode, node: ThreadNode,
hasSession: boolean, hasSession: boolean,
treeView: boolean, treeView: boolean,
isTraversingReplies: boolean = false, ): ThreadSkeletonParts {
return {
parents: Array.from(flattenThreadParents(node, hasSession)),
highlightedPost: node,
replies: Array.from(flattenThreadReplies(node, hasSession, treeView)),
}
}
function* flattenThreadParents(
node: ThreadNode,
hasSession: boolean,
): Generator<YieldedItem, void> { ): Generator<YieldedItem, void> {
if (node.type === 'post') { if (node.type === 'post') {
if (!node.ctx.isParentLoading) {
if (node.parent) { if (node.parent) {
yield* flattenThreadSkeleton(node.parent, hasSession, treeView, false) yield* flattenThreadParents(node.parent, hasSession)
} else if (!isTraversingReplies) { }
yield TOP_COMPONENT if (!node.ctx.isHighlightedPost) {
yield node
}
} else if (node.type === 'not-found') {
yield node
} else if (node.type === 'blocked') {
yield node
} }
} }
if (!hasSession && node.ctx.depth > 0 && hasPwiOptOut(node)) {
function* flattenThreadReplies(
node: ThreadNode,
hasSession: boolean,
treeView: boolean,
): Generator<YieldedItem, void> {
if (node.type === 'post') {
if (!hasSession && hasPwiOptOut(node)) {
return return
} }
if (!node.ctx.isHighlightedPost) {
yield node yield node
if (node.ctx.isHighlightedPost && !node.post.viewer?.replyDisabled) {
yield REPLY_PROMPT
} }
if (node.replies?.length) { if (node.replies?.length) {
for (const reply of node.replies) { for (const reply of node.replies) {
yield* flattenThreadSkeleton(reply, hasSession, treeView, true) yield* flattenThreadReplies(reply, hasSession, treeView)
if (!treeView && !node.ctx.isHighlightedPost) { if (!treeView && !node.ctx.isHighlightedPost) {
break break
} }
} }
} else if (node.ctx.isChildLoading) {
yield CHILD_SPINNER
} }
} else if (node.type === 'not-found') { } else if (node.type === 'not-found') {
yield DELETED yield node
} else if (node.type === 'blocked') { } else if (node.type === 'blocked') {
yield BLOCKED yield node
} }
} }
@@ -0,0 +1,154 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {logger} from '#/logger'
import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Shadow, useProfileShadow} from 'state/cache/profile-shadow'
import {track} from 'lib/analytics/analytics'
import {
useProfileFollowMutationQueue,
useProfileQuery,
} from 'state/queries/profile'
import {useRequireAuth} from 'state/session'
export function PostThreadFollowBtn({did}: {did: string}) {
const {data: profile, isLoading} = useProfileQuery({did})
// We will never hit this - the profile will always be cached or loaded above
// but it keeps the typechecker happy
if (isLoading || !profile) return null
return <PostThreadFollowBtnLoaded profile={profile} />
}
function PostThreadFollowBtnLoaded({
profile: profileUnshadowed,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
}) {
const navigation = useNavigation()
const {_} = useLingui()
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const {isTabletOrDesktop} = useWebMediaQueries()
const profile: Shadow<AppBskyActorDefs.ProfileViewBasic> =
useProfileShadow(profileUnshadowed)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
const requireAuth = useRequireAuth()
const isFollowing = !!profile.viewer?.following
const [wasFollowing, setWasFollowing] = React.useState<boolean>(isFollowing)
// This prevents the button from disappearing as soon as we follow.
const showFollowBtn = React.useMemo(
() => !isFollowing || !wasFollowing,
[isFollowing, wasFollowing],
)
/**
* We want this button to stay visible even after following, so that the user can unfollow if they want.
* However, we need it to disappear after we push to a screen and then come back. We also need it to
* show up if we view the post while following, go to the profile and unfollow, then come back to the
* post.
*
* We want to update wasFollowing both on blur and on focus so that we hit all these cases. On native,
* we could do this only on focus because the transition animation gives us time to not notice the
* sudden rendering of the button. However, on web if we do this, there's an obvious flicker once the
* button renders. So, we update the state in both cases.
*/
React.useEffect(() => {
const updateWasFollowing = () => {
if (wasFollowing !== isFollowing) {
setWasFollowing(isFollowing)
}
}
const unsubscribeFocus = navigation.addListener('focus', updateWasFollowing)
const unsubscribeBlur = navigation.addListener('blur', updateWasFollowing)
return () => {
unsubscribeFocus()
unsubscribeBlur()
}
}, [isFollowing, wasFollowing, navigation])
const onPress = React.useCallback(() => {
if (!isFollowing) {
requireAuth(async () => {
try {
track('ProfileHeader:FollowButtonClicked')
await queueFollow()
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to follow', {message: String(e)})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
})
} else {
requireAuth(async () => {
try {
track('ProfileHeader:UnfollowButtonClicked')
await queueUnfollow()
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unfollow', {message: String(e)})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
})
}
}, [isFollowing, requireAuth, queueFollow, _, queueUnfollow])
if (!showFollowBtn) return null
return (
<View style={{width: isTabletOrDesktop ? 130 : 120}}>
<View style={styles.btnOuter}>
<TouchableOpacity
testID="followBtn"
onPress={onPress}
style={[styles.btn, !isFollowing ? palInverted.view : pal.viewLight]}
accessibilityRole="button"
accessibilityLabel={_(msg`Follow ${profile.handle}`)}
accessibilityHint={_(
msg`Shows posts from ${profile.handle} in your feed`,
)}>
{isTabletOrDesktop && (
<FontAwesomeIcon
icon={!isFollowing ? 'plus' : 'check'}
style={[!isFollowing ? palInverted.text : pal.text, s.mr5]}
/>
)}
<Text
type="button"
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
numberOfLines={1}>
{!isFollowing ? <Trans>Follow</Trans> : <Trans>Following</Trans>}
</Text>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
btnOuter: {
marginLeft: 'auto',
},
btn: {
flexDirection: 'row',
borderRadius: 50,
paddingVertical: 8,
paddingHorizontal: 14,
},
})
+24 -40
View File
@@ -9,6 +9,7 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {Link, TextLink} from '../util/Link' import {Link, TextLink} from '../util/Link'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@@ -30,7 +31,6 @@ import {PostSandboxWarning} from '../util/PostSandboxWarning'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {formatCount} from '../util/numeric/format' import {formatCount} from '../util/numeric/format'
import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {MAX_POST_LINES} from 'lib/constants' import {MAX_POST_LINES} from 'lib/constants'
@@ -42,6 +42,7 @@ import {useModerationOpts} from '#/state/queries/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser' import {useOpenLink} from '#/state/preferences/in-app-browser'
import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
import {ThreadPost} from '#/state/queries/post-thread' import {ThreadPost} from '#/state/queries/post-thread'
import {useSession} from 'state/session'
import {WhoCanReply} from '../threadgate/WhoCanReply' import {WhoCanReply} from '../threadgate/WhoCanReply'
export function PostThreadItem({ export function PostThreadItem({
@@ -113,7 +114,6 @@ export function PostThreadItem({
} }
function PostThreadItemDeleted() { function PostThreadItemDeleted() {
const styles = useStyles()
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}> <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
@@ -163,7 +163,7 @@ let PostThreadItemLoaded = ({
const [limitLines, setLimitLines] = React.useState( const [limitLines, setLimitLines] = React.useState(
() => countLines(richText?.text) >= MAX_POST_LINES, () => countLines(richText?.text) >= MAX_POST_LINES,
) )
const styles = useStyles() const {currentAccount} = useSession()
const hasEngagement = post.likeCount || post.repostCount const hasEngagement = post.likeCount || post.repostCount
const rootUri = record.reply?.root?.uri || post.uri const rootUri = record.reply?.root?.uri || post.uri
@@ -249,7 +249,7 @@ let PostThreadItemLoaded = ({
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
accessible={false}> accessible={false}>
<PostSandboxWarning /> <PostSandboxWarning />
<View style={styles.layout}> <View style={[styles.layout]}>
<View style={[styles.layoutAvi, {paddingBottom: 8}]}> <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
<PreviewableUserAvatar <PreviewableUserAvatar
size={42} size={42}
@@ -262,11 +262,7 @@ let PostThreadItemLoaded = ({
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<View <View
style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}> style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}>
<View style={[s.flexRow]}> <Link style={s.flex1} href={authorHref} title={authorTitle}>
<Link
style={styles.metaItem}
href={authorHref}
title={authorTitle}>
<Text <Text
type="xl-bold" type="xl-bold"
style={[pal.text]} style={[pal.text]}
@@ -278,17 +274,6 @@ let PostThreadItemLoaded = ({
)} )}
</Text> </Text>
</Link> </Link>
<TimeElapsed timestamp={post.indexedAt}>
{({timeElapsed}) => (
<Text
type="md"
style={[styles.metaItem, pal.textLight]}
title={niceDate(post.indexedAt)}>
&middot;&nbsp;{timeElapsed}
</Text>
)}
</TimeElapsed>
</View>
</View> </View>
<View style={styles.meta}> <View style={styles.meta}>
{isAuthorMuted && ( {isAuthorMuted && (
@@ -315,16 +300,16 @@ let PostThreadItemLoaded = ({
</Text> </Text>
</View> </View>
)} )}
<Link <Link style={s.flex1} href={authorHref} title={authorTitle}>
style={styles.metaItem}
href={authorHref}
title={authorTitle}>
<Text type="md" style={[pal.textLight]} numberOfLines={1}> <Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')} {sanitizeHandle(post.author.handle, '@')}
</Text> </Text>
</Link> </Link>
</View> </View>
</View> </View>
{currentAccount?.did !== post.author.did && (
<PostThreadFollowBtn did={post.author.did} />
)}
</View> </View>
<View style={[s.pl10, s.pr10, s.pb10]}> <View style={[s.pl10, s.pr10, s.pb10]}>
<ContentHider <ContentHider
@@ -490,6 +475,7 @@ let PostThreadItemLoaded = ({
: 8, : 8,
}, },
]}> ]}>
{/* If we are in threaded mode, the avatar is rendered in PostMeta */}
{!isThreadedChild && ( {!isThreadedChild && (
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<PreviewableUserAvatar <PreviewableUserAvatar
@@ -515,7 +501,12 @@ let PostThreadItemLoaded = ({
</View> </View>
)} )}
<View style={styles.layoutContent}> <View
style={
isThreadedChild
? styles.layoutContentThreaded
: styles.layoutContent
}>
<PostMeta <PostMeta
author={post.author} author={post.author}
authorHasWarning={!!post.author.labels?.length} authorHasWarning={!!post.author.labels?.length}
@@ -626,7 +617,6 @@ function PostOuterWrapper({
}>) { }>) {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const pal = usePalette('default') const pal = usePalette('default')
const styles = useStyles()
if (treeView && depth > 0) { if (treeView && depth > 0) {
return ( return (
<View <View
@@ -703,9 +693,7 @@ function ExpandedPostDetails({
) )
} }
const useStyles = () => { const styles = StyleSheet.create({
const {isDesktop} = useWebMediaQueries()
return StyleSheet.create({
outer: { outer: {
borderTopWidth: 1, borderTopWidth: 1,
paddingLeft: 8, paddingLeft: 8,
@@ -720,26 +708,23 @@ const useStyles = () => {
}, },
layout: { layout: {
flexDirection: 'row', flexDirection: 'row',
gap: 10, paddingHorizontal: 8,
paddingLeft: 8,
}, },
layoutAvi: {}, layoutAvi: {},
layoutContent: { layoutContent: {
flex: 1,
marginLeft: 10,
},
layoutContentThreaded: {
flex: 1, flex: 1,
paddingRight: 10, paddingRight: 10,
}, },
meta: { meta: {
flexDirection: 'row', flexDirection: 'row',
paddingTop: 2, paddingVertical: 2,
paddingBottom: 2,
}, },
metaExpandedLine1: { metaExpandedLine1: {
paddingTop: 0, paddingVertical: 0,
paddingBottom: 0,
},
metaItem: {
paddingRight: 5,
maxWidth: isDesktop ? 380 : 220,
}, },
alert: { alert: {
marginBottom: 6, marginBottom: 6,
@@ -793,4 +778,3 @@ const useStyles = () => {
cursor: 'pointer', cursor: 'pointer',
}, },
}) })
}
+18 -3
View File
@@ -26,14 +26,23 @@ interface Props {
onSubmitQuery: () => void onSubmitQuery: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
} }
export function SearchInput({
export interface SearchInputRef {
focus?: () => void
}
export const SearchInput = React.forwardRef<SearchInputRef, Props>(
function SearchInput(
{
query, query,
setIsInputFocused, setIsInputFocused,
onChangeQuery, onChangeQuery,
onPressCancelSearch, onPressCancelSearch,
onSubmitQuery, onSubmitQuery,
style, style,
}: Props) { },
ref,
) {
const theme = useTheme() const theme = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
@@ -44,6 +53,11 @@ export function SearchInput({
textInput.current?.blur() textInput.current?.blur()
}, [onPressCancelSearch, textInput]) }, [onPressCancelSearch, textInput])
React.useImperativeHandle(ref, () => ({
focus: () => textInput.current?.focus(),
blur: () => textInput.current?.blur(),
}))
return ( return (
<View style={[pal.viewLight, styles.container, style]}> <View style={[pal.viewLight, styles.container, style]}>
<MagnifyingGlassIcon style={[pal.icon, styles.icon]} size={21} /> <MagnifyingGlassIcon style={[pal.icon, styles.icon]} size={21} />
@@ -83,7 +97,8 @@ export function SearchInput({
) : undefined} ) : undefined}
</View> </View>
) )
} },
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+1 -1
View File
@@ -208,7 +208,7 @@ let PostCtrls = ({
{big && ( {big && (
<View style={styles.ctrlBig}> <View style={styles.ctrlBig}>
<TouchableOpacity <TouchableOpacity
testID="likeBtn" testID="shareBtn"
style={[styles.btn]} style={[styles.btn]}
onPress={onShare} onPress={onShare}
accessibilityRole="button" accessibilityRole="button"
+2
View File
@@ -39,6 +39,7 @@ import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash' import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash'
import {faComments} from '@fortawesome/free-regular-svg-icons/faComments' import {faComments} from '@fortawesome/free-regular-svg-icons/faComments'
import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass' import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
import {faDownload} from '@fortawesome/free-solid-svg-icons/faDownload'
import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis' import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope' import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope'
import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation' import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation'
@@ -143,6 +144,7 @@ library.add(
faCommentSlash, faCommentSlash,
faComments, faComments,
faCompass, faCompass,
faDownload,
faEllipsis, faEllipsis,
faEnvelope, faEnvelope,
faEye, faEye,
+2 -2
View File
@@ -37,8 +37,8 @@ export const CommunityGuidelinesScreen = (_props: Props) => {
The Community Guidelines have been moved to{' '} The Community Guidelines have been moved to{' '}
<TextLink <TextLink
style={pal.link} style={pal.link}
href="https://blueskyweb.xyz/support/community-guidelines" href="https://bsky.social/about/support/community-guidelines"
text="blueskyweb.xyz/support/community-guidelines" text="bsky.social/about/support/community-guidelines"
/> />
</Trans> </Trans>
</Text> </Text>
+2 -2
View File
@@ -34,8 +34,8 @@ export const CopyrightPolicyScreen = (_props: Props) => {
The Copyright Policy has been moved to{' '} The Copyright Policy has been moved to{' '}
<TextLink <TextLink
style={pal.link} style={pal.link}
href="https://blueskyweb.xyz/support/community-guidelines" href="https://bsky.social/about/support/copyright"
text="blueskyweb.xyz/support/community-guidelines" text="bsky.social/about/support/copyright"
/> />
</Trans> </Trans>
</Text> </Text>
+129 -51
View File
@@ -1,5 +1,11 @@
import React from 'react' import React from 'react'
import {ActivityIndicator, StyleSheet, View, type FlatList} from 'react-native' import {
ActivityIndicator,
StyleSheet,
View,
type FlatList,
Pressable,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {ViewHeader} from 'view/com/util/ViewHeader' import {ViewHeader} from 'view/com/util/ViewHeader'
@@ -8,9 +14,10 @@ import {Link} from 'view/com/util/Link'
import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types' import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {ComposeIcon2, CogIcon} from 'lib/icons' import {ComposeIcon2, CogIcon, MagnifyingGlassIcon2} from 'lib/icons'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {SearchInput} from 'view/com/util/forms/SearchInput' import {atoms as a, useTheme} from '#/alf'
import {SearchInput, SearchInputRef} from 'view/com/util/forms/SearchInput'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
import { import {
LoadingPlaceholder, LoadingPlaceholder,
@@ -35,7 +42,11 @@ import {
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {isNative} from '#/platform/detection' import {isNative, isWeb} from '#/platform/detection'
import {HITSLOP_10} from 'lib/constants'
import {IconCircle} from '#/components/IconCircle'
import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
@@ -121,6 +132,7 @@ export function FeedsScreen(_props: Props) {
} = useSearchPopularFeedsMutation() } = useSearchPopularFeedsMutation()
const {hasSession} = useSession() const {hasSession} = useSession()
const listRef = React.useRef<FlatList>(null) const listRef = React.useRef<FlatList>(null)
const searchInputRef = React.useRef<SearchInputRef>(null)
/** /**
* A search query is present. We may not have search results yet. * A search query is present. We may not have search results yet.
@@ -207,12 +219,7 @@ export function FeedsScreen(_props: Props) {
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3, // pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
}) })
} else { } else {
if (preferences?.feeds?.saved.length === 0) { if (preferences?.feeds?.saved.length !== 0) {
slices.push({
key: 'savedFeedNoResults',
type: 'savedFeedNoResults',
})
} else {
const {saved, pinned} = preferences.feeds const {saved, pinned} = preferences.feeds
slices = slices.concat( slices = slices.concat(
@@ -330,6 +337,17 @@ export function FeedsScreen(_props: Props) {
const renderHeaderBtn = React.useCallback(() => { const renderHeaderBtn = React.useCallback(() => {
return ( return (
<View style={styles.headerBtnGroup}>
<Pressable
accessibilityRole="button"
hitSlop={HITSLOP_10}
onPress={searchInputRef.current?.focus}>
<MagnifyingGlassIcon2
size={22}
strokeWidth={2}
style={pal.textLight}
/>
</Pressable>
<Link <Link
href="/settings/saved-feeds" href="/settings/saved-feeds"
hitSlop={10} hitSlop={10}
@@ -338,6 +356,7 @@ export function FeedsScreen(_props: Props) {
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}> accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
<CogIcon size={22} strokeWidth={2} style={pal.textLight} /> <CogIcon size={22} strokeWidth={2} style={pal.textLight} />
</Link> </Link>
</View>
) )
}, [pal, _]) }, [pal, _])
@@ -380,12 +399,13 @@ export function FeedsScreen(_props: Props) {
) { ) {
return ( return (
<View style={s.p10}> <View style={s.p10}>
<ActivityIndicator /> <ActivityIndicator size="large" />
</View> </View>
) )
} else if (item.type === 'savedFeedsHeader') { } else if (item.type === 'savedFeedsHeader') {
if (!isMobile) {
return ( return (
<>
{!isMobile && (
<View <View
style={[ style={[
pal.view, pal.view,
@@ -396,8 +416,19 @@ export function FeedsScreen(_props: Props) {
}, },
]}> ]}>
<Text type="title-lg" style={[pal.text, s.bold]}> <Text type="title-lg" style={[pal.text, s.bold]}>
<Trans>My Feeds</Trans> <Trans>Feeds</Trans>
</Text> </Text>
<View style={styles.headerBtnGroup}>
<Pressable
accessibilityRole="button"
hitSlop={HITSLOP_10}
onPress={searchInputRef.current?.focus}>
<MagnifyingGlassIcon2
size={22}
strokeWidth={2}
style={pal.icon}
/>
</Pressable>
<Link <Link
href="/settings/saved-feeds" href="/settings/saved-feeds"
accessibilityLabel={_(msg`Edit My Feeds`)} accessibilityLabel={_(msg`Edit My Feeds`)}
@@ -405,9 +436,11 @@ export function FeedsScreen(_props: Props) {
<CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> <CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
</Link> </Link>
</View> </View>
</View>
)}
{preferences?.feeds?.saved?.length !== 0 && <FeedsSavedHeader />}
</>
) )
}
return <View />
} else if (item.type === 'savedFeedNoResults') { } else if (item.type === 'savedFeedNoResults') {
return ( return (
<View <View
@@ -425,37 +458,10 @@ export function FeedsScreen(_props: Props) {
} else if (item.type === 'popularFeedsHeader') { } else if (item.type === 'popularFeedsHeader') {
return ( return (
<> <>
<View <FeedsAboutHeader />
style={[ <View style={{paddingHorizontal: 12, paddingBottom: 12}}>
pal.view,
styles.header,
{
// This is first in the flatlist without a session -esb
marginTop: hasSession ? 16 : 0,
paddingLeft: isMobile ? 12 : undefined,
paddingRight: 10,
paddingBottom: isMobile ? 6 : undefined,
},
]}>
<Text type="title-lg" style={[pal.text, s.bold]}>
<Trans>Discover new feeds</Trans>
</Text>
{!isMobile && (
<SearchInput
query={query}
onChangeQuery={onChangeQuery}
onPressCancelSearch={onPressCancelSearch}
onSubmitQuery={onSubmitQuery}
setIsInputFocused={onChangeSearchFocus}
style={{flex: 1, maxWidth: 250}}
/>
)}
</View>
{isMobile && (
<View style={{paddingHorizontal: 8, paddingBottom: 10}}>
<SearchInput <SearchInput
ref={searchInputRef}
query={query} query={query}
onChangeQuery={onChangeQuery} onChangeQuery={onChangeQuery}
onPressCancelSearch={onPressCancelSearch} onPressCancelSearch={onPressCancelSearch}
@@ -463,7 +469,6 @@ export function FeedsScreen(_props: Props) {
setIsInputFocused={onChangeSearchFocus} setIsInputFocused={onChangeSearchFocus}
/> />
</View> </View>
)}
</> </>
) )
} else if (item.type === 'popularFeedsLoading') { } else if (item.type === 'popularFeedsLoading') {
@@ -495,15 +500,20 @@ export function FeedsScreen(_props: Props) {
return null return null
}, },
[ [
_,
hasSession,
isMobile, isMobile,
pal, pal.view,
pal.border,
pal.text,
pal.icon,
pal.textLight,
_,
preferences?.feeds?.saved?.length,
query, query,
onChangeQuery, onChangeQuery,
onPressCancelSearch, onPressCancelSearch,
onSubmitQuery, onSubmitQuery,
onChangeSearchFocus, onChangeSearchFocus,
hasSession,
], ],
) )
@@ -518,8 +528,6 @@ export function FeedsScreen(_props: Props) {
/> />
)} )}
{preferences ? <View /> : <ActivityIndicator />}
<List <List
ref={listRef} ref={listRef}
style={[!isTabletOrDesktop && s.flex1, styles.list]} style={[!isTabletOrDesktop && s.flex1, styles.list]}
@@ -626,6 +634,71 @@ function SavedFeedLoadingPlaceholder() {
) )
} }
function FeedsSavedHeader() {
const t = useTheme()
return (
<View
style={
isWeb
? [
a.flex_row,
a.px_md,
a.py_lg,
a.gap_md,
a.border_b,
t.atoms.border_contrast_low,
]
: [
{flexDirection: 'row-reverse'},
a.p_lg,
a.gap_md,
a.border_b,
t.atoms.border_contrast_low,
]
}>
<IconCircle icon={ListSparkle_Stroke2_Corner0_Rounded} size="lg" />
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>My Feeds</Trans>
</Text>
<Text style={[t.atoms.text_contrast_high]}>
<Trans>All the feeds you've saved, right in one place.</Trans>
</Text>
</View>
</View>
)
}
function FeedsAboutHeader() {
const t = useTheme()
return (
<View
style={
isWeb
? [a.flex_row, a.px_md, a.pt_lg, a.pb_lg, a.gap_md]
: [{flexDirection: 'row-reverse'}, a.p_lg, a.gap_md]
}>
<IconCircle
icon={ListMagnifyingGlass_Stroke2_Corner0_Rounded}
size="lg"
/>
<View style={[a.flex_1, a.gap_sm]}>
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>Discover New Feeds</Trans>
</Text>
<Text style={[t.atoms.text_contrast_high]}>
<Trans>
Custom feeds built by the community bring you new experiences and
help you find the content you love.
</Trans>
</Text>
</View>
</View>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
@@ -663,4 +736,9 @@ const styles = StyleSheet.create({
paddingHorizontal: 4, paddingHorizontal: 4,
paddingVertical: 2, paddingVertical: 2,
}, },
headerBtnGroup: {
flexDirection: 'row',
gap: 15,
alignItems: 'center',
},
}) })
+2 -2
View File
@@ -34,8 +34,8 @@ export const PrivacyPolicyScreen = (_props: Props) => {
The Privacy Policy has been moved to{' '} The Privacy Policy has been moved to{' '}
<TextLink <TextLink
style={pal.link} style={pal.link}
href="https://blueskyweb.xyz/support/privacy-policy" href="https://bsky.social/about/support/privacy-policy"
text="blueskyweb.xyz/support/privacy-policy" text="bsky.social/about/support/privacy-policy"
/> />
</Trans> </Trans>
</Text> </Text>
@@ -0,0 +1,103 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Text, P} from '#/components/Typography'
import {Button, ButtonText} from '#/components/Button'
import {InlineLink, Link} from '#/components/Link'
import {getAgent, useSession} from '#/state/session'
export function ExportCarDialog({
control,
}: {
control: Dialog.DialogOuterProps['control']
}) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession()
const downloadUrl = React.useMemo(() => {
const agent = getAgent()
if (!currentAccount || !agent.session) {
return '' // shouldnt ever happen
}
// eg: https://bsky.social/xrpc/com.atproto.sync.getRepo?did=did:plc:ewvi7nxzyoun6zhxrhs64oiz
const url = new URL(agent.pdsUrl || agent.service)
url.pathname = '/xrpc/com.atproto.sync.getRepo'
url.searchParams.set('did', agent.session.did)
return url.toString()
}, [currentAccount])
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
<Trans>Export My Data</Trans>
</Text>
<P nativeID="dialog-description" style={[a.text_sm]}>
<Trans>
Your account repository, containing all public data records, can
be downloaded as a "CAR" file. This file does not include media
embeds, such as images, or your private data, which must be
fetched separately.
</Trans>
</P>
<Link
variant="solid"
color="primary"
size="large"
label={_(msg`Download CAR file`)}
to={downloadUrl}
download="repo.car">
<ButtonText>
<Trans>Download CAR file</Trans>
</ButtonText>
</Link>
<P
style={[
a.py_xs,
t.atoms.text_contrast_medium,
a.text_sm,
a.leading_snug,
a.flex_1,
]}>
<Trans>
This feature is in beta. You can read more about repository
exports in{' '}
<InlineLink
to="https://atproto.com/blog/repo-export"
style={[a.text_sm]}>
this blogpost.
</InlineLink>
</Trans>
</P>
<View style={gtMobile && [a.flex_row, a.justify_end]}>
<Button
testID="doneBtn"
variant="outline"
color="primary"
size={gtMobile ? 'small' : 'large'}
onPress={() => control.close()}
label={_(msg`Done`)}>
{_(msg`Done`)}
</Button>
</View>
{!gtMobile && <View style={{height: 40}} />}
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
@@ -17,14 +17,6 @@ import {
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import * as AppInfo from 'lib/app-info' import * as AppInfo from 'lib/app-info'
import {s, colors} from 'lib/styles'
import {ScrollView} from '../com/util/Views'
import {Link, TextLink} from '../com/util/Link'
import {Text} from '../com/util/text/Text'
import * as Toast from '../com/util/Toast'
import {UserAvatar} from '../com/util/UserAvatar'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useCustomPalette} from 'lib/hooks/useCustomPalette' import {useCustomPalette} from 'lib/hooks/useCustomPalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -34,8 +26,6 @@ import {NavigationProp} from 'lib/routes/types'
import {HandIcon, HashtagIcon} from 'lib/icons' import {HandIcon, HashtagIcon} from 'lib/icons'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import { import {
@@ -48,22 +38,12 @@ import {
useRequireAltTextEnabled, useRequireAltTextEnabled,
useSetRequireAltTextEnabled, useSetRequireAltTextEnabled,
} from '#/state/preferences' } from '#/state/preferences'
import { import {useSession, useSessionApi, SessionAccount} from '#/state/session'
useSession,
useSessionApi,
SessionAccount,
getAgent,
} from '#/state/session'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useClearPreferencesMutation} from '#/state/queries/preferences' import {useClearPreferencesMutation} from '#/state/queries/preferences'
import {useInviteCodesQuery} from '#/state/queries/invites' // TODO import {useInviteCodesQuery} from '#/state/queries/invites'
import {clear as clearStorage} from '#/state/persisted/store' import {clear as clearStorage} from '#/state/persisted/store'
import {clearLegacyStorage} from '#/state/persisted/legacy' import {clearLegacyStorage} from '#/state/persisted/legacy'
// TEMPORARY (APP-700)
// remove after backend testing finishes
// -prf
import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header'
import {STATUS_PAGE_URL} from 'lib/constants' import {STATUS_PAGE_URL} from 'lib/constants'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -75,6 +55,19 @@ import {
useSetInAppBrowser, useSetInAppBrowser,
} from '#/state/preferences/in-app-browser' } from '#/state/preferences/in-app-browser'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useDialogControl} from '#/components/Dialog'
import {s, colors} from 'lib/styles'
import {ScrollView} from 'view/com/util/Views'
import {Link, TextLink} from 'view/com/util/Link'
import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {ExportCarDialog} from './ExportCarDialog'
function SettingsAccountCard({account}: {account: SessionAccount}) { function SettingsAccountCard({account}: {account: SessionAccount}) {
const pal = usePalette('default') const pal = usePalette('default')
@@ -159,23 +152,22 @@ export function SettingsScreen({}: Props) {
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const {isSwitchingAccounts, accounts, currentAccount} = useSession() const {isSwitchingAccounts, accounts, currentAccount} = useSession()
const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting(
getAgent(),
)
const {mutate: clearPreferences} = useClearPreferencesMutation() const {mutate: clearPreferences} = useClearPreferencesMutation()
const {data: invites} = useInviteCodesQuery() // TODO
const invitesAvailable = invites?.available?.length ?? 0 // const {data: invites} = useInviteCodesQuery()
// const invitesAvailable = invites?.available?.length ?? 0
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
const exportCarControl = useDialogControl()
const primaryBg = useCustomPalette<ViewStyle>({ // const primaryBg = useCustomPalette<ViewStyle>({
light: {backgroundColor: colors.blue0}, // light: {backgroundColor: colors.blue0},
dark: {backgroundColor: colors.blue6}, // dark: {backgroundColor: colors.blue6},
}) // })
const primaryText = useCustomPalette<TextStyle>({ // const primaryText = useCustomPalette<TextStyle>({
light: {color: colors.blue3}, // light: {color: colors.blue3},
dark: {color: colors.blue2}, // dark: {color: colors.blue2},
}) // })
const dangerBg = useCustomPalette<ViewStyle>({ const dangerBg = useCustomPalette<ViewStyle>({
light: {backgroundColor: colors.red1}, light: {backgroundColor: colors.red1},
@@ -214,10 +206,16 @@ export function SettingsScreen({}: Props) {
}) })
}, [track, queryClient, openModal, currentAccount]) }, [track, queryClient, openModal, currentAccount])
const onPressExportRepository = React.useCallback(() => {
exportCarControl.open()
}, [exportCarControl])
/* TODO
const onPressInviteCodes = React.useCallback(() => { const onPressInviteCodes = React.useCallback(() => {
track('Settings:InvitecodesButtonClicked') track('Settings:InvitecodesButtonClicked')
openModal({name: 'invite-codes'}) openModal({name: 'invite-codes'})
}, [track, openModal]) }, [track, openModal])
*/
const onPressLanguageSettings = React.useCallback(() => { const onPressLanguageSettings = React.useCallback(() => {
navigation.navigate('LanguageSettings') navigation.navigate('LanguageSettings')
@@ -282,6 +280,8 @@ export function SettingsScreen({}: Props) {
return ( return (
<View style={s.hContentRegion} testID="settingsScreen"> <View style={s.hContentRegion} testID="settingsScreen">
<ExportCarDialog control={exportCarControl} />
<SimpleViewHeader <SimpleViewHeader
showBackButton={isMobile} showBackButton={isMobile}
style={[ style={[
@@ -395,6 +395,8 @@ export function SettingsScreen({}: Props) {
<View style={styles.spacer20} /> <View style={styles.spacer20} />
{/* TODO (
<>
<Text type="xl-bold" style={[pal.text, styles.heading]}> <Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Invite a Friend</Trans> <Trans>Invite a Friend</Trans>
</Text> </Text>
@@ -425,7 +427,9 @@ export function SettingsScreen({}: Props) {
} }
/> />
</View> </View>
<Text type="lg" style={invitesAvailable > 0 ? pal.link : pal.text}> <Text
type="lg"
style={invitesAvailable > 0 ? pal.link : pal.text}>
{invites?.disabled ? ( {invites?.disabled ? (
<Trans> <Trans>
Your invite codes are hidden when logged in using an App Your invite codes are hidden when logged in using an App
@@ -440,6 +444,8 @@ export function SettingsScreen({}: Props) {
</TouchableOpacity> </TouchableOpacity>
<View style={styles.spacer20} /> <View style={styles.spacer20} />
</>
)*/}
<Text type="xl-bold" style={[pal.text, styles.heading]}> <Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Accessibility</Trans> <Trans>Accessibility</Trans>
@@ -735,6 +741,29 @@ export function SettingsScreen({}: Props) {
<Trans>Change Password</Trans> <Trans>Change Password</Trans>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
testID="exportRepositoryBtn"
style={[
styles.linkCard,
pal.view,
isSwitchingAccounts && styles.dimmed,
]}
onPress={isSwitchingAccounts ? undefined : onPressExportRepository}
accessibilityRole="button"
accessibilityLabel={_(msg`Export my data`)}
accessibilityHint={_(
msg`Download Bluesky account data (repository)`,
)}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="download"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text} numberOfLines={1}>
<Trans>Export My Data</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[pal.view, styles.linkCard]} style={[pal.view, styles.linkCard]}
onPress={onPressDeleteAccount} onPress={onPressDeleteAccount}
@@ -756,9 +785,6 @@ export function SettingsScreen({}: Props) {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.spacer20} /> <View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Developer Tools</Trans>
</Text>
<TouchableOpacity <TouchableOpacity
style={[pal.view, styles.linkCardNoIcon]} style={[pal.view, styles.linkCardNoIcon]}
onPress={onPressSystemLog} onPress={onPressSystemLog}
@@ -769,14 +795,6 @@ export function SettingsScreen({}: Props) {
<Trans>System log</Trans> <Trans>System log</Trans>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{__DEV__ ? (
<ToggleButton
type="default-light"
label="Experiment: Use AppView Proxy"
isSelected={debugHeaderEnabled}
onPress={toggleDebugHeader}
/>
) : null}
{__DEV__ ? ( {__DEV__ ? (
<> <>
<TouchableOpacity <TouchableOpacity
@@ -863,13 +881,13 @@ export function SettingsScreen({}: Props) {
<TextLink <TextLink
type="md" type="md"
style={pal.link} style={pal.link}
href="https://blueskyweb.xyz/support/tos" href="https://bsky.social/about/support/tos"
text={_(msg`Terms of Service`)} text={_(msg`Terms of Service`)}
/> />
<TextLink <TextLink
type="md" type="md"
style={pal.link} style={pal.link}
href="https://blueskyweb.xyz/support/privacy-policy" href="https://bsky.social/about/support/privacy-policy"
text={_(msg`Privacy Policy`)} text={_(msg`Privacy Policy`)}
/> />
</View> </View>
+4 -4
View File
@@ -14,19 +14,19 @@ export function Links() {
<View style={[a.gap_md, a.align_start]}> <View style={[a.gap_md, a.align_start]}>
<InlineLink <InlineLink
to="https://blueskyweb.xyz" to="https://bsky.social"
warnOnMismatchingTextChild warnOnMismatchingTextChild
style={[a.text_md]}> style={[a.text_md]}>
External External
</InlineLink> </InlineLink>
<InlineLink to="https://blueskyweb.xyz" style={[a.text_md]}> <InlineLink to="https://bsky.social" style={[a.text_md]}>
<H3>External with custom children</H3> <H3>External with custom children</H3>
</InlineLink> </InlineLink>
<InlineLink <InlineLink
to="https://blueskyweb.xyz" to="https://bsky.social"
warnOnMismatchingTextChild warnOnMismatchingTextChild
style={[a.text_lg]}> style={[a.text_lg]}>
https://blueskyweb.xyz https://bsky.social
</InlineLink> </InlineLink>
<InlineLink <InlineLink
to="https://bsky.app/profile/bsky.app" to="https://bsky.app/profile/bsky.app"
+2 -2
View File
@@ -21,11 +21,11 @@ export function Typography() {
<RichText <RichText
resolveFacets resolveFacets
value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`} value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
/> />
<RichText <RichText
resolveFacets resolveFacets
value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`} value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
style={[a.text_xl]} style={[a.text_xl]}
/> />
</View> </View>
+2 -2
View File
@@ -33,8 +33,8 @@ export const TermsOfServiceScreen = (_props: Props) => {
<Trans>The Terms of Service have been moved to</Trans>{' '} <Trans>The Terms of Service have been moved to</Trans>{' '}
<TextLink <TextLink
style={pal.link} style={pal.link}
href="https://blueskyweb.xyz/support/tos" href="https://bsky.social/about/support/tos"
text="blueskyweb.xyz/support/tos" text="bsky.social/about/support/tos"
/> />
</Text> </Text>
</View> </View>
+3 -56
View File
@@ -44,12 +44,10 @@ import {formatCountShortOnly} from 'view/com/util/numeric/format'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerOpen} from '#/state/shell'
import {useModalControls} from '#/state/modals'
import {useSession, SessionAccount} from '#/state/session' import {useSession, SessionAccount} from '#/state/session'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {emitSoftReset} from '#/state/events' import {emitSoftReset} from '#/state/events'
import {useInviteCodesQuery} from '#/state/queries/invites'
import {NavSignupCard} from '#/view/shell/NavSignupCard' import {NavSignupCard} from '#/view/shell/NavSignupCard'
import {TextLink} from '../com/util/Link' import {TextLink} from '../com/util/Link'
@@ -228,8 +226,7 @@ let DrawerContent = ({}: {}): React.ReactNode => {
{hasSession ? ( {hasSession ? (
<> <>
<InviteCodes /> <View style={{height: 16}} />
<View style={{height: 10}} />
<SearchMenuItem isActive={isAtSearch} onPress={onPressSearch} /> <SearchMenuItem isActive={isAtSearch} onPress={onPressSearch} />
<HomeMenuItem isActive={isAtHome} onPress={onPressHome} /> <HomeMenuItem isActive={isAtHome} onPress={onPressHome} />
<NotificationsMenuItem <NotificationsMenuItem
@@ -255,13 +252,13 @@ let DrawerContent = ({}: {}): React.ReactNode => {
<TextLink <TextLink
type="md" type="md"
style={pal.link} style={pal.link}
href="https://blueskyweb.xyz/support/tos" href="https://bsky.social/about/support/tos"
text={_(msg`Terms of Service`)} text={_(msg`Terms of Service`)}
/> />
<TextLink <TextLink
type="md" type="md"
style={pal.link} style={pal.link}
href="https://blueskyweb.xyz/support/privacy-policy" href="https://bsky.social/about/support/privacy-policy"
text={_(msg`Privacy Policy`)} text={_(msg`Privacy Policy`)}
/> />
</View> </View>
@@ -621,56 +618,6 @@ function MenuItem({
) )
} }
let InviteCodes = ({}: {}): React.ReactNode => {
const {track} = useAnalytics()
const setDrawerOpen = useSetDrawerOpen()
const pal = usePalette('default')
const {data: invites} = useInviteCodesQuery()
const invitesAvailable = invites?.available?.length ?? 0
const {openModal} = useModalControls()
const {_} = useLingui()
const onPress = React.useCallback(() => {
track('Menu:ItemClicked', {url: '#invite-codes'})
setDrawerOpen(false)
openModal({name: 'invite-codes'})
}, [openModal, track, setDrawerOpen])
return (
<TouchableOpacity
testID="menuItemInviteCodes"
style={styles.inviteCodes}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={_(msg`Invite codes: ${invitesAvailable} available`)}
accessibilityHint={_(msg`Opens list of invite codes`)}
disabled={invites?.disabled}>
<FontAwesomeIcon
icon="ticket"
style={[
styles.inviteCodesIcon,
invitesAvailable > 0 ? pal.link : pal.textLight,
]}
size={18}
/>
<Text
type="lg-medium"
style={invitesAvailable > 0 ? pal.link : pal.textLight}>
{invites?.disabled ? (
<Trans>
Your invite codes are hidden when logged in using an App Password
</Trans>
) : invitesAvailable === 1 ? (
<Trans>{invitesAvailable} invite code available</Trans>
) : (
<Trans>{invitesAvailable} invite codes available</Trans>
)}
</Text>
</TouchableOpacity>
)
}
InviteCodes = React.memo(InviteCodes)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
view: { view: {
flex: 1, flex: 1,
+1 -1
View File
@@ -334,7 +334,7 @@ export function DesktopLeftNav() {
} }
iconFilled={ iconFilled={
<HashtagIcon <HashtagIcon
strokeWidth={2.5} strokeWidth={4}
style={pal.text as FontAwesomeIconStyle} style={pal.text as FontAwesomeIconStyle}
size={isDesktop ? 24 : 28} size={isDesktop ? 24 : 28}
/> />
+4 -87
View File
@@ -1,6 +1,5 @@
import React from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {DesktopSearch} from './Search' import {DesktopSearch} from './Search'
import {DesktopFeeds} from './Feeds' import {DesktopFeeds} from './Feeds'
@@ -9,12 +8,9 @@ import {TextLink} from 'view/com/util/Link'
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {formatCount} from 'view/com/util/numeric/format'
import {useModalControls} from '#/state/modals'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Plural, Trans, msg, plural} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useInviteCodesQuery} from '#/state/queries/invites'
export function DesktopRightNav({routeName}: {routeName: string}) { export function DesktopRightNav({routeName}: {routeName: string}) {
const pal = usePalette('default') const pal = usePalette('default')
@@ -80,7 +76,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
<TextLink <TextLink
type="md" type="md"
style={pal.link} style={pal.link}
href="https://blueskyweb.xyz/support/privacy-policy" href="https://bsky.social/about/support/privacy-policy"
text={_(msg`Privacy`)} text={_(msg`Privacy`)}
/> />
<Text type="md" style={pal.textLight}> <Text type="md" style={pal.textLight}>
@@ -89,7 +85,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
<TextLink <TextLink
type="md" type="md"
style={pal.link} style={pal.link}
href="https://blueskyweb.xyz/support/tos" href="https://bsky.social/about/support/tos"
text={_(msg`Terms`)} text={_(msg`Terms`)}
/> />
<Text type="md" style={pal.textLight}> <Text type="md" style={pal.textLight}>
@@ -103,78 +99,11 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
/> />
</View> </View>
</View> </View>
{hasSession && <InviteCodes />}
</View> </View>
</View> </View>
) )
} }
function InviteCodes() {
const pal = usePalette('default')
const {openModal} = useModalControls()
const {data: invites} = useInviteCodesQuery()
const invitesAvailable = invites?.available?.length ?? 0
const {_} = useLingui()
const onPress = React.useCallback(() => {
openModal({name: 'invite-codes'})
}, [openModal])
if (!invites) {
return null
}
if (invites?.disabled) {
return (
<View style={[styles.inviteCodes, pal.border]}>
<FontAwesomeIcon
icon="ticket"
style={[styles.inviteCodesIcon, pal.textLight]}
size={16}
/>
<Text type="md-medium" style={pal.textLight}>
<Trans>
Your invite codes are hidden when logged in using an App Password
</Trans>
</Text>
</View>
)
}
return (
<TouchableOpacity
style={[styles.inviteCodes, pal.border]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={_(
plural(invitesAvailable, {
one: 'Invite codes: # available',
other: 'Invite codes: # available',
}),
)}
accessibilityHint={_(msg`Opens list of invite codes`)}>
<FontAwesomeIcon
icon="ticket"
style={[
styles.inviteCodesIcon,
invitesAvailable > 0 ? pal.link : pal.textLight,
]}
size={16}
/>
<Text
type="md-medium"
style={invitesAvailable > 0 ? pal.link : pal.textLight}>
<Plural
value={formatCount(invitesAvailable)}
one="# invite code available"
other="# invite codes available"
/>
</Text>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
rightNav: { rightNav: {
// @ts-ignore web only // @ts-ignore web only
@@ -193,18 +122,6 @@ const styles = StyleSheet.create({
messageLine: { messageLine: {
marginBottom: 10, marginBottom: 10,
}, },
inviteCodes: {
borderTopWidth: 1,
paddingHorizontal: 12,
paddingVertical: 12,
flexDirection: 'row',
},
inviteCodesIcon: {
marginTop: 2,
marginRight: 6,
flexShrink: 0,
},
desktopFeedsContainer: { desktopFeedsContainer: {
borderTopWidth: 1, borderTopWidth: 1,
borderBottomWidth: 1, borderBottomWidth: 1,
+5
View File
@@ -10,6 +10,11 @@
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1.00001, viewport-fit=cover" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1.00001, viewport-fit=cover"
/> />
<!--
Preconnect to essential domains
-->
<link rel="preconnect" href="https://bsky.social">
<link rel="preconnect" href="https://bsky.network">
<title>%WEB_TITLE%</title> <title>%WEB_TITLE%</title>
<style> <style>
/** /**