Merge remote-tracking branch 'origin/main' into 3p-moderators
* origin/main: (69 commits) Update .po files use `showControls` to show/hide live text icon on ios (#2982) Fix dim mode unread notif color Make dim theme dim (#2966) Add handle validation to create account UI (#2959) Normalize relative day (#2874) increase timeout to 15s (#2958) use `useOpenLink` hook for links in ALF (#2975) Rename Home Feed Prefs to Following Feed Prefs (#2965) Refactor feed header components (#2964) patch react-navigation to fix history bug (#2955) Use EAS managed build number, run build/submit on GH Actions (#2841) Fix `numberOfLines` not updating on iOS 15 (#2956) Fix UITextView line height adjustment for DynamicType, always use the max width for the view (#2916) Navigate back from a deleted post's route (#2948) Add optional close callback to Dialog (#2947) Fix flash when pressing into just-created post (#2945) Last usage (#2944) Update blogpost URL in ExportCarDialog.tsx (#2939) Prefer full posts for post thread placeholder (#2943) ...
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: Build and Submit Android
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
type: choice
|
||||
description: Build profile to use
|
||||
options:
|
||||
- production
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Submit Android
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
cache: yarn
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⛏️ Setup EAS local builds
|
||||
run: yarn global add eas-cli-local-build-plugin
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: yarn use-build-number eas build -p android --profile production --local --output build.aab --non-interactive
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p android --non-interactive --path build.aab
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: Build and Submit iOS
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 5 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
type: choice
|
||||
description: Build profile to use
|
||||
options:
|
||||
- production
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
cache: yarn
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⛏️ Setup EAS local builds
|
||||
run: yarn global add eas-cli-local-build-plugin
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
with:
|
||||
version: 1.14.3
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@v3
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
# We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
|
||||
# change unless the yarn version changes as well.
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: yarn use-build-number eas build -p ios --profile production --local --output build.ipa --non-interactive
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p ios --non-interactive --path build.ipa
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Deploy Nightly Testflight Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 5 * * *'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Deploy Nightly Testflight Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: |
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
cache: yarn
|
||||
|
||||
- name: Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Bump build number
|
||||
run: yarn bump:ios
|
||||
|
||||
- name: EAS build and submit
|
||||
run: eas build -p ios --profile production --auto-submit --non-interactive
|
||||
|
||||
- name: Commit
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: Nightly iOS Build Bump
|
||||
branch: main
|
||||
commit_user_name: github-actions[bot]
|
||||
commit_user_email: github-actions[bot]@users.noreply.github.com
|
||||
@@ -18,9 +18,10 @@ describe('Create account', () => {
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
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')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('emailInput')).typeText('example@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
@@ -33,12 +34,6 @@ describe('Create account', () => {
|
||||
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await expect(element(by.id('welcomeOnboarding'))).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()
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('Home screen', () => {
|
||||
const carlaPosts = by.id('feedItem-by-carla.test')
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
|
||||
await expect(
|
||||
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 expect(
|
||||
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can repost posts', async () => {
|
||||
const carlaPosts = by.id('feedItem-by-carla.test')
|
||||
await expect(
|
||||
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('repostBtn').withAncestor(carlaPosts)).atIndex(0).tap()
|
||||
await expect(element(by.id('repostModal'))).toBeVisible()
|
||||
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('repostCount').withAncestor(carlaPosts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can report posts', async () => {
|
||||
|
||||
@@ -28,9 +28,10 @@ describe('invite-codes', () => {
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
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')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('inviteCodeInput')).typeText(inviteCode)
|
||||
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 device.takeScreenshot('4- entered handle')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
await expect(element(by.id('welcomeOnboarding'))).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()
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,16 +28,17 @@ describe('invite-codes', () => {
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
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')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('inviteCodeInput')).typeText(inviteCode)
|
||||
await element(by.id('emailInput')).typeText('example@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
await device.takeScreenshot('4- entered account details')
|
||||
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 device.takeScreenshot('5- requested code')
|
||||
await element(by.id('codeInput')).typeText('000000')
|
||||
@@ -46,12 +47,6 @@ describe('invite-codes', () => {
|
||||
await element(by.id('handleInput')).typeText('e2e-test')
|
||||
await device.takeScreenshot('7- entered handle')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
await expect(element(by.id('welcomeOnboarding'))).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()
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
|
||||
import {openApp, loginAsAlice, loginAsBob, createServer} from '../util'
|
||||
|
||||
describe('Mod lists', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -62,38 +62,39 @@ describe('Mod lists', () => {
|
||||
.withTimeout(5000)
|
||||
})
|
||||
|
||||
it('Set avi via the edit modlist modal', async () => {
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit list details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Library')).tap()
|
||||
await sleep(3e3)
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
// DISABLED e2e environment is real finicky about avatar uploads -prf
|
||||
// it('Set avi via the edit modlist modal', async () => {
|
||||
// await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
// await element(by.id('headerDropdownBtn')).tap()
|
||||
// await element(by.text('Edit list details')).tap()
|
||||
// await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
// await element(by.id('changeAvatarBtn')).tap()
|
||||
// await element(by.text('Library')).tap()
|
||||
// await sleep(3e3)
|
||||
// await element(by.id('saveBtn')).tap()
|
||||
// await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
// await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
// // have to wait for the toast to clear
|
||||
// await waitFor(element(by.id('headerDropdownBtn')))
|
||||
// .toBeVisible()
|
||||
// .withTimeout(5000)
|
||||
// })
|
||||
|
||||
it('Remove avi via the edit modlist modal', async () => {
|
||||
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
await element(by.text('Edit list details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Remove')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('headerDropdownBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
})
|
||||
// it('Remove avi via the edit modlist modal', async () => {
|
||||
// await expect(element(by.id('userAvatarImage'))).toExist()
|
||||
// await element(by.id('headerDropdownBtn')).tap()
|
||||
// await element(by.text('Edit list details')).tap()
|
||||
// await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
// await element(by.id('changeAvatarBtn')).tap()
|
||||
// await element(by.text('Remove')).tap()
|
||||
// await element(by.id('saveBtn')).tap()
|
||||
// await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
// await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
// // have to wait for the toast to clear
|
||||
// await waitFor(element(by.id('headerDropdownBtn')))
|
||||
// .toBeVisible()
|
||||
// .withTimeout(5000)
|
||||
// })
|
||||
|
||||
it('Delete the modlist', async () => {
|
||||
await element(by.id('headerDropdownBtn')).tap()
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('Profile screen', () => {
|
||||
const posts = by.id('feedItem-by-bob.test')
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
|
||||
await expect(
|
||||
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 expect(
|
||||
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can repost posts', async () => {
|
||||
const posts = by.id('feedItem-by-bob.test')
|
||||
await expect(
|
||||
element(by.id('repostCount').withAncestor(posts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('repostBtn').withAncestor(posts)).atIndex(0).tap()
|
||||
await expect(element(by.id('repostModal'))).toBeVisible()
|
||||
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('repostCount').withAncestor(posts)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can report posts', async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('Self-labeling', () => {
|
||||
await element(by.id('composerPublishBtn')).tap()
|
||||
await expect(element(by.id('composeFAB'))).toBeVisible()
|
||||
const posts = by.id('feedItem-by-alice.test')
|
||||
await element(by.id('e2eRefreshHome')).tap()
|
||||
await expect(
|
||||
element(by.id('contentHider-embed').withAncestor(posts)).atIndex(0),
|
||||
).toExist()
|
||||
|
||||
@@ -18,9 +18,10 @@ describe('Create account', () => {
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
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')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('emailInput')).typeText('text-verification@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
@@ -40,13 +41,7 @@ describe('Create account', () => {
|
||||
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await expect(element(by.id('welcomeOnboarding'))).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()
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
|
||||
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 element(by.id('selectServiceButton')).tap()
|
||||
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')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('emailInput')).typeText('text-verification2@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('Thread screen', () => {
|
||||
const post = by.id('postThreadItem-by-carla.test')
|
||||
await expect(
|
||||
element(by.id('likeCount').withAncestor(post)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
|
||||
await expect(
|
||||
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 expect(
|
||||
element(by.id('likeCount').withAncestor(post)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can repost the root post', async () => {
|
||||
@@ -85,7 +85,7 @@ describe('Thread screen', () => {
|
||||
const post = by.id('postThreadItem-by-carla.test')
|
||||
await expect(
|
||||
element(by.id('repostCount').withAncestor(post)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
|
||||
await expect(element(by.id('repostModal'))).toBeVisible()
|
||||
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('repostCount').withAncestor(post)).atIndex(0),
|
||||
).toHaveText('0')
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
it('Can report the root post', async () => {
|
||||
|
||||
+2
-1
@@ -56,9 +56,10 @@ export async function login(
|
||||
if (takeScreenshots) {
|
||||
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')).tapReturnKey()
|
||||
await element(by.id('customServerSelectBtn')).tap()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
if (takeScreenshots) {
|
||||
await device.takeScreenshot('3- input custom service')
|
||||
}
|
||||
|
||||
+4
-19
@@ -11,24 +11,12 @@ const DARK_SPLASH_CONFIG = {
|
||||
resizeMode: 'cover',
|
||||
}
|
||||
|
||||
module.exports = function () {
|
||||
module.exports = function (config) {
|
||||
/**
|
||||
* App version number. Should be incremented as part of a release cycle.
|
||||
*/
|
||||
const VERSION = pkg.version
|
||||
|
||||
/**
|
||||
* iOS build number. Must be incremented for each TestFlight version.
|
||||
* WARNING: Always leave this variable on line 24! If it is moved, you need to update ./scripts/bumpIosBuildNumber.sh
|
||||
*/
|
||||
const IOS_BUILD_NUMBER = '3'
|
||||
|
||||
/**
|
||||
* Android build number. Must be incremented for each release.
|
||||
* WARNING: Always leave this variable on line 30! If it is moved, you need to update ./scripts/bumpAndroidBuildNumber.sh
|
||||
*/
|
||||
const ANDROID_VERSION_CODE = 61
|
||||
|
||||
/**
|
||||
* Uses built-in Expo env vars
|
||||
*
|
||||
@@ -36,11 +24,10 @@ module.exports = function () {
|
||||
*/
|
||||
const PLATFORM = process.env.EAS_BUILD_PLATFORM
|
||||
|
||||
/**
|
||||
* Additional granularity for the `dist` field
|
||||
*/
|
||||
const DIST_BUILD_NUMBER =
|
||||
PLATFORM === 'android' ? ANDROID_VERSION_CODE : IOS_BUILD_NUMBER
|
||||
PLATFORM === 'android'
|
||||
? process.env.BSKY_ANDROID_VERSION_CODE
|
||||
: process.env.BSKY_IOS_BUILD_NUMBER
|
||||
|
||||
return {
|
||||
expo: {
|
||||
@@ -57,7 +44,6 @@ module.exports = function () {
|
||||
userInterfaceStyle: 'automatic',
|
||||
splash: SPLASH_CONFIG,
|
||||
ios: {
|
||||
buildNumber: IOS_BUILD_NUMBER,
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
config: {
|
||||
@@ -85,7 +71,6 @@ module.exports = function () {
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
android: {
|
||||
versionCode: ANDROID_VERSION_CODE,
|
||||
icon: './assets/icon.png',
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/icon-android-foreground.png',
|
||||
|
||||
@@ -191,7 +191,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/settings", server.WebGeneric)
|
||||
e.GET("/settings/language", server.WebGeneric)
|
||||
e.GET("/settings/app-passwords", server.WebGeneric)
|
||||
e.GET("/settings/home-feed", server.WebGeneric)
|
||||
e.GET("/settings/following-feed", server.WebGeneric)
|
||||
e.GET("/settings/saved-feeds", server.WebGeneric)
|
||||
e.GET("/settings/threads", server.WebGeneric)
|
||||
e.GET("/settings/external-embeds", server.WebGeneric)
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
|
||||
<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>
|
||||
|
||||
<!-- Hello Humans! API docs at https://atproto.com -->
|
||||
@@ -206,10 +211,11 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
<link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="theme-color">
|
||||
<meta name="application-name" content="Bluesky">
|
||||
<meta name="generator" content="bskyweb">
|
||||
<meta property="og:site_name" content="Bluesky Social" />
|
||||
<link type="application/activity+json" href="" />
|
||||
|
||||
{% block html_head_extra -%}{%- endblock %}
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 3.8.1",
|
||||
"promptToConfigurePushNotifications": false
|
||||
"promptToConfigurePushNotifications": false,
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"base": {
|
||||
@@ -28,7 +29,21 @@
|
||||
"production": {
|
||||
"extends": "base",
|
||||
"ios": {
|
||||
"resourceClass": "large"
|
||||
"resourceClass": "large",
|
||||
"autoIncrement": true
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"channel": "production"
|
||||
},
|
||||
"github": {
|
||||
"extends": "base",
|
||||
"ios": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"channel": "production"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ module.exports = {
|
||||
'uk',
|
||||
'ca',
|
||||
'zh-CN',
|
||||
'it',
|
||||
],
|
||||
catalogs: [
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
RCT_REMAP_SHADOW_PROPERTY(numberOfLines, numberOfLines, NSInteger)
|
||||
RCT_REMAP_SHADOW_PROPERTY(allowsFontScaling, allowsFontScaling, BOOL)
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(numberOfLines, NSInteger)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onTextLayout, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(ellipsizeMode, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(selectable, BOOL)
|
||||
|
||||
@@ -40,19 +40,19 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
self.setAttributedText()
|
||||
}
|
||||
|
||||
// Tell yoga not to use flexbox
|
||||
// Returning true here will tell Yoga to not use flexbox and instead use our custom measure func.
|
||||
override func isYogaLeafNode() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// We only need to insert text children
|
||||
// We should only insert children that are UITextView shadows
|
||||
override func insertReactSubview(_ subview: RCTShadowView!, at atIndex: Int) {
|
||||
if subview.isKind(of: RNUITextViewChildShadow.self) {
|
||||
super.insertReactSubview(subview, at: atIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Whenever the subvies update, set the text
|
||||
// Every time the subviews change, we need to reformat and render the text.
|
||||
override func didUpdateReactSubviews() {
|
||||
self.setAttributedText()
|
||||
}
|
||||
@@ -64,7 +64,7 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
return
|
||||
}
|
||||
|
||||
// Update the text
|
||||
// Since we are inside the shadow view here, we have to find the real view and update the text.
|
||||
self.bridge.uiManager.addUIBlock { uiManager, viewRegistry in
|
||||
guard let textView = viewRegistry?[self.reactTag] as? RNUITextView else {
|
||||
return
|
||||
@@ -100,18 +100,25 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
// Create the attributed string with the generic attributes
|
||||
let string = NSMutableAttributedString(string: child.text, attributes: attributes)
|
||||
|
||||
// Set the paragraph style attributes if necessary
|
||||
// Set the paragraph style attributes if necessary. We can check this by seeing if the provided
|
||||
// line height is not 0.0.
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
if child.lineHeight != 0.0 {
|
||||
paragraphStyle.minimumLineHeight = child.lineHeight
|
||||
paragraphStyle.maximumLineHeight = child.lineHeight
|
||||
// Whenever we change the line height for the text, we are also removing the DynamicType
|
||||
// adjustment for line height. We need to get the multiplier and apply that to the
|
||||
// line height.
|
||||
let scaleMultiplier = scaledFontSize / child.fontSize
|
||||
paragraphStyle.minimumLineHeight = child.lineHeight * scaleMultiplier
|
||||
paragraphStyle.maximumLineHeight = child.lineHeight * scaleMultiplier
|
||||
|
||||
string.addAttribute(
|
||||
NSAttributedString.Key.paragraphStyle,
|
||||
value: paragraphStyle,
|
||||
range: NSMakeRange(0, string.length)
|
||||
)
|
||||
|
||||
// Store that height
|
||||
// To calcualte the size of the text without creating a new UILabel or UITextView, we have
|
||||
// to store this line height for later.
|
||||
self.lineHeight = child.lineHeight
|
||||
} else {
|
||||
self.lineHeight = font.lineHeight
|
||||
@@ -124,24 +131,22 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
self.dirtyLayout()
|
||||
}
|
||||
|
||||
// Create a YGSize based on the max width
|
||||
// To create the needed size we need to:
|
||||
// 1. Get the max size that we can use for the view
|
||||
// 2. Calculate the height of the text based on that max size
|
||||
// 3. Determine how many lines the text is, and limit that number if it exceeds the max
|
||||
// 4. Set the frame size and return the YGSize. YGSize requires Float values while CGSize needs CGFloat
|
||||
func getNeededSize(maxWidth: Float) -> YGSize {
|
||||
// Create the max size and figure out the size of the entire text
|
||||
let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
|
||||
let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
|
||||
|
||||
// Figure out how many total lines there are
|
||||
let totalLines = Int(ceil(textSize.height / self.lineHeight))
|
||||
var totalLines = Int(ceil(textSize.height / self.lineHeight))
|
||||
|
||||
// Default to the text size
|
||||
var neededSize: CGSize = textSize.size
|
||||
|
||||
// If the total lines > max number, return size with the max
|
||||
if self.numberOfLines != 0, totalLines > self.numberOfLines {
|
||||
neededSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(self.numberOfLines) * self.lineHeight))
|
||||
totalLines = self.numberOfLines
|
||||
}
|
||||
|
||||
self.frameSize = neededSize
|
||||
return YGSize(width: Float(neededSize.width), height: Float(neededSize.height))
|
||||
self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight))
|
||||
return YGSize(width: Float(self.frameSize.width), height: Float(self.frameSize.height))
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.68.0",
|
||||
"version": "1.69.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -12,8 +12,12 @@
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"use-build-number": "./scripts/useBuildNumberEnv.sh",
|
||||
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/",
|
||||
"build-all": "yarn intl:build && eas build --platform all",
|
||||
"build-all": "yarn intl:build && yarn use-build-number eas build --platform all",
|
||||
"build-ios": "yarn use-build-number eas build -p ios",
|
||||
"build-android": "yarn use-build-number eas build -p android",
|
||||
"build": "yarn use-build-number eas build",
|
||||
"start": "expo start --dev-client",
|
||||
"start:prod": "expo start --dev-client --no-dev --minify",
|
||||
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
||||
@@ -36,10 +40,7 @@
|
||||
"intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)",
|
||||
"intl:extract": "lingui extract",
|
||||
"intl:compile": "lingui compile",
|
||||
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
|
||||
"bump": "./scripts/bumpIosBuildNumber.sh && ./scripts/bumpAndroidBuildNumber.sh",
|
||||
"bump:ios": "./scripts/bumpIosBuildNumber.sh",
|
||||
"bump:android": "./scripts/bumpAndroidBuildNumber.sh"
|
||||
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.9.5",
|
||||
@@ -123,7 +124,6 @@
|
||||
"js-sha256": "^0.9.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lande": "^1.0.10",
|
||||
"libphonenumber-js": "^1.10.53",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
@@ -137,7 +137,7 @@
|
||||
"mobx": "^6.6.1",
|
||||
"mobx-react-lite": "^3.4.0",
|
||||
"mobx-utils": "^6.0.6",
|
||||
"nanoid": "^5.0.2",
|
||||
"nanoid": "^5.0.5",
|
||||
"normalize-url": "^8.0.0",
|
||||
"patch-package": "^6.5.1",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
@@ -164,6 +164,7 @@
|
||||
"react-native-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "~3.29.0",
|
||||
"react-native-svg": "14.1.0",
|
||||
"react-native-ui-text-view": "link:./modules/react-native-ui-text-view",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"react-native-uuid": "^2.0.1",
|
||||
"react-native-version-number": "^0.3.6",
|
||||
@@ -178,8 +179,7 @@
|
||||
"tlds": "^1.234.0",
|
||||
"use-deep-compare": "^1.1.0",
|
||||
"zeego": "^1.6.2",
|
||||
"zod": "^3.20.2",
|
||||
"react-native-ui-text-view": "link:./modules/react-native-ui-text-view"
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.2.28",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
diff --git a/node_modules/@react-navigation/native/lib/commonjs/useLinking.js b/node_modules/@react-navigation/native/lib/commonjs/useLinking.js
|
||||
index ef4f368..2b0da35 100644
|
||||
--- a/node_modules/@react-navigation/native/lib/commonjs/useLinking.js
|
||||
+++ b/node_modules/@react-navigation/native/lib/commonjs/useLinking.js
|
||||
@@ -273,8 +273,12 @@ function useLinking(ref, _ref) {
|
||||
});
|
||||
const currentIndex = history.index;
|
||||
try {
|
||||
- if (nextIndex !== -1 && nextIndex < currentIndex) {
|
||||
- // An existing entry for this path exists and it's less than current index, go back to that
|
||||
+ if (
|
||||
+ nextIndex !== -1 &&
|
||||
+ nextIndex < currentIndex &&
|
||||
+ // We should only go back if the entry exists and it's less than current index
|
||||
+ history.get(nextIndex - currentIndex)
|
||||
+ ) { // An existing entry for this path exists and it's less than current index, go back to that
|
||||
await history.go(nextIndex - currentIndex);
|
||||
} else {
|
||||
// We couldn't find an existing entry to go back to, so we'll go back by the delta
|
||||
diff --git a/node_modules/@react-navigation/native/lib/module/useLinking.js b/node_modules/@react-navigation/native/lib/module/useLinking.js
|
||||
index 62a3b43..11a5a28 100644
|
||||
--- a/node_modules/@react-navigation/native/lib/module/useLinking.js
|
||||
+++ b/node_modules/@react-navigation/native/lib/module/useLinking.js
|
||||
@@ -264,8 +264,12 @@ export default function useLinking(ref, _ref) {
|
||||
});
|
||||
const currentIndex = history.index;
|
||||
try {
|
||||
- if (nextIndex !== -1 && nextIndex < currentIndex) {
|
||||
- // An existing entry for this path exists and it's less than current index, go back to that
|
||||
+ if (
|
||||
+ nextIndex !== -1 &&
|
||||
+ nextIndex < currentIndex &&
|
||||
+ // We should only go back if the entry exists and it's less than current index
|
||||
+ history.get(nextIndex - currentIndex)
|
||||
+ ) { // An existing entry for this path exists and it's less than current index, go back to that
|
||||
await history.go(nextIndex - currentIndex);
|
||||
} else {
|
||||
// We couldn't find an existing entry to go back to, so we'll go back by the delta
|
||||
diff --git a/node_modules/@react-navigation/native/src/useLinking.tsx b/node_modules/@react-navigation/native/src/useLinking.tsx
|
||||
index 3db40b7..9ba4ecd 100644
|
||||
--- a/node_modules/@react-navigation/native/src/useLinking.tsx
|
||||
+++ b/node_modules/@react-navigation/native/src/useLinking.tsx
|
||||
@@ -381,7 +381,12 @@ export default function useLinking(
|
||||
const currentIndex = history.index;
|
||||
|
||||
try {
|
||||
- if (nextIndex !== -1 && nextIndex < currentIndex) {
|
||||
+ if (
|
||||
+ nextIndex !== -1 &&
|
||||
+ nextIndex < currentIndex &&
|
||||
+ // We should only go back if the entry exists and it's less than current index
|
||||
+ history.get(nextIndex - currentIndex)
|
||||
+ ) {
|
||||
// An existing entry for this path exists and it's less than current index, go back to that
|
||||
await history.go(nextIndex - currentIndex);
|
||||
} else {
|
||||
@@ -0,0 +1,5 @@
|
||||
# React Navigation history bug patch
|
||||
|
||||
This patches react-navigation to fix the issues in https://github.com/bluesky-social/social-app/issues/710.
|
||||
|
||||
This is based on the PR found at https://github.com/react-navigation/react-navigation/pull/11833
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The number here should always be the line number the iOS build variable is on
|
||||
line=$(sed "30q;d" ./app.config.js)
|
||||
currentBuildNumber=$(echo "$line" | grep -oE '[0-9]+([.][0-9]+)?')
|
||||
newBuildNumber=$((currentBuildNumber+1))
|
||||
newBuildVariable="const ANDROID_VERSION_CODE = '$newBuildNumber'"
|
||||
sed -i.bak "30s/.*/ $newBuildVariable/" ./app.config.js
|
||||
rm -rf ./app.config.js.bak
|
||||
|
||||
echo "Android build number bumped to $newBuildNumber"
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The number here should always be the line number the iOS build variable is on
|
||||
line=$(sed "24q;d" ./app.config.js)
|
||||
currentBuildNumber=$(echo "$line" | grep -oE '[0-9]+([.][0-9]+)?')
|
||||
newBuildNumber=$((currentBuildNumber+1))
|
||||
newBuildVariable="const IOS_BUILD_NUMBER = '$newBuildNumber'"
|
||||
sed -i.bak "24s/.*/ $newBuildVariable/" ./app.config.js
|
||||
rm -rf ./app.config.js.bak
|
||||
|
||||
echo "iOS build number bumped to $newBuildNumber"
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
outputIos=$(eas build:version:get -p ios)
|
||||
outputAndroid=$(eas build:version:get -p android)
|
||||
currentIosVersion=${outputIos#*buildNumber - }
|
||||
currentAndroidVersion=${outputAndroid#*versionCode - }
|
||||
|
||||
BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1))
|
||||
BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1))
|
||||
|
||||
bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*"
|
||||
|
||||
+7
-4
@@ -72,7 +72,7 @@ import {AppPasswords} from 'view/screens/AppPasswords'
|
||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
|
||||
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
|
||||
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
||||
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
||||
@@ -248,9 +248,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
options={{title: title(msg`Edit My Feeds`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PreferencesHomeFeed"
|
||||
getComponent={() => PreferencesHomeFeed}
|
||||
options={{title: title(msg`Home Feed Preferences`), requireAuth: true}}
|
||||
name="PreferencesFollowingFeed"
|
||||
getComponent={() => PreferencesFollowingFeed}
|
||||
options={{
|
||||
title: title(msg`Following Feed Preferences`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PreferencesThreads"
|
||||
|
||||
+7
-5
@@ -21,7 +21,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import Svg, {Path, SvgProps} from 'react-native-svg'
|
||||
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {useThemePrefs} from 'state/shell'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
|
||||
// @ts-ignore
|
||||
@@ -75,10 +74,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
isLayoutReady &&
|
||||
reduceMotion !== undefined
|
||||
|
||||
const {colorMode} = useThemePrefs()
|
||||
const colorScheme = useColorScheme()
|
||||
const themeName = colorMode === 'system' ? colorScheme : colorMode
|
||||
const isDarkMode = themeName === 'dark'
|
||||
const isDarkMode = colorScheme === 'dark'
|
||||
|
||||
const logoAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
@@ -263,7 +260,12 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
{backgroundColor: '#fff'},
|
||||
{
|
||||
backgroundColor: isDarkMode
|
||||
? // special off-spec color for dark mode
|
||||
'#0F1824'
|
||||
: '#fff',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
+17
-1
@@ -176,43 +176,59 @@ export const atoms = {
|
||||
},
|
||||
text_2xs: {
|
||||
fontSize: tokens.fontSize._2xs,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_xs: {
|
||||
fontSize: tokens.fontSize.xs,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_sm: {
|
||||
fontSize: tokens.fontSize.sm,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_md: {
|
||||
fontSize: tokens.fontSize.md,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_lg: {
|
||||
fontSize: tokens.fontSize.lg,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_xl: {
|
||||
fontSize: tokens.fontSize.xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_2xl: {
|
||||
fontSize: tokens.fontSize._2xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_3xl: {
|
||||
fontSize: tokens.fontSize._3xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_4xl: {
|
||||
fontSize: tokens.fontSize._4xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_5xl: {
|
||||
fontSize: tokens.fontSize._5xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
leading_tight: {
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
leading_snug: {
|
||||
lineHeight: 1.25,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
leading_normal: {
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
tracking_normal: {
|
||||
letterSpacing: 0,
|
||||
},
|
||||
tracking_wide: {
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
font_normal: {
|
||||
fontWeight: tokens.fontWeight.normal,
|
||||
},
|
||||
|
||||
+50
-27
@@ -73,19 +73,19 @@ export const darkPalette: Palette = {
|
||||
white: tokens.color.gray_0,
|
||||
black: tokens.color.trueBlack,
|
||||
|
||||
contrast_25: tokens.color.gray_1000,
|
||||
contrast_50: tokens.color.gray_975,
|
||||
contrast_100: tokens.color.gray_950,
|
||||
contrast_200: tokens.color.gray_900,
|
||||
contrast_300: tokens.color.gray_800,
|
||||
contrast_400: tokens.color.gray_700,
|
||||
contrast_500: tokens.color.gray_600,
|
||||
contrast_600: tokens.color.gray_500,
|
||||
contrast_700: tokens.color.gray_400,
|
||||
contrast_800: tokens.color.gray_300,
|
||||
contrast_900: tokens.color.gray_200,
|
||||
contrast_950: tokens.color.gray_100,
|
||||
contrast_975: tokens.color.gray_50,
|
||||
contrast_25: `hsl(211, 28%, 8%)`,
|
||||
contrast_50: `hsl(211, 28%, 11%)`,
|
||||
contrast_100: `hsl(211, 28%, 16%)`,
|
||||
contrast_200: `hsl(211, 28%, 24%)`,
|
||||
contrast_300: `hsl(211, 24%, 31%)`,
|
||||
contrast_400: `hsl(211, 24%, 38%)`,
|
||||
contrast_500: `hsl(211, 20%, 44%)`,
|
||||
contrast_600: `hsl(211, 20%, 55%)`,
|
||||
contrast_700: `hsl(211, 20%, 63%)`,
|
||||
contrast_800: `hsl(211, 20%, 71%)`,
|
||||
contrast_900: `hsl(211, 20%, 79%)`,
|
||||
contrast_950: `hsl(211, 20%, 87%)`,
|
||||
contrast_975: `hsl(211, 20%, 95%)`,
|
||||
|
||||
primary_25: tokens.color.blue_25,
|
||||
primary_50: tokens.color.blue_50,
|
||||
@@ -132,21 +132,28 @@ export const darkPalette: Palette = {
|
||||
|
||||
export const dimPalette: Palette = {
|
||||
...darkPalette,
|
||||
black: tokens.color.gray_1000,
|
||||
black: `hsl(211, 28%, 12%)`,
|
||||
|
||||
contrast_25: tokens.color.gray_975,
|
||||
contrast_50: tokens.color.gray_950,
|
||||
contrast_100: tokens.color.gray_900,
|
||||
contrast_200: tokens.color.gray_800,
|
||||
contrast_300: tokens.color.gray_700,
|
||||
contrast_400: tokens.color.gray_600,
|
||||
contrast_500: tokens.color.gray_500,
|
||||
contrast_600: tokens.color.gray_400,
|
||||
contrast_700: tokens.color.gray_300,
|
||||
contrast_800: tokens.color.gray_200,
|
||||
contrast_900: tokens.color.gray_100,
|
||||
contrast_950: tokens.color.gray_50,
|
||||
contrast_975: tokens.color.gray_25,
|
||||
contrast_25: `hsl(211, 28%, 15%)`,
|
||||
contrast_50: `hsl(211, 28%, 18%)`,
|
||||
contrast_100: `hsl(211, 28%, 24%)`,
|
||||
contrast_200: `hsl(211, 28%, 27%)`,
|
||||
contrast_300: `hsl(211, 24%, 34%)`,
|
||||
contrast_400: `hsl(211, 24%, 41%)`,
|
||||
contrast_500: `hsl(211, 20%, 52%)`,
|
||||
contrast_600: `hsl(211, 20%, 55%)`,
|
||||
contrast_700: `hsl(211, 20%, 67%)`,
|
||||
contrast_800: `hsl(211, 20%, 71%)`,
|
||||
contrast_900: `hsl(211, 20%, 79%)`,
|
||||
contrast_950: `hsl(211, 20%, 87%)`,
|
||||
contrast_975: `hsl(211, 20%, 95%)`,
|
||||
|
||||
primary_600: `hsl(211, 95%, 39%)`,
|
||||
primary_700: `hsl(211, 90%, 30%)`,
|
||||
primary_800: `hsl(211, 90%, 23%)`,
|
||||
primary_900: `hsl(211, 80%, 16%)`,
|
||||
primary_950: `hsl(211, 80%, 13%)`,
|
||||
primary_975: `hsl(211, 80%, 10%)`,
|
||||
} as const
|
||||
|
||||
export const light = {
|
||||
@@ -325,6 +332,7 @@ export const dark: Theme = {
|
||||
export const dim: Theme = {
|
||||
...dark,
|
||||
name: 'dim',
|
||||
palette: dimPalette,
|
||||
atoms: {
|
||||
...dark.atoms,
|
||||
text: {
|
||||
@@ -393,5 +401,20 @@ export const dim: Theme = {
|
||||
border_contrast_high: {
|
||||
borderColor: dimPalette.contrast_300,
|
||||
},
|
||||
shadow_sm: {
|
||||
...atoms.shadow_sm,
|
||||
shadowOpacity: 0.7,
|
||||
shadowColor: `hsl(211, 28%, 3%)`,
|
||||
},
|
||||
shadow_md: {
|
||||
...atoms.shadow_md,
|
||||
shadowOpacity: 0.7,
|
||||
shadowColor: `hsl(211, 28%, 3%)`,
|
||||
},
|
||||
shadow_lg: {
|
||||
...atoms.shadow_lg,
|
||||
shadowOpacity: 0.7,
|
||||
shadowColor: `hsl(211, 28%, 3%)`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function useColorModeTheme(): ThemeName {
|
||||
React.useLayoutEffect(() => {
|
||||
const theme = getThemeName(colorScheme, colorMode, darkTheme)
|
||||
updateDocument(theme)
|
||||
updateSystemBackground(theme)
|
||||
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
|
||||
}, [colorMode, colorScheme, darkTheme])
|
||||
|
||||
return React.useMemo(
|
||||
@@ -42,23 +42,24 @@ function updateDocument(theme: ThemeName) {
|
||||
if (isWeb && typeof window !== 'undefined') {
|
||||
// @ts-ignore web only
|
||||
const html = window.document.documentElement
|
||||
// @ts-ignore web only
|
||||
const meta = window.document.querySelector('meta[name="theme-color"]')
|
||||
|
||||
// remove any other color mode classes
|
||||
html.className = html.className.replace(/(theme)--\w+/g, '')
|
||||
|
||||
html.classList.add(`theme--${theme}`)
|
||||
// set color to 'theme-color' meta tag
|
||||
meta?.setAttribute('content', getBackgroundColor(theme))
|
||||
}
|
||||
}
|
||||
|
||||
function updateSystemBackground(theme: ThemeName) {
|
||||
function getBackgroundColor(theme: ThemeName): string {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
SystemUI.setBackgroundColorAsync(light.atoms.bg.backgroundColor)
|
||||
break
|
||||
return light.atoms.bg.backgroundColor
|
||||
case 'dark':
|
||||
SystemUI.setBackgroundColorAsync(dark.atoms.bg.backgroundColor)
|
||||
break
|
||||
return dark.atoms.bg.backgroundColor
|
||||
case 'dim':
|
||||
SystemUI.setBackgroundColorAsync(dim.atoms.bg.backgroundColor)
|
||||
break
|
||||
return dim.atoms.bg.backgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ export type ButtonState = {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type ButtonContext = VariantProps & ButtonState
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID'
|
||||
@@ -67,7 +69,7 @@ export type ButtonProps = Pick<
|
||||
children:
|
||||
| React.ReactNode
|
||||
| string
|
||||
| ((state: VariantProps & ButtonState) => React.ReactNode | string)
|
||||
| ((context: ButtonContext) => React.ReactNode | string)
|
||||
}
|
||||
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
|
||||
|
||||
@@ -351,7 +353,7 @@ export function Button({
|
||||
}
|
||||
}, [variant, color])
|
||||
|
||||
const context = React.useMemo(
|
||||
const context = React.useMemo<ButtonContext>(
|
||||
() => ({
|
||||
...state,
|
||||
variant,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
import {useDialogStateContext} from '#/state/dialogs'
|
||||
import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types'
|
||||
import {
|
||||
DialogContextProps,
|
||||
DialogControlProps,
|
||||
DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
|
||||
export const Context = React.createContext<DialogContextProps>({
|
||||
close: () => {},
|
||||
@@ -11,7 +15,7 @@ export function useDialogContext() {
|
||||
return React.useContext(Context)
|
||||
}
|
||||
|
||||
export function useDialogControl() {
|
||||
export function useDialogControl(): DialogOuterProps['control'] {
|
||||
const id = React.useId()
|
||||
const control = React.useRef<DialogControlProps>({
|
||||
open: () => {},
|
||||
@@ -30,6 +34,6 @@ export function useDialogControl() {
|
||||
return {
|
||||
ref: control,
|
||||
open: () => control.current.open(),
|
||||
close: () => control.current.close(),
|
||||
close: cb => control.current.close(cb),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import BottomSheet, {
|
||||
} from '@gorhom/bottom-sheet'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {useTheme, atoms as a, flatten} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
|
||||
@@ -29,19 +29,36 @@ export function Outer({
|
||||
control,
|
||||
onClose,
|
||||
nativeOptions,
|
||||
defaultOpen,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const t = useTheme()
|
||||
const sheet = React.useRef<BottomSheet>(null)
|
||||
const sheetOptions = nativeOptions?.sheet || {}
|
||||
const hasSnapPoints = !!sheetOptions.snapPoints
|
||||
const insets = useSafeAreaInsets()
|
||||
const closeCallback = React.useRef<() => void>()
|
||||
|
||||
const open = React.useCallback<DialogControlProps['open']>(({index} = {}) => {
|
||||
sheet.current?.snapToIndex(index || 0)
|
||||
}, [])
|
||||
/*
|
||||
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
|
||||
*/
|
||||
const [openIndex, setOpenIndex] = React.useState(-1)
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
/*
|
||||
* `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
|
||||
*/
|
||||
const isOpen = openIndex > -1
|
||||
|
||||
const open = React.useCallback<DialogControlProps['open']>(
|
||||
({index} = {}) => {
|
||||
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
|
||||
setOpenIndex(index || 0)
|
||||
},
|
||||
[setOpenIndex],
|
||||
)
|
||||
|
||||
const close = React.useCallback<DialogControlProps['close']>(cb => {
|
||||
if (cb) {
|
||||
closeCallback.current = cb
|
||||
}
|
||||
sheet.current?.close()
|
||||
}, [])
|
||||
|
||||
@@ -57,60 +74,66 @@ export function Outer({
|
||||
const onChange = React.useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
closeCallback.current?.()
|
||||
closeCallback.current = undefined
|
||||
onClose?.()
|
||||
setOpenIndex(-1)
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
[onClose, setOpenIndex],
|
||||
)
|
||||
|
||||
const context = React.useMemo(() => ({close}), [close])
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<BottomSheet
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
topInset={insets.top}
|
||||
{...sheetOptions}
|
||||
ref={sheet}
|
||||
index={defaultOpen ? 0 : -1}
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
backdropComponent={props => (
|
||||
<BottomSheetBackdrop
|
||||
opacity={0.4}
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onChange={onChange}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
</Portal>
|
||||
isOpen && (
|
||||
<Portal>
|
||||
<BottomSheet
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
topInset={insets.top}
|
||||
{...sheetOptions}
|
||||
snapPoints={sheetOptions.snapPoints || ['100%']}
|
||||
ref={sheet}
|
||||
index={openIndex}
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
backdropComponent={props => (
|
||||
<BottomSheetBackdrop
|
||||
opacity={0.4}
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
{...props}
|
||||
style={[flatten(props.style), t.atoms.bg_contrast_300]}
|
||||
/>
|
||||
)}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onChange={onChange}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
</Portal>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// TODO a11y props here, or is that handled by the sheet?
|
||||
export function Inner(props: DialogInnerProps) {
|
||||
export function Inner({children, style}: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetView
|
||||
@@ -122,13 +145,14 @@ export function Inner(props: DialogInnerProps) {
|
||||
borderTopRightRadius: 40,
|
||||
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
|
||||
},
|
||||
flatten(style),
|
||||
]}>
|
||||
{props.children}
|
||||
{children}
|
||||
</BottomSheetView>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScrollableInner(props: DialogInnerProps) {
|
||||
export function ScrollableInner({children, style}: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
@@ -137,13 +161,15 @@ export function ScrollableInner(props: DialogInnerProps) {
|
||||
style={[
|
||||
a.flex_1, // main diff is this
|
||||
a.p_xl,
|
||||
a.h_full,
|
||||
{
|
||||
paddingTop: 40,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
},
|
||||
flatten(style),
|
||||
]}>
|
||||
{props.children}
|
||||
{children}
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||
</BottomSheetScrollView>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useTheme, atoms as a, useBreakpoints, web} from '#/alf'
|
||||
import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
|
||||
@@ -23,12 +23,11 @@ export function Outer({
|
||||
children,
|
||||
control,
|
||||
onClose,
|
||||
defaultOpen,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen)
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [isVisible, setIsVisible] = React.useState(true)
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
@@ -159,7 +158,7 @@ export function Inner({
|
||||
shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
|
||||
shadowRadius: 30,
|
||||
},
|
||||
...(Array.isArray(style) ? style : [style || {}]),
|
||||
flatten(style),
|
||||
]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
import React from 'react'
|
||||
import type {ViewStyle, AccessibilityProps} from 'react-native'
|
||||
import type {AccessibilityProps} from 'react-native'
|
||||
import {BottomSheetProps} from '@gorhom/bottom-sheet'
|
||||
|
||||
import {ViewStyleProp} from '#/alf'
|
||||
|
||||
type A11yProps = Required<AccessibilityProps>
|
||||
|
||||
export type DialogContextProps = {
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export type DialogControlOpenOptions = {index?: number}
|
||||
export type DialogControlOpenOptions = {
|
||||
/**
|
||||
* NATIVE ONLY
|
||||
*
|
||||
* Optional index of the snap point to open the bottom sheet to. Defaults to
|
||||
* 0, which is the first snap point (i.e. "open").
|
||||
*/
|
||||
index?: number
|
||||
}
|
||||
|
||||
export type DialogControlProps = {
|
||||
open: (options?: DialogControlOpenOptions) => void
|
||||
close: () => void
|
||||
close: (callback?: () => void) => void
|
||||
}
|
||||
|
||||
export type DialogOuterProps = {
|
||||
defaultOpen?: boolean
|
||||
control: {
|
||||
ref: React.RefObject<DialogControlProps>
|
||||
open: (index?: number) => void
|
||||
close: () => void
|
||||
}
|
||||
} & DialogControlProps
|
||||
onClose?: () => void
|
||||
nativeOptions?: {
|
||||
sheet?: Omit<BottomSheetProps, 'children'>
|
||||
@@ -29,10 +36,7 @@ export type DialogOuterProps = {
|
||||
webOptions?: {}
|
||||
}
|
||||
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<{
|
||||
style?: ViewStyle
|
||||
}> &
|
||||
T
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
|
||||
export type DialogInnerProps =
|
||||
| DialogInnerPropsBase<{
|
||||
label?: undefined
|
||||
|
||||
@@ -30,8 +30,8 @@ export function IconCircle({
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
{
|
||||
width: 64,
|
||||
height: 64,
|
||||
width: size === 'lg' ? 52 : 64,
|
||||
height: size === 'lg' ? 52 : 64,
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
|
||||
},
|
||||
+79
-63
@@ -1,9 +1,5 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
GestureResponderEvent,
|
||||
Linking,
|
||||
TouchableWithoutFeedback,
|
||||
} from 'react-native'
|
||||
import {GestureResponderEvent} from 'react-native'
|
||||
import {
|
||||
useLinkProps,
|
||||
useNavigation,
|
||||
@@ -23,7 +19,8 @@ import {
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {router} from '#/routes'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {useOpenLink} from 'state/preferences/in-app-browser'
|
||||
|
||||
/**
|
||||
* Only available within a `Link`, since that inherits from `Button`.
|
||||
@@ -37,6 +34,11 @@ type BaseLinkProps = Pick<
|
||||
> & {
|
||||
testID?: string
|
||||
|
||||
/**
|
||||
* Label for a11y. Defaults to the href.
|
||||
*/
|
||||
label?: string
|
||||
|
||||
/**
|
||||
* The React Navigation `StackAction` to perform when the link is pressed.
|
||||
*/
|
||||
@@ -50,11 +52,17 @@ type BaseLinkProps = Pick<
|
||||
warnOnMismatchingTextChild?: boolean
|
||||
|
||||
/**
|
||||
* Callback for when the link is pressed.
|
||||
* Callback for when the link is pressed. Prevent default and return `false`
|
||||
* to exit early and prevent navigation.
|
||||
*
|
||||
* DO NOT use this for navigation, that's what the `to` prop is for.
|
||||
*/
|
||||
onPress?: (e: GestureResponderEvent) => void
|
||||
onPress?: (e: GestureResponderEvent) => void | false
|
||||
|
||||
/**
|
||||
* Web-only attribute. Sets `download` attr on web.
|
||||
*/
|
||||
download?: string
|
||||
}
|
||||
|
||||
export function useLink({
|
||||
@@ -73,10 +81,13 @@ export function useLink({
|
||||
})
|
||||
const isExternal = isExternalUrl(href)
|
||||
const {openModal, closeModal} = useModalControls()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
const onPress = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
outerOnPress?.(e)
|
||||
const exitEarlyIfFalse = outerOnPress?.(e)
|
||||
|
||||
if (exitEarlyIfFalse === false) return
|
||||
|
||||
const requiresWarning = Boolean(
|
||||
warnOnMismatchingTextChild &&
|
||||
@@ -97,7 +108,7 @@ export function useLink({
|
||||
e.preventDefault()
|
||||
|
||||
if (isExternal) {
|
||||
Linking.openURL(href)
|
||||
openLink(href)
|
||||
} else {
|
||||
/**
|
||||
* A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
|
||||
@@ -115,7 +126,7 @@ export function useLink({
|
||||
href.startsWith('http') ||
|
||||
href.startsWith('mailto')
|
||||
) {
|
||||
Linking.openURL(href)
|
||||
openLink(href)
|
||||
} else {
|
||||
closeModal() // close any active modals
|
||||
|
||||
@@ -136,15 +147,16 @@ export function useLink({
|
||||
}
|
||||
},
|
||||
[
|
||||
href,
|
||||
isExternal,
|
||||
warnOnMismatchingTextChild,
|
||||
navigation,
|
||||
action,
|
||||
displayText,
|
||||
closeModal,
|
||||
openModal,
|
||||
outerOnPress,
|
||||
warnOnMismatchingTextChild,
|
||||
displayText,
|
||||
isExternal,
|
||||
href,
|
||||
openModal,
|
||||
openLink,
|
||||
closeModal,
|
||||
action,
|
||||
navigation,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -156,12 +168,7 @@ export function useLink({
|
||||
}
|
||||
|
||||
export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
|
||||
Omit<ButtonProps, 'onPress' | 'disabled' | 'label'> & {
|
||||
/**
|
||||
* Label for a11y. Defaults to the href.
|
||||
*/
|
||||
label?: string
|
||||
}
|
||||
Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
|
||||
|
||||
/**
|
||||
* A interactive element that renders as a `<a>` tag on the web. On mobile it
|
||||
@@ -176,6 +183,7 @@ export function Link({
|
||||
to,
|
||||
action = 'push',
|
||||
onPress: outerOnPress,
|
||||
download,
|
||||
...rest
|
||||
}: LinkProps) {
|
||||
const {href, isExternal, onPress} = useLink({
|
||||
@@ -193,14 +201,15 @@ export function Link({
|
||||
role="link"
|
||||
accessibilityRole="link"
|
||||
href={href}
|
||||
onPress={onPress}
|
||||
onPress={download ? undefined : onPress}
|
||||
{...web({
|
||||
hrefAttrs: {
|
||||
target: isExternal ? 'blank' : undefined,
|
||||
target: download ? undefined : isExternal ? 'blank' : undefined,
|
||||
rel: isExternal ? 'noopener noreferrer' : undefined,
|
||||
download,
|
||||
},
|
||||
dataSet: {
|
||||
// default to no underline, apply this ourselves
|
||||
// no underline, only `InlineLink` has underlines
|
||||
noUnderline: '1',
|
||||
},
|
||||
})}>
|
||||
@@ -210,13 +219,7 @@ export function Link({
|
||||
}
|
||||
|
||||
export type InlineLinkProps = React.PropsWithChildren<
|
||||
BaseLinkProps &
|
||||
TextStyleProp & {
|
||||
/**
|
||||
* Label for a11y. Defaults to the href.
|
||||
*/
|
||||
label?: string
|
||||
}
|
||||
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
|
||||
>
|
||||
|
||||
export function InlineLink({
|
||||
@@ -226,6 +229,8 @@ export function InlineLink({
|
||||
warnOnMismatchingTextChild,
|
||||
style,
|
||||
onPress: outerOnPress,
|
||||
download,
|
||||
selectable,
|
||||
...rest
|
||||
}: InlineLinkProps) {
|
||||
const t = useTheme()
|
||||
@@ -237,44 +242,55 @@ export function InlineLink({
|
||||
warnOnMismatchingTextChild,
|
||||
onPress: outerOnPress,
|
||||
})
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const flattenedStyle = flatten(style)
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
<Text
|
||||
selectable={selectable}
|
||||
label={href}
|
||||
{...rest}
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
(hovered || focused || pressed) && {
|
||||
...web({outline: 0}),
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
|
||||
},
|
||||
flattenedStyle,
|
||||
]}
|
||||
role="link"
|
||||
onPress={download ? undefined : onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}>
|
||||
<Text
|
||||
label={href}
|
||||
{...rest}
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
(focused || pressed) && {
|
||||
outline: 0,
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.palette.primary_500,
|
||||
},
|
||||
flatten(style),
|
||||
]}
|
||||
role="link"
|
||||
accessibilityRole="link"
|
||||
href={href}
|
||||
{...web({
|
||||
hrefAttrs: {
|
||||
target: isExternal ? 'blank' : undefined,
|
||||
rel: isExternal ? 'noopener noreferrer' : undefined,
|
||||
},
|
||||
})}>
|
||||
{children}
|
||||
</Text>
|
||||
</TouchableWithoutFeedback>
|
||||
onBlur={onBlur}
|
||||
onMouseEnter={onHoverIn}
|
||||
onMouseLeave={onHoverOut}
|
||||
accessibilityRole="link"
|
||||
href={href}
|
||||
{...web({
|
||||
hrefAttrs: {
|
||||
target: download ? undefined : isExternal ? 'blank' : undefined,
|
||||
rel: isExternal ? 'noopener noreferrer' : undefined,
|
||||
download,
|
||||
},
|
||||
dataSet: {
|
||||
// default to no underline, apply this ourselves
|
||||
noUnderline: '1',
|
||||
},
|
||||
})}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import Animated, {
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, useTheme, flatten} from '#/alf'
|
||||
import {Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader'
|
||||
|
||||
@@ -38,7 +38,12 @@ export function Loader(props: Props) {
|
||||
]}>
|
||||
<Icon
|
||||
{...props}
|
||||
style={[a.absolute, a.inset_0, props.style, t.atoms.text_contrast_high]}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.text_contrast_high,
|
||||
flatten(props.style),
|
||||
]}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ export function Outer({
|
||||
<Dialog.Inner
|
||||
accessibilityLabelledBy={titleId}
|
||||
accessibilityDescribedBy={descriptionId}
|
||||
style={{width: 'auto', maxWidth: 400}}>
|
||||
style={[{width: 'auto', maxWidth: 400}]}>
|
||||
{children}
|
||||
</Dialog.Inner>
|
||||
</Context.Provider>
|
||||
|
||||
+17
-10
@@ -1,9 +1,9 @@
|
||||
import React from 'react'
|
||||
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
|
||||
|
||||
import {atoms as a, TextStyleProp} from '#/alf'
|
||||
import {atoms as a, TextStyleProp, flatten} from '#/alf'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
@@ -16,18 +16,20 @@ export function RichText({
|
||||
numberOfLines,
|
||||
disableLinks,
|
||||
resolveFacets = false,
|
||||
}: TextStyleProp & {
|
||||
value: RichTextAPI | string
|
||||
testID?: string
|
||||
numberOfLines?: number
|
||||
disableLinks?: boolean
|
||||
resolveFacets?: boolean
|
||||
}) {
|
||||
selectable,
|
||||
}: TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
testID?: string
|
||||
numberOfLines?: number
|
||||
disableLinks?: boolean
|
||||
resolveFacets?: boolean
|
||||
}) {
|
||||
const detected = React.useRef(false)
|
||||
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
)
|
||||
const styles = [a.leading_normal, style]
|
||||
const styles = [a.leading_snug, flatten(style)]
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolveFacets) return
|
||||
@@ -50,6 +52,7 @@ export function RichText({
|
||||
if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
|
||||
return (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={[
|
||||
{
|
||||
@@ -65,6 +68,7 @@ export function RichText({
|
||||
}
|
||||
return (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={styles}
|
||||
numberOfLines={numberOfLines}
|
||||
@@ -88,6 +92,7 @@ export function RichText({
|
||||
) {
|
||||
els.push(
|
||||
<InlineLink
|
||||
selectable={selectable}
|
||||
key={key}
|
||||
to={`/profile/${mention.did}`}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
@@ -102,6 +107,7 @@ export function RichText({
|
||||
} else {
|
||||
els.push(
|
||||
<InlineLink
|
||||
selectable={selectable}
|
||||
key={key}
|
||||
to={link.uri}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
@@ -120,6 +126,7 @@ export function RichText({
|
||||
|
||||
return (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={styles}
|
||||
numberOfLines={numberOfLines}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import React from 'react'
|
||||
import {Text as RNText, TextStyle, TextProps} from 'react-native'
|
||||
import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
|
||||
import {UITextView} from 'react-native-ui-text-view'
|
||||
|
||||
import {useTheme, atoms, web, flatten} from '#/alf'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
|
||||
export type TextProps = RNTextProps & {
|
||||
/**
|
||||
* Lets the user select text, to use the native copy and paste functionality.
|
||||
*/
|
||||
selectable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Util to calculate lineHeight from a text size atom and a leading atom
|
||||
@@ -44,27 +53,24 @@ function normalizeTextStyles(styles: TextStyle[]) {
|
||||
/**
|
||||
* Our main text component. Use this most of the time.
|
||||
*/
|
||||
export function Text({style, ...rest}: TextProps) {
|
||||
export function Text({style, selectable, ...rest}: TextProps) {
|
||||
const t = useTheme()
|
||||
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
|
||||
return <RNText style={s} {...rest} />
|
||||
return selectable && isIOS ? (
|
||||
<UITextView style={s} {...rest} />
|
||||
) : (
|
||||
<RNText selectable={selectable} style={s} {...rest} />
|
||||
)
|
||||
}
|
||||
|
||||
export function createHeadingElement({level}: {level: number}) {
|
||||
return function HeadingElement({style, ...rest}: TextProps) {
|
||||
const t = useTheme()
|
||||
const attr =
|
||||
web({
|
||||
role: 'heading',
|
||||
'aria-level': level,
|
||||
}) || {}
|
||||
return (
|
||||
<RNText
|
||||
{...attr}
|
||||
{...rest}
|
||||
style={normalizeTextStyles([t.atoms.text, flatten(style)])}
|
||||
/>
|
||||
)
|
||||
return <Text {...attr} {...rest} style={style} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,21 +84,15 @@ export const H4 = createHeadingElement({level: 4})
|
||||
export const H5 = createHeadingElement({level: 5})
|
||||
export const H6 = createHeadingElement({level: 6})
|
||||
export function P({style, ...rest}: TextProps) {
|
||||
const t = useTheme()
|
||||
const attr =
|
||||
web({
|
||||
role: 'paragraph',
|
||||
}) || {}
|
||||
return (
|
||||
<RNText
|
||||
<Text
|
||||
{...attr}
|
||||
{...rest}
|
||||
style={normalizeTextStyles([
|
||||
atoms.text_md,
|
||||
atoms.leading_normal,
|
||||
t.atoms.text,
|
||||
flatten(style),
|
||||
])}
|
||||
style={[atoms.text_md, atoms.leading_normal, flatten(style)]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function DateField({
|
||||
timeZoneName={'Etc/UTC'}
|
||||
display="spinner"
|
||||
// @ts-ignore applies in iOS only -prf
|
||||
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
|
||||
themeVariant={t.name === 'light' ? 'light' : 'dark'}
|
||||
value={new Date(value)}
|
||||
onChange={onChangeInternal}
|
||||
/>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function DateField({
|
||||
mode="date"
|
||||
timeZoneName={'Etc/UTC'}
|
||||
display="spinner"
|
||||
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
|
||||
themeVariant={t.name === 'light' ? 'light' : 'dark'}
|
||||
value={new Date(value)}
|
||||
onChange={onChangeInternal}
|
||||
/>
|
||||
|
||||
@@ -5,15 +5,13 @@ import {
|
||||
TextInputProps,
|
||||
TextStyle,
|
||||
ViewStyle,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
AccessibilityProps,
|
||||
} from 'react-native'
|
||||
|
||||
import {HITSLOP_20} from 'lib/constants'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useTheme, atoms as a, web, tokens, android, flatten} from '#/alf'
|
||||
import {Text, leading} from '#/components/Typography'
|
||||
import {useTheme, atoms as a, web, tokens, android} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
|
||||
@@ -41,7 +39,6 @@ export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
|
||||
|
||||
export function Root({children, isInvalid = false}: RootProps) {
|
||||
const inputRef = React.useRef<TextInput>(null)
|
||||
const rootRef = React.useRef<View>(null)
|
||||
const {
|
||||
state: hovered,
|
||||
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 (
|
||||
<Context.Provider value={context}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
ref={rootRef}
|
||||
role="none"
|
||||
style={[
|
||||
a.flex_row,
|
||||
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}>
|
||||
<View
|
||||
style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
|
||||
{...web({
|
||||
onClick: () => inputRef.current?.focus(),
|
||||
onMouseOver: onHoverIn,
|
||||
onMouseOut: onHoverOut,
|
||||
})}>
|
||||
{children}
|
||||
</Pressable>
|
||||
</View>
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
@@ -149,7 +128,6 @@ export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
||||
value: string
|
||||
onChangeText: (value: string) => void
|
||||
isInvalid?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function createInput(Component: typeof TextInput) {
|
||||
@@ -188,7 +166,6 @@ export function createInput(Component: typeof TextInput) {
|
||||
<Component
|
||||
accessibilityHint={undefined}
|
||||
{...rest}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
ref={ctx.inputRef}
|
||||
value={value}
|
||||
@@ -205,17 +182,17 @@ export function createInput(Component: typeof TextInput) {
|
||||
a.text_md,
|
||||
t.atoms.text,
|
||||
a.px_xs,
|
||||
android({
|
||||
paddingBottom: 2,
|
||||
}),
|
||||
{
|
||||
lineHeight: rest.multiline
|
||||
? leading(a.text_md, a.leading_normal)
|
||||
: a.text_md.fontSize * 1.1875,
|
||||
// paddingVertical doesn't work w/multiline - esb
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
lineHeight: a.text_md.fontSize * 1.1875,
|
||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||
minHeight: rest.multiline ? 60 : undefined,
|
||||
minHeight: rest.multiline ? 80 : undefined,
|
||||
},
|
||||
flatten(rest.style),
|
||||
android({
|
||||
paddingBottom: 16,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -317,7 +294,6 @@ export function Suffix({
|
||||
const ctx = React.useContext(Context)
|
||||
return (
|
||||
<Text
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
style={[
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
+7
-90
@@ -3,9 +3,8 @@ import {Insets, Platform} from 'react-native'
|
||||
export const LOCAL_DEV_SERVICE =
|
||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
||||
export const PROD_SERVICE = 'https://bsky.social'
|
||||
export const DEFAULT_SERVICE = PROD_SERVICE
|
||||
|
||||
export const BSKY_SERVICE = 'https://bsky.social'
|
||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||
const HELP_DESK_LANG = 'en-us'
|
||||
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
||||
|
||||
@@ -36,92 +35,12 @@ export const MAX_GRAPHEME_LENGTH = 300
|
||||
// but increasing limit per user feedback
|
||||
export const MAX_ALT_TEXT = 1000
|
||||
|
||||
export function IS_LOCAL_DEV(url: string) {
|
||||
return url.includes('localhost')
|
||||
export function IS_PROD_SERVICE(url?: string) {
|
||||
return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
|
||||
}
|
||||
|
||||
export function IS_STAGING(url: string) {
|
||||
return url.startsWith('https://staging.bsky.dev')
|
||||
}
|
||||
|
||||
export function IS_PROD(url: string) {
|
||||
// NOTE
|
||||
// until open federation, "production" is defined as the main server
|
||||
// this definition will not work once federation is enabled!
|
||||
// -prf
|
||||
return (
|
||||
url.startsWith('https://bsky.social') ||
|
||||
url.startsWith('https://api.bsky.app') ||
|
||||
/bsky\.network\/?$/.test(url)
|
||||
)
|
||||
}
|
||||
|
||||
export const PROD_TEAM_HANDLES = [
|
||||
'jay.bsky.social',
|
||||
'pfrazee.com',
|
||||
'divy.zone',
|
||||
'dholms.xyz',
|
||||
'why.bsky.world',
|
||||
'iamrosewang.bsky.social',
|
||||
]
|
||||
export const STAGING_TEAM_HANDLES = [
|
||||
'arcalinea.staging.bsky.dev',
|
||||
'paul.staging.bsky.dev',
|
||||
'paul2.staging.bsky.dev',
|
||||
]
|
||||
export const DEV_TEAM_HANDLES = ['alice.test', 'bob.test', 'carla.test']
|
||||
|
||||
export function TEAM_HANDLES(serviceUrl: string) {
|
||||
if (serviceUrl.includes('localhost')) {
|
||||
return DEV_TEAM_HANDLES
|
||||
} else if (serviceUrl.includes('staging')) {
|
||||
return STAGING_TEAM_HANDLES
|
||||
} else {
|
||||
return PROD_TEAM_HANDLES
|
||||
}
|
||||
}
|
||||
|
||||
export const STAGING_DEFAULT_FEED = (rkey: string) =>
|
||||
`at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}`
|
||||
export const PROD_DEFAULT_FEED = (rkey: string) =>
|
||||
`at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
|
||||
export async function DEFAULT_FEEDS(
|
||||
serviceUrl: string,
|
||||
resolveHandle: (name: string) => Promise<string>,
|
||||
) {
|
||||
// TODO: remove this when the test suite no longer relies on it
|
||||
if (IS_LOCAL_DEV(serviceUrl)) {
|
||||
// local dev
|
||||
const aliceDid = await resolveHandle('alice.test')
|
||||
return {
|
||||
pinned: [
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
|
||||
],
|
||||
saved: [
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
|
||||
],
|
||||
}
|
||||
} else if (IS_STAGING(serviceUrl)) {
|
||||
// staging
|
||||
return {
|
||||
pinned: [STAGING_DEFAULT_FEED('whats-hot')],
|
||||
saved: [
|
||||
STAGING_DEFAULT_FEED('bsky-team'),
|
||||
STAGING_DEFAULT_FEED('with-friends'),
|
||||
STAGING_DEFAULT_FEED('whats-hot'),
|
||||
STAGING_DEFAULT_FEED('hot-classic'),
|
||||
],
|
||||
}
|
||||
} else {
|
||||
// production
|
||||
return {
|
||||
pinned: [PROD_DEFAULT_FEED('whats-hot')],
|
||||
saved: [PROD_DEFAULT_FEED('whats-hot')],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const POST_IMG_MAX = {
|
||||
width: 2000,
|
||||
@@ -135,13 +54,11 @@ export const STAGING_LINK_META_PROXY =
|
||||
export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url='
|
||||
|
||||
export function LINK_META_PROXY(serviceUrl: string) {
|
||||
if (IS_LOCAL_DEV(serviceUrl)) {
|
||||
return STAGING_LINK_META_PROXY
|
||||
} else if (IS_STAGING(serviceUrl)) {
|
||||
return STAGING_LINK_META_PROXY
|
||||
} else {
|
||||
if (IS_PROD_SERVICE(serviceUrl)) {
|
||||
return PROD_LINK_META_PROXY
|
||||
}
|
||||
|
||||
return STAGING_LINK_META_PROXY
|
||||
}
|
||||
|
||||
export const STATUS_PAGE_URL = 'https://status.bsky.app/'
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import {CountryCode} from 'libphonenumber-js'
|
||||
|
||||
// ISO 3166-1 alpha-2 codes
|
||||
|
||||
export interface CountryCodeMap {
|
||||
code2: CountryCode
|
||||
name: string
|
||||
}
|
||||
|
||||
export const COUNTRY_CODES: CountryCodeMap[] = [
|
||||
{code2: 'AF', name: 'Afghanistan (+93)'},
|
||||
{code2: 'AX', name: 'Åland Islands (+358)'},
|
||||
{code2: 'AL', name: 'Albania (+355)'},
|
||||
{code2: 'DZ', name: 'Algeria (+213)'},
|
||||
{code2: 'AS', name: 'American Samoa (+1)'},
|
||||
{code2: 'AD', name: 'Andorra (+376)'},
|
||||
{code2: 'AO', name: 'Angola (+244)'},
|
||||
{code2: 'AI', name: 'Anguilla (+1)'},
|
||||
{code2: 'AG', name: 'Antigua and Barbuda (+1)'},
|
||||
{code2: 'AR', name: 'Argentina (+54)'},
|
||||
{code2: 'AM', name: 'Armenia (+374)'},
|
||||
{code2: 'AW', name: 'Aruba (+297)'},
|
||||
{code2: 'AU', name: 'Australia (+61)'},
|
||||
{code2: 'AT', name: 'Austria (+43)'},
|
||||
{code2: 'AZ', name: 'Azerbaijan (+994)'},
|
||||
{code2: 'BS', name: 'Bahamas (+1)'},
|
||||
{code2: 'BH', name: 'Bahrain (+973)'},
|
||||
{code2: 'BD', name: 'Bangladesh (+880)'},
|
||||
{code2: 'BB', name: 'Barbados (+1)'},
|
||||
{code2: 'BY', name: 'Belarus (+375)'},
|
||||
{code2: 'BE', name: 'Belgium (+32)'},
|
||||
{code2: 'BZ', name: 'Belize (+501)'},
|
||||
{code2: 'BJ', name: 'Benin (+229)'},
|
||||
{code2: 'BM', name: 'Bermuda (+1)'},
|
||||
{code2: 'BT', name: 'Bhutan (+975)'},
|
||||
{code2: 'BO', name: 'Bolivia (Plurinational State of) (+591)'},
|
||||
{code2: 'BQ', name: 'Bonaire, Sint Eustatius and Saba (+599)'},
|
||||
{code2: 'BA', name: 'Bosnia and Herzegovina (+387)'},
|
||||
{code2: 'BW', name: 'Botswana (+267)'},
|
||||
{code2: 'BR', name: 'Brazil (+55)'},
|
||||
{code2: 'IO', name: 'British Indian Ocean Territory (+246)'},
|
||||
{code2: 'BN', name: 'Brunei Darussalam (+673)'},
|
||||
{code2: 'BG', name: 'Bulgaria (+359)'},
|
||||
{code2: 'BF', name: 'Burkina Faso (+226)'},
|
||||
{code2: 'BI', name: 'Burundi (+257)'},
|
||||
{code2: 'CV', name: 'Cabo Verde (+238)'},
|
||||
{code2: 'KH', name: 'Cambodia (+855)'},
|
||||
{code2: 'CM', name: 'Cameroon (+237)'},
|
||||
{code2: 'CA', name: 'Canada (+1)'},
|
||||
{code2: 'KY', name: 'Cayman Islands (+1)'},
|
||||
{code2: 'CF', name: 'Central African Republic (+236)'},
|
||||
{code2: 'TD', name: 'Chad (+235)'},
|
||||
{code2: 'CL', name: 'Chile (+56)'},
|
||||
{code2: 'CN', name: 'China (+86)'},
|
||||
{code2: 'CX', name: 'Christmas Island (+61)'},
|
||||
{code2: 'CC', name: 'Cocos (Keeling) Islands (+61)'},
|
||||
{code2: 'CO', name: 'Colombia (+57)'},
|
||||
{code2: 'KM', name: 'Comoros (+269)'},
|
||||
{code2: 'CG', name: 'Congo (+242)'},
|
||||
{code2: 'CD', name: 'Congo, Democratic Republic of the (+243)'},
|
||||
{code2: 'CK', name: 'Cook Islands (+682)'},
|
||||
{code2: 'CR', name: 'Costa Rica (+506)'},
|
||||
{code2: 'CI', name: "Côte d'Ivoire (+225)"},
|
||||
{code2: 'HR', name: 'Croatia (+385)'},
|
||||
{code2: 'CU', name: 'Cuba (+53)'},
|
||||
{code2: 'CW', name: 'Curaçao (+599)'},
|
||||
{code2: 'CY', name: 'Cyprus (+357)'},
|
||||
{code2: 'CZ', name: 'Czechia (+420)'},
|
||||
{code2: 'DK', name: 'Denmark (+45)'},
|
||||
{code2: 'DJ', name: 'Djibouti (+253)'},
|
||||
{code2: 'DM', name: 'Dominica (+1)'},
|
||||
{code2: 'DO', name: 'Dominican Republic (+1)'},
|
||||
{code2: 'EC', name: 'Ecuador (+593)'},
|
||||
{code2: 'EG', name: 'Egypt (+20)'},
|
||||
{code2: 'SV', name: 'El Salvador (+503)'},
|
||||
{code2: 'GQ', name: 'Equatorial Guinea (+240)'},
|
||||
{code2: 'ER', name: 'Eritrea (+291)'},
|
||||
{code2: 'EE', name: 'Estonia (+372)'},
|
||||
{code2: 'SZ', name: 'Eswatini (+268)'},
|
||||
{code2: 'ET', name: 'Ethiopia (+251)'},
|
||||
{code2: 'FK', name: 'Falkland Islands (Malvinas) (+500)'},
|
||||
{code2: 'FO', name: 'Faroe Islands (+298)'},
|
||||
{code2: 'FJ', name: 'Fiji (+679)'},
|
||||
{code2: 'FI', name: 'Finland (+358)'},
|
||||
{code2: 'FR', name: 'France (+33)'},
|
||||
{code2: 'GF', name: 'French Guiana (+594)'},
|
||||
{code2: 'PF', name: 'French Polynesia (+689)'},
|
||||
{code2: 'GA', name: 'Gabon (+241)'},
|
||||
{code2: 'GM', name: 'Gambia (+220)'},
|
||||
{code2: 'GE', name: 'Georgia (+995)'},
|
||||
{code2: 'DE', name: 'Germany (+49)'},
|
||||
{code2: 'GH', name: 'Ghana (+233)'},
|
||||
{code2: 'GI', name: 'Gibraltar (+350)'},
|
||||
{code2: 'GR', name: 'Greece (+30)'},
|
||||
{code2: 'GL', name: 'Greenland (+299)'},
|
||||
{code2: 'GD', name: 'Grenada (+1)'},
|
||||
{code2: 'GP', name: 'Guadeloupe (+590)'},
|
||||
{code2: 'GU', name: 'Guam (+1)'},
|
||||
{code2: 'GT', name: 'Guatemala (+502)'},
|
||||
{code2: 'GG', name: 'Guernsey (+44)'},
|
||||
{code2: 'GN', name: 'Guinea (+224)'},
|
||||
{code2: 'GW', name: 'Guinea-Bissau (+245)'},
|
||||
{code2: 'GY', name: 'Guyana (+592)'},
|
||||
{code2: 'HT', name: 'Haiti (+509)'},
|
||||
{code2: 'VA', name: 'Holy See (+39)'},
|
||||
{code2: 'HN', name: 'Honduras (+504)'},
|
||||
{code2: 'HK', name: 'Hong Kong (+852)'},
|
||||
{code2: 'HU', name: 'Hungary (+36)'},
|
||||
{code2: 'IS', name: 'Iceland (+354)'},
|
||||
{code2: 'IN', name: 'India (+91)'},
|
||||
{code2: 'ID', name: 'Indonesia (+62)'},
|
||||
{code2: 'IR', name: 'Iran (Islamic Republic of) (+98)'},
|
||||
{code2: 'IQ', name: 'Iraq (+964)'},
|
||||
{code2: 'IE', name: 'Ireland (+353)'},
|
||||
{code2: 'IM', name: 'Isle of Man (+44)'},
|
||||
{code2: 'IL', name: 'Israel (+972)'},
|
||||
{code2: 'IT', name: 'Italy (+39)'},
|
||||
{code2: 'JM', name: 'Jamaica (+1)'},
|
||||
{code2: 'JP', name: 'Japan (+81)'},
|
||||
{code2: 'JE', name: 'Jersey (+44)'},
|
||||
{code2: 'JO', name: 'Jordan (+962)'},
|
||||
{code2: 'KZ', name: 'Kazakhstan (+7)'},
|
||||
{code2: 'KE', name: 'Kenya (+254)'},
|
||||
{code2: 'KI', name: 'Kiribati (+686)'},
|
||||
{code2: 'KP', name: "Korea (Democratic People's Republic of) (+850)"},
|
||||
{code2: 'KR', name: 'Korea, Republic of (+82)'},
|
||||
{code2: 'KW', name: 'Kuwait (+965)'},
|
||||
{code2: 'KG', name: 'Kyrgyzstan (+996)'},
|
||||
{code2: 'LA', name: "Lao People's Democratic Republic (+856)"},
|
||||
{code2: 'LV', name: 'Latvia (+371)'},
|
||||
{code2: 'LB', name: 'Lebanon (+961)'},
|
||||
{code2: 'LS', name: 'Lesotho (+266)'},
|
||||
{code2: 'LR', name: 'Liberia (+231)'},
|
||||
{code2: 'LY', name: 'Libya (+218)'},
|
||||
{code2: 'LI', name: 'Liechtenstein (+423)'},
|
||||
{code2: 'LT', name: 'Lithuania (+370)'},
|
||||
{code2: 'LU', name: 'Luxembourg (+352)'},
|
||||
{code2: 'MO', name: 'Macao (+853)'},
|
||||
{code2: 'MG', name: 'Madagascar (+261)'},
|
||||
{code2: 'MW', name: 'Malawi (+265)'},
|
||||
{code2: 'MY', name: 'Malaysia (+60)'},
|
||||
{code2: 'MV', name: 'Maldives (+960)'},
|
||||
{code2: 'ML', name: 'Mali (+223)'},
|
||||
{code2: 'MT', name: 'Malta (+356)'},
|
||||
{code2: 'MH', name: 'Marshall Islands (+692)'},
|
||||
{code2: 'MQ', name: 'Martinique (+596)'},
|
||||
{code2: 'MR', name: 'Mauritania (+222)'},
|
||||
{code2: 'MU', name: 'Mauritius (+230)'},
|
||||
{code2: 'YT', name: 'Mayotte (+262)'},
|
||||
{code2: 'MX', name: 'Mexico (+52)'},
|
||||
{code2: 'FM', name: 'Micronesia (Federated States of) (+691)'},
|
||||
{code2: 'MD', name: 'Moldova, Republic of (+373)'},
|
||||
{code2: 'MC', name: 'Monaco (+377)'},
|
||||
{code2: 'MN', name: 'Mongolia (+976)'},
|
||||
{code2: 'ME', name: 'Montenegro (+382)'},
|
||||
{code2: 'MS', name: 'Montserrat (+1)'},
|
||||
{code2: 'MA', name: 'Morocco (+212)'},
|
||||
{code2: 'MZ', name: 'Mozambique (+258)'},
|
||||
{code2: 'MM', name: 'Myanmar (+95)'},
|
||||
{code2: 'NA', name: 'Namibia (+264)'},
|
||||
{code2: 'NR', name: 'Nauru (+674)'},
|
||||
{code2: 'NP', name: 'Nepal (+977)'},
|
||||
{code2: 'NL', name: 'Netherlands, Kingdom of the (+31)'},
|
||||
{code2: 'NC', name: 'New Caledonia (+687)'},
|
||||
{code2: 'NZ', name: 'New Zealand (+64)'},
|
||||
{code2: 'NI', name: 'Nicaragua (+505)'},
|
||||
{code2: 'NE', name: 'Niger (+227)'},
|
||||
{code2: 'NG', name: 'Nigeria (+234)'},
|
||||
{code2: 'NU', name: 'Niue (+683)'},
|
||||
{code2: 'NF', name: 'Norfolk Island (+672)'},
|
||||
{code2: 'MK', name: 'North Macedonia (+389)'},
|
||||
{code2: 'MP', name: 'Northern Mariana Islands (+1)'},
|
||||
{code2: 'NO', name: 'Norway (+47)'},
|
||||
{code2: 'OM', name: 'Oman (+968)'},
|
||||
{code2: 'PK', name: 'Pakistan (+92)'},
|
||||
{code2: 'PW', name: 'Palau (+680)'},
|
||||
{code2: 'PS', name: 'Palestine, State of (+970)'},
|
||||
{code2: 'PA', name: 'Panama (+507)'},
|
||||
{code2: 'PG', name: 'Papua New Guinea (+675)'},
|
||||
{code2: 'PY', name: 'Paraguay (+595)'},
|
||||
{code2: 'PE', name: 'Peru (+51)'},
|
||||
{code2: 'PH', name: 'Philippines (+63)'},
|
||||
{code2: 'PL', name: 'Poland (+48)'},
|
||||
{code2: 'PT', name: 'Portugal (+351)'},
|
||||
{code2: 'PR', name: 'Puerto Rico (+1)'},
|
||||
{code2: 'QA', name: 'Qatar (+974)'},
|
||||
{code2: 'RE', name: 'Réunion (+262)'},
|
||||
{code2: 'RO', name: 'Romania (+40)'},
|
||||
{code2: 'RU', name: 'Russian Federation (+7)'},
|
||||
{code2: 'RW', name: 'Rwanda (+250)'},
|
||||
{code2: 'BL', name: 'Saint Barthélemy (+590)'},
|
||||
{code2: 'SH', name: 'Saint Helena, Ascension and Tristan da Cunha (+290)'},
|
||||
{code2: 'KN', name: 'Saint Kitts and Nevis (+1)'},
|
||||
{code2: 'LC', name: 'Saint Lucia (+1)'},
|
||||
{code2: 'MF', name: 'Saint Martin (French part) (+590)'},
|
||||
{code2: 'PM', name: 'Saint Pierre and Miquelon (+508)'},
|
||||
{code2: 'VC', name: 'Saint Vincent and the Grenadines (+1)'},
|
||||
{code2: 'WS', name: 'Samoa (+685)'},
|
||||
{code2: 'SM', name: 'San Marino (+378)'},
|
||||
{code2: 'ST', name: 'Sao Tome and Principe (+239)'},
|
||||
{code2: 'SA', name: 'Saudi Arabia (+966)'},
|
||||
{code2: 'SN', name: 'Senegal (+221)'},
|
||||
{code2: 'RS', name: 'Serbia (+381)'},
|
||||
{code2: 'SC', name: 'Seychelles (+248)'},
|
||||
{code2: 'SL', name: 'Sierra Leone (+232)'},
|
||||
{code2: 'SG', name: 'Singapore (+65)'},
|
||||
{code2: 'SX', name: 'Sint Maarten (Dutch part) (+1)'},
|
||||
{code2: 'SK', name: 'Slovakia (+421)'},
|
||||
{code2: 'SI', name: 'Slovenia (+386)'},
|
||||
{code2: 'SB', name: 'Solomon Islands (+677)'},
|
||||
{code2: 'SO', name: 'Somalia (+252)'},
|
||||
{code2: 'ZA', name: 'South Africa (+27)'},
|
||||
{code2: 'SS', name: 'South Sudan (+211)'},
|
||||
{code2: 'ES', name: 'Spain (+34)'},
|
||||
{code2: 'LK', name: 'Sri Lanka (+94)'},
|
||||
{code2: 'SD', name: 'Sudan (+249)'},
|
||||
{code2: 'SR', name: 'Suriname (+597)'},
|
||||
{code2: 'SJ', name: 'Svalbard and Jan Mayen (+47)'},
|
||||
{code2: 'SE', name: 'Sweden (+46)'},
|
||||
{code2: 'CH', name: 'Switzerland (+41)'},
|
||||
{code2: 'SY', name: 'Syrian Arab Republic (+963)'},
|
||||
{code2: 'TW', name: 'Taiwan (+886)'},
|
||||
{code2: 'TJ', name: 'Tajikistan (+992)'},
|
||||
{code2: 'TZ', name: 'Tanzania, United Republic of (+255)'},
|
||||
{code2: 'TH', name: 'Thailand (+66)'},
|
||||
{code2: 'TL', name: 'Timor-Leste (+670)'},
|
||||
{code2: 'TG', name: 'Togo (+228)'},
|
||||
{code2: 'TK', name: 'Tokelau (+690)'},
|
||||
{code2: 'TO', name: 'Tonga (+676)'},
|
||||
{code2: 'TT', name: 'Trinidad and Tobago (+1)'},
|
||||
{code2: 'TN', name: 'Tunisia (+216)'},
|
||||
{code2: 'TR', name: 'Türkiye (+90)'},
|
||||
{code2: 'TM', name: 'Turkmenistan (+993)'},
|
||||
{code2: 'TC', name: 'Turks and Caicos Islands (+1)'},
|
||||
{code2: 'TV', name: 'Tuvalu (+688)'},
|
||||
{code2: 'UG', name: 'Uganda (+256)'},
|
||||
{code2: 'UA', name: 'Ukraine (+380)'},
|
||||
{code2: 'AE', name: 'United Arab Emirates (+971)'},
|
||||
{
|
||||
code2: 'GB',
|
||||
name: 'United Kingdom of Great Britain and Northern Ireland (+44)',
|
||||
},
|
||||
{code2: 'US', name: 'United States of America (+1)'},
|
||||
{code2: 'UY', name: 'Uruguay (+598)'},
|
||||
{code2: 'UZ', name: 'Uzbekistan (+998)'},
|
||||
{code2: 'VU', name: 'Vanuatu (+678)'},
|
||||
{code2: 'VE', name: 'Venezuela (Bolivarian Republic of) (+58)'},
|
||||
{code2: 'VN', name: 'Viet Nam (+84)'},
|
||||
{code2: 'VG', name: 'Virgin Islands (British) (+1)'},
|
||||
{code2: 'VI', name: 'Virgin Islands (U.S.) (+1)'},
|
||||
{code2: 'WF', name: 'Wallis and Futuna (+681)'},
|
||||
{code2: 'EH', name: 'Western Sahara (+212)'},
|
||||
{code2: 'YE', name: 'Yemen (+967)'},
|
||||
{code2: 'ZM', name: 'Zambia (+260)'},
|
||||
{code2: 'ZW', name: 'Zimbabwe (+263)'},
|
||||
]
|
||||
@@ -26,7 +26,7 @@ export interface LinkMeta {
|
||||
export async function getLinkMeta(
|
||||
agent: BskyAgent,
|
||||
url: string,
|
||||
timeout = 5e3,
|
||||
timeout = 15e3,
|
||||
): Promise<LinkMeta> {
|
||||
if (isBskyAppUrl(url)) {
|
||||
return extractBskyMeta(agent, url)
|
||||
|
||||
@@ -5,12 +5,13 @@ import {compressIfNeeded} from './manip'
|
||||
|
||||
let _imageCounter = 0
|
||||
async function getFile() {
|
||||
const files = await RNFS.readDir(
|
||||
let files = await RNFS.readDir(
|
||||
RNFS.LibraryDirectoryPath.split('/')
|
||||
.slice(0, -5)
|
||||
.concat(['Media', 'DCIM', '100APPLE'])
|
||||
.join('/'),
|
||||
)
|
||||
files = files.filter(file => file.path.endsWith('.JPG'))
|
||||
const file = files[_imageCounter++ % files.length]
|
||||
return await compressIfNeeded({
|
||||
path: file.path,
|
||||
|
||||
@@ -31,7 +31,7 @@ export type CommonNavigatorParams = {
|
||||
CopyrightPolicy: undefined
|
||||
AppPasswords: undefined
|
||||
SavedFeeds: undefined
|
||||
PreferencesHomeFeed: undefined
|
||||
PreferencesFollowingFeed: undefined
|
||||
PreferencesThreads: undefined
|
||||
PreferencesExternalEmbeds: undefined
|
||||
}
|
||||
|
||||
@@ -343,45 +343,45 @@ export function parseEmbedPlayerFromUrl(
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlayerHeight({
|
||||
export function getPlayerAspect({
|
||||
type,
|
||||
width,
|
||||
hasThumb,
|
||||
width,
|
||||
}: {
|
||||
type: EmbedPlayerParams['type']
|
||||
width: number
|
||||
hasThumb: boolean
|
||||
}) {
|
||||
if (!hasThumb) return (width / 16) * 9
|
||||
width: number
|
||||
}): {aspectRatio?: number; height?: number} {
|
||||
if (!hasThumb) return {aspectRatio: 16 / 9}
|
||||
|
||||
switch (type) {
|
||||
case 'youtube_video':
|
||||
case 'twitch_video':
|
||||
case 'vimeo_video':
|
||||
return (width / 16) * 9
|
||||
return {aspectRatio: 16 / 9}
|
||||
case 'youtube_short':
|
||||
if (SCREEN_HEIGHT < 600) {
|
||||
return ((width / 9) * 16) / 1.75
|
||||
return {aspectRatio: (9 / 16) * 1.75}
|
||||
} else {
|
||||
return ((width / 9) * 16) / 1.5
|
||||
return {aspectRatio: (9 / 16) * 1.5}
|
||||
}
|
||||
case 'spotify_album':
|
||||
case 'apple_music_album':
|
||||
case 'apple_music_playlist':
|
||||
case 'spotify_playlist':
|
||||
case 'soundcloud_set':
|
||||
return 380
|
||||
return {height: 380}
|
||||
case 'spotify_song':
|
||||
if (width <= 300) {
|
||||
return 155
|
||||
return {height: 155}
|
||||
}
|
||||
return 232
|
||||
return {height: 232}
|
||||
case 'soundcloud_track':
|
||||
return 165
|
||||
return {height: 165}
|
||||
case 'apple_music_song':
|
||||
return 150
|
||||
return {height: 150}
|
||||
default:
|
||||
return width
|
||||
return {aspectRatio: 16 / 9}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Regex from the go implementation
|
||||
// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
|
||||
const VALIDATE_REGEX =
|
||||
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
||||
|
||||
export function makeValidHandle(str: string): string {
|
||||
if (str.length > 20) {
|
||||
str = str.slice(0, 20)
|
||||
@@ -19,3 +24,27 @@ export function isInvalidHandle(handle: string): boolean {
|
||||
export function sanitizeHandle(handle: string, prefix = ''): string {
|
||||
return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}`
|
||||
}
|
||||
|
||||
export interface IsValidHandle {
|
||||
handleChars: boolean
|
||||
frontLength: boolean
|
||||
totalLength: boolean
|
||||
overall: boolean
|
||||
}
|
||||
|
||||
// More checks from https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/handle/index.ts#L72
|
||||
export function validateHandle(str: string, userDomain: string): IsValidHandle {
|
||||
const fullHandle = createFullHandle(str, userDomain)
|
||||
|
||||
const results = {
|
||||
handleChars:
|
||||
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
|
||||
frontLength: str.length >= 3,
|
||||
totalLength: fullHandle.length <= 253,
|
||||
}
|
||||
|
||||
return {
|
||||
...results,
|
||||
overall: !Object.values(results).includes(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function ago(date: number | string | Date): string {
|
||||
} else if (diffSeconds < DAY) {
|
||||
return `${Math.floor(diffSeconds / HOUR)}h`
|
||||
} else if (diffSeconds < MONTH) {
|
||||
return `${Math.floor(diffSeconds / DAY)}d`
|
||||
return `${Math.round(diffSeconds / DAY)}d`
|
||||
} else if (diffSeconds < YEAR) {
|
||||
return `${Math.floor(diffSeconds / MONTH)}mo`
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {PROD_SERVICE} from 'lib/constants'
|
||||
import {BSKY_SERVICE} from 'lib/constants'
|
||||
import TLDs from 'tlds'
|
||||
import psl from 'psl'
|
||||
|
||||
@@ -28,7 +28,7 @@ export function makeRecordUri(
|
||||
export function toNiceDomain(url: string): string {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
if (`https://${urlp.host}` === PROD_SERVICE) {
|
||||
if (`https://${urlp.host}` === BSKY_SERVICE) {
|
||||
return 'Bluesky Social'
|
||||
}
|
||||
return urlp.host ? urlp.host : url
|
||||
|
||||
+20
-1
@@ -306,7 +306,7 @@ export const darkTheme: Theme = {
|
||||
|
||||
// non-standard
|
||||
textVeryLight: darkPalette.contrast_400,
|
||||
replyLine: darkPalette.contrast_100,
|
||||
replyLine: darkPalette.contrast_200,
|
||||
replyLineDot: darkPalette.contrast_200,
|
||||
unreadNotifBg: darkPalette.primary_975,
|
||||
unreadNotifBorder: darkPalette.primary_900,
|
||||
@@ -344,6 +344,25 @@ export const dimTheme: Theme = {
|
||||
default: {
|
||||
...darkTheme.palette.default,
|
||||
background: dimPalette.black,
|
||||
backgroundLight: dimPalette.contrast_50,
|
||||
text: dimPalette.white,
|
||||
textLight: dimPalette.contrast_700,
|
||||
textInverted: dimPalette.black,
|
||||
link: dimPalette.primary_500,
|
||||
border: dimPalette.contrast_100,
|
||||
borderDark: dimPalette.contrast_200,
|
||||
icon: dimPalette.contrast_500,
|
||||
|
||||
// non-standard
|
||||
textVeryLight: dimPalette.contrast_400,
|
||||
replyLine: dimPalette.contrast_200,
|
||||
replyLineDot: dimPalette.contrast_200,
|
||||
unreadNotifBg: `hsl(211, 48%, 17%)`,
|
||||
unreadNotifBorder: `hsl(211, 48%, 30%)`,
|
||||
postCtrl: dimPalette.contrast_500,
|
||||
brandText: dimPalette.primary_500,
|
||||
emptyStateIcon: dimPalette.contrast_300,
|
||||
borderLinkHover: dimPalette.contrast_300,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -140,6 +140,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
||||
return AppLanguage.ca
|
||||
case 'zh-CN':
|
||||
return AppLanguage.zh_CN
|
||||
case 'it':
|
||||
return AppLanguage.it
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -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 messagesCa} from '#/locale/locales/ca/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 {AppLanguage} from '#/locale/languages'
|
||||
@@ -65,6 +66,11 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
}
|
||||
case AppLanguage.zh_CN: {
|
||||
i18n.loadAndActivate({locale, messages: messagesZh_CN})
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
i18n.loadAndActivate({locale, messages: messagesIt})
|
||||
break
|
||||
}
|
||||
default: {
|
||||
i18n.loadAndActivate({locale, messages: messagesEn})
|
||||
|
||||
@@ -56,6 +56,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
mod = await import(`./locales/zh-CN/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
mod = await import(`./locales/it/messages`)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
mod = await import(`./locales/en/messages`)
|
||||
break
|
||||
|
||||
@@ -17,6 +17,7 @@ export enum AppLanguage {
|
||||
uk = 'uk',
|
||||
ca = 'ca',
|
||||
zh_CN = 'zh-CN',
|
||||
it = 'it',
|
||||
}
|
||||
|
||||
interface AppLanguageConfig {
|
||||
@@ -37,6 +38,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
|
||||
{code2: AppLanguage.uk, name: 'Українська – Ukrainian'},
|
||||
{code2: AppLanguage.ca, name: 'Català – Catalan'},
|
||||
{code2: AppLanguage.zh_CN, name: '简体中文(中国) – Chinese (Simplified)'},
|
||||
{code2: AppLanguage.it, name: 'Italiano - Italian'},
|
||||
]
|
||||
|
||||
export const LANGUAGES: Language[] = [
|
||||
|
||||
+721
-607
File diff suppressed because it is too large
Load Diff
+755
-641
File diff suppressed because it is too large
Load Diff
+719
-605
File diff suppressed because it is too large
Load Diff
+721
-607
File diff suppressed because it is too large
Load Diff
+721
-607
File diff suppressed because it is too large
Load Diff
+721
-607
File diff suppressed because it is too large
Load Diff
+721
-607
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+721
-607
File diff suppressed because it is too large
Load Diff
+721
-607
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+721
-607
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -25,7 +25,7 @@ export const router = new Router({
|
||||
DebugMod: '/sys/debug-mod',
|
||||
Log: '/sys/log',
|
||||
AppPasswords: '/settings/app-passwords',
|
||||
PreferencesHomeFeed: '/settings/home-feed',
|
||||
PreferencesFollowingFeed: '/settings/following-feed',
|
||||
PreferencesThreads: '/settings/threads',
|
||||
PreferencesExternalEmbeds: '/settings/external-embeds',
|
||||
SavedFeeds: '/settings/saved-feeds',
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
flatten,
|
||||
TextStyleProp,
|
||||
} 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 {Button, ButtonIcon} from '#/components/Button'
|
||||
import {ScrollView} from '#/view/com/util/Views'
|
||||
@@ -209,16 +209,18 @@ export function Title({
|
||||
style,
|
||||
}: React.PropsWithChildren<TextStyleProp>) {
|
||||
return (
|
||||
<H2
|
||||
<Text
|
||||
style={[
|
||||
a.pb_sm,
|
||||
a.text_4xl,
|
||||
a.font_bold,
|
||||
{
|
||||
lineHeight: leading(a.text_4xl, a.leading_tight),
|
||||
},
|
||||
flatten(style),
|
||||
]}>
|
||||
{children}
|
||||
</H2>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
export type FeedConfig = {
|
||||
default: boolean
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
Description,
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
import {
|
||||
bulkWriteFollows,
|
||||
sortPrimaryAlgorithmFeeds,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
usePreferencesQuery,
|
||||
useSetFeedViewPreferencesMutation,
|
||||
} from 'state/queries/preferences'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
export function StepFollowingFeed() {
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
OnboardingControls,
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
export function StepInterests() {
|
||||
const {_} = useLingui()
|
||||
@@ -139,7 +139,7 @@ export function StepInterests() {
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.align_start]}>
|
||||
<View style={[a.align_start]} testID="onboardingInterests">
|
||||
<IconCircle
|
||||
icon={isError ? EmojiSad : Hashtag}
|
||||
style={[
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
|
||||
import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
function AnimatedDivider() {
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
SuggestedAccountCardPlaceholder,
|
||||
} from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard'
|
||||
import {aggregateInterestItems} from '#/screens/Onboarding/util'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
export function Inner({
|
||||
profiles,
|
||||
|
||||
@@ -3,7 +3,6 @@ import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {IS_PROD} from '#/env'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
|
||||
@@ -21,22 +20,29 @@ import {
|
||||
} from '#/screens/Onboarding/Layout'
|
||||
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
|
||||
import {aggregateInterestItems} from '#/screens/Onboarding/util'
|
||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
import {IS_PROD_SERVICE} from 'lib/constants'
|
||||
import {useSession} from 'state/session'
|
||||
|
||||
export function StepTopicalFeeds() {
|
||||
const {_} = useLingui()
|
||||
const {track} = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
|
||||
const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const suggestedFeedUris = React.useMemo(() => {
|
||||
if (!IS_PROD) return []
|
||||
if (!IS_PROD_SERVICE(currentAccount?.service)) return []
|
||||
return aggregateInterestItems(
|
||||
state.interestsStepResults.selectedInterests,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris.default,
|
||||
).slice(0, 10)
|
||||
}, [state.interestsStepResults])
|
||||
}, [
|
||||
currentAccount?.service,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris,
|
||||
state.interestsStepResults.selectedInterests,
|
||||
])
|
||||
|
||||
const interestsText = React.useMemo(() => {
|
||||
const i = state.interestsStepResults.selectedInterests.map(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -133,23 +133,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||
return query
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper is used by the post-thread placeholder function to
|
||||
* find a post in the query-data cache
|
||||
*/
|
||||
export function findPostInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): AppBskyFeedDefs.PostView | undefined {
|
||||
const generator = findAllPostsInQueryData(queryClient, uri)
|
||||
const result = generator.next()
|
||||
if (result.done) {
|
||||
return undefined
|
||||
} else {
|
||||
return result.value
|
||||
}
|
||||
}
|
||||
|
||||
export function* findAllPostsInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
|
||||
@@ -376,23 +376,6 @@ function createApi(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper is used by the post-thread placeholder function to
|
||||
* find a post in the query-data cache
|
||||
*/
|
||||
export function findPostInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): AppBskyFeedDefs.PostView | undefined {
|
||||
const generator = findAllPostsInQueryData(queryClient, uri)
|
||||
const result = generator.next()
|
||||
if (result.done) {
|
||||
return undefined
|
||||
} else {
|
||||
return result.value
|
||||
}
|
||||
}
|
||||
|
||||
export function* findAllPostsInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
|
||||
@@ -8,8 +8,8 @@ import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||
import {findPostInQueryData as findPostInFeedQueryData} from './post-feed'
|
||||
import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed'
|
||||
import {precacheThreadPostProfiles} from './profile'
|
||||
import {getEmbeddedPost} from './util'
|
||||
|
||||
@@ -82,21 +82,9 @@ export function usePostThreadQuery(uri: string | undefined) {
|
||||
return undefined
|
||||
}
|
||||
{
|
||||
const item = findPostInQueryData(queryClient, uri)
|
||||
if (item) {
|
||||
return threadNodeToPlaceholderThread(item)
|
||||
}
|
||||
}
|
||||
{
|
||||
const item = findPostInFeedQueryData(queryClient, uri)
|
||||
if (item) {
|
||||
return postViewToPlaceholderThread(item)
|
||||
}
|
||||
}
|
||||
{
|
||||
const item = findPostInNotifsQueryData(queryClient, uri)
|
||||
if (item) {
|
||||
return postViewToPlaceholderThread(item)
|
||||
const post = findPostInQueryData(queryClient, uri)
|
||||
if (post) {
|
||||
return post
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
@@ -171,11 +159,18 @@ function responseToThreadNodes(
|
||||
AppBskyFeedPost.isRecord(node.post.record) &&
|
||||
AppBskyFeedPost.validateRecord(node.post.record).success
|
||||
) {
|
||||
const post = node.post
|
||||
// These should normally be present. They're missing only for
|
||||
// posts that were *just* created. Ideally, the backend would
|
||||
// know to return zeros. Fill them in manually to compensate.
|
||||
post.replyCount ??= 0
|
||||
post.likeCount ??= 0
|
||||
post.repostCount ??= 0
|
||||
return {
|
||||
type: 'post',
|
||||
_reactKey: node.post.uri,
|
||||
uri: node.post.uri,
|
||||
post: node.post,
|
||||
post: post,
|
||||
record: node.post.record,
|
||||
parent:
|
||||
node.parent && direction !== 'down'
|
||||
@@ -213,14 +208,24 @@ function responseToThreadNodes(
|
||||
function findPostInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): ThreadNode | undefined {
|
||||
const generator = findAllPostsInQueryData(queryClient, uri)
|
||||
const result = generator.next()
|
||||
if (result.done) {
|
||||
return undefined
|
||||
} else {
|
||||
return result.value
|
||||
): ThreadNode | void {
|
||||
let partial
|
||||
for (let item of findAllPostsInQueryData(queryClient, uri)) {
|
||||
if (item.type === 'post') {
|
||||
// Currently, the backend doesn't send full post info in some cases
|
||||
// (for example, for quoted posts). We use missing `likeCount`
|
||||
// as a way to detect that. In the future, we should fix this on
|
||||
// the backend, which will let us always stop on the first result.
|
||||
const hasAllInfo = item.post.likeCount != null
|
||||
if (hasAllInfo) {
|
||||
return item
|
||||
} else {
|
||||
partial = item
|
||||
// Keep searching, we might still find a full post in the cache.
|
||||
}
|
||||
}
|
||||
}
|
||||
return partial
|
||||
}
|
||||
|
||||
export function* findAllPostsInQueryData(
|
||||
@@ -236,7 +241,10 @@ export function* findAllPostsInQueryData(
|
||||
}
|
||||
for (const item of traverseThread(queryData)) {
|
||||
if (item.uri === uri) {
|
||||
yield item
|
||||
const placeholder = threadNodeToPlaceholderThread(item)
|
||||
if (placeholder) {
|
||||
yield placeholder
|
||||
}
|
||||
}
|
||||
const quotedPost =
|
||||
item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
|
||||
@@ -245,6 +253,12 @@ export function* findAllPostsInQueryData(
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
||||
yield postViewToPlaceholderThread(post)
|
||||
}
|
||||
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
||||
yield postViewToPlaceholderThread(post)
|
||||
}
|
||||
}
|
||||
|
||||
function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
|
||||
|
||||
@@ -53,5 +53,6 @@ export function embedViewRecordToPostView(
|
||||
record: v.value,
|
||||
indexedAt: v.indexedAt,
|
||||
labels: v.labels,
|
||||
embed: v.embeds?.[0],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {networkRetry} from '#/lib/async/retry'
|
||||
import {logger} from '#/logger'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {PUBLIC_BSKY_AGENT} from '#/state/queries'
|
||||
import {IS_PROD} from '#/lib/constants'
|
||||
import {emitSessionDropped} from '../events'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
@@ -36,7 +35,6 @@ export type SessionState = {
|
||||
}
|
||||
export type StateContext = SessionState & {
|
||||
hasSession: boolean
|
||||
isSandbox: boolean
|
||||
}
|
||||
export type ApiContext = {
|
||||
createAccount: (props: {
|
||||
@@ -84,7 +82,6 @@ const StateContext = React.createContext<StateContext>({
|
||||
accounts: [],
|
||||
currentAccount: undefined,
|
||||
hasSession: false,
|
||||
isSandbox: false,
|
||||
})
|
||||
|
||||
const ApiContext = React.createContext<ApiContext>({
|
||||
@@ -612,9 +609,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
() => ({
|
||||
...state,
|
||||
hasSession: !!state.currentAccount,
|
||||
isSandbox: state.currentAccount
|
||||
? !IS_PROD(state.currentAccount?.service)
|
||||
: false,
|
||||
}),
|
||||
[state],
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
|
||||
import {s, colors} from 'lib/styles'
|
||||
@@ -9,6 +10,14 @@ import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
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 = ({
|
||||
onPressSignin,
|
||||
@@ -20,6 +29,22 @@ export const SplashScreen = ({
|
||||
const pal = usePalette('default')
|
||||
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 (
|
||||
<CenteredView style={[styles.container, pal.view]}>
|
||||
<ErrorBoundary>
|
||||
@@ -58,6 +83,51 @@ export const SplashScreen = ({
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</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>
|
||||
</CenteredView>
|
||||
)
|
||||
@@ -73,7 +143,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
btns: {
|
||||
paddingBottom: 40,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
@@ -95,4 +165,11 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
fontSize: 21,
|
||||
},
|
||||
footer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,9 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
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 = ({
|
||||
onDismiss,
|
||||
@@ -98,24 +102,84 @@ export const SplashScreen = ({
|
||||
|
||||
function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
|
||||
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 (
|
||||
<View style={[styles.footer, pal.view, pal.border]}>
|
||||
<TextLink
|
||||
href="https://bsky.social"
|
||||
text="Business"
|
||||
text={_(msg`Business`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
href="https://bsky.social/about/blog"
|
||||
text="Blog"
|
||||
text={_(msg`Blog`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
href="https://bsky.social/about/join"
|
||||
text="Jobs"
|
||||
text={_(msg`Jobs`)}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -188,9 +252,10 @@ const useStyles = () => {
|
||||
padding: 20,
|
||||
borderTopWidth: 1,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 20,
|
||||
},
|
||||
footerLink: {
|
||||
marginRight: 20,
|
||||
},
|
||||
footerDivider: {flexGrow: 1},
|
||||
footerLink: {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react'
|
||||
import {WebView, WebViewNavigation} from 'react-native-webview'
|
||||
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {CreateAccountState} from 'view/com/auth/create/state'
|
||||
|
||||
const ALLOWED_HOSTS = [
|
||||
'bsky.social',
|
||||
'bsky.app',
|
||||
'staging.bsky.app',
|
||||
'staging.bsky.dev',
|
||||
'js.hcaptcha.com',
|
||||
'newassets.hcaptcha.com',
|
||||
'api2.hcaptcha.com',
|
||||
]
|
||||
|
||||
export function CaptchaWebView({
|
||||
url,
|
||||
stateParam,
|
||||
uiState,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
url: string
|
||||
stateParam: string
|
||||
uiState?: CreateAccountState
|
||||
onSuccess: (code: string) => void
|
||||
onError: () => void
|
||||
}) {
|
||||
const redirectHost = React.useMemo(() => {
|
||||
if (!uiState?.serviceUrl) return 'bsky.app'
|
||||
|
||||
return uiState?.serviceUrl &&
|
||||
new URL(uiState?.serviceUrl).host === 'staging.bsky.dev'
|
||||
? 'staging.bsky.app'
|
||||
: 'bsky.app'
|
||||
}, [uiState?.serviceUrl])
|
||||
|
||||
const wasSuccessful = React.useRef(false)
|
||||
|
||||
const onShouldStartLoadWithRequest = React.useCallback(
|
||||
(event: ShouldStartLoadRequest) => {
|
||||
const urlp = new URL(event.url)
|
||||
return ALLOWED_HOSTS.includes(urlp.host)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const onNavigationStateChange = React.useCallback(
|
||||
(e: WebViewNavigation) => {
|
||||
if (wasSuccessful.current) return
|
||||
|
||||
const urlp = new URL(e.url)
|
||||
if (urlp.host !== redirectHost) return
|
||||
|
||||
const code = urlp.searchParams.get('code')
|
||||
if (urlp.searchParams.get('state') !== stateParam || !code) {
|
||||
onError()
|
||||
return
|
||||
}
|
||||
|
||||
wasSuccessful.current = true
|
||||
onSuccess(code)
|
||||
},
|
||||
[redirectHost, stateParam, onSuccess, onError],
|
||||
)
|
||||
|
||||
return (
|
||||
<WebView
|
||||
source={{uri: url}}
|
||||
javaScriptEnabled
|
||||
style={styles.webview}
|
||||
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||
onNavigationStateChange={onNavigationStateChange}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webview: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 10,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
|
||||
// @ts-ignore web only, we will always redirect to the app on web (CORS)
|
||||
const REDIRECT_HOST = new URL(window.location.href).host
|
||||
|
||||
export function CaptchaWebView({
|
||||
url,
|
||||
stateParam,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
url: string
|
||||
stateParam: string
|
||||
onSuccess: (code: string) => void
|
||||
onError: () => void
|
||||
}) {
|
||||
const onLoad = React.useCallback(() => {
|
||||
// @ts-ignore web
|
||||
const frame: HTMLIFrameElement = document.getElementById(
|
||||
'captcha-iframe',
|
||||
) as HTMLIFrameElement
|
||||
|
||||
try {
|
||||
// @ts-ignore web
|
||||
const href = frame?.contentWindow?.location.href
|
||||
if (!href) return
|
||||
const urlp = new URL(href)
|
||||
|
||||
// This shouldn't happen with CORS protections, but for good measure
|
||||
if (urlp.host !== REDIRECT_HOST) return
|
||||
|
||||
const code = urlp.searchParams.get('code')
|
||||
if (urlp.searchParams.get('state') !== stateParam || !code) {
|
||||
onError()
|
||||
return
|
||||
}
|
||||
onSuccess(code)
|
||||
} catch (e) {
|
||||
// We don't need to handle this
|
||||
}
|
||||
}, [stateParam, onSuccess, onError])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={url}
|
||||
style={styles.iframe}
|
||||
id="captcha-iframe"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
iframe: {
|
||||
flex: 1,
|
||||
borderWidth: 0,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
})
|
||||
@@ -13,33 +13,25 @@ import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {useCreateAccount, submit} from './state'
|
||||
import {useCreateAccount, useSubmitCreateAccount} from './state'
|
||||
import {useServiceQuery} from '#/state/queries/service'
|
||||
import {
|
||||
usePreferencesSetBirthDateMutation,
|
||||
useSetSaveFeedsMutation,
|
||||
DEFAULT_PROD_FEEDS,
|
||||
} from '#/state/queries/preferences'
|
||||
import {FEEDBACK_FORM_URL, HITSLOP_10, IS_PROD} from '#/lib/constants'
|
||||
import {FEEDBACK_FORM_URL, HITSLOP_10} from '#/lib/constants'
|
||||
|
||||
import {Step1} from './Step1'
|
||||
import {Step2} from './Step2'
|
||||
import {Step3} from './Step3'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {TextLink} from '../../util/Link'
|
||||
import {getAgent} from 'state/session'
|
||||
import {createFullHandle, validateHandle} from 'lib/strings/handles'
|
||||
|
||||
export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
|
||||
const {screen} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const [uiState, uiDispatch] = useCreateAccount()
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
const {createAccount} = useSessionApi()
|
||||
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
|
||||
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const submit = useSubmitCreateAccount(uiState, uiDispatch)
|
||||
|
||||
React.useEffect(() => {
|
||||
screen('CreateAccount')
|
||||
@@ -84,33 +76,52 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
|
||||
if (!uiState.canNext) {
|
||||
return
|
||||
}
|
||||
if (uiState.step < 3) {
|
||||
uiDispatch({type: 'next'})
|
||||
} else {
|
||||
|
||||
if (uiState.step === 2) {
|
||||
if (!validateHandle(uiState.handle, uiState.userDomain).overall) {
|
||||
return
|
||||
}
|
||||
|
||||
uiDispatch({type: 'set-processing', value: true})
|
||||
try {
|
||||
await submit({
|
||||
onboardingDispatch,
|
||||
createAccount,
|
||||
uiState,
|
||||
uiDispatch,
|
||||
_,
|
||||
const res = await getAgent().resolveHandle({
|
||||
handle: createFullHandle(uiState.handle, uiState.userDomain),
|
||||
})
|
||||
setBirthDate({birthDate: uiState.birthDate})
|
||||
if (IS_PROD(uiState.serviceUrl)) {
|
||||
setSavedFeeds(DEFAULT_PROD_FEEDS)
|
||||
|
||||
if (res.data.did) {
|
||||
uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`That handle is already taken.`),
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// dont need to handle here
|
||||
} catch (e) {
|
||||
// Don't need to handle
|
||||
} finally {
|
||||
uiDispatch({type: 'set-processing', value: false})
|
||||
}
|
||||
|
||||
if (!uiState.isCaptchaRequired) {
|
||||
try {
|
||||
await submit()
|
||||
} catch {
|
||||
// dont need to handle here
|
||||
}
|
||||
// We don't need to go to the next page if there wasn't a captcha required
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
uiDispatch({type: 'next'})
|
||||
}, [
|
||||
uiState,
|
||||
uiState.canNext,
|
||||
uiState.step,
|
||||
uiState.isCaptchaRequired,
|
||||
uiState.handle,
|
||||
uiState.userDomain,
|
||||
uiDispatch,
|
||||
onboardingDispatch,
|
||||
createAccount,
|
||||
setBirthDate,
|
||||
setSavedFeeds,
|
||||
_,
|
||||
submit,
|
||||
])
|
||||
|
||||
// rendering
|
||||
|
||||
@@ -73,6 +73,10 @@ export function Step1({
|
||||
/>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your account`)} />
|
||||
|
||||
{uiState.error ? (
|
||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
||||
) : undefined}
|
||||
|
||||
<View style={s.pb20}>
|
||||
<Text type="md-medium" style={[pal.text, s.mb2]}>
|
||||
<Trans>Hosting provider</Trans>
|
||||
@@ -88,7 +92,7 @@ export function Step1({
|
||||
style={[pal.textLight, {marginLeft: 14}]}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
testID="loginSelectServiceButton"
|
||||
testID="selectServiceButton"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
@@ -259,9 +263,6 @@ export function Step1({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{uiState.error ? (
|
||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
||||
) : undefined}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -269,7 +270,7 @@ export function Step1({
|
||||
const styles = StyleSheet.create({
|
||||
error: {
|
||||
borderRadius: 6,
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
dateInputButton: {
|
||||
borderWidth: 1,
|
||||
|
||||
+106
-273
@@ -1,35 +1,26 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import RNPickerSelect from 'react-native-picker-select'
|
||||
import {
|
||||
CreateAccountState,
|
||||
CreateAccountDispatch,
|
||||
requestVerificationCode,
|
||||
} from './state'
|
||||
import {View} from 'react-native'
|
||||
import {CreateAccountState, CreateAccountDispatch} from './state'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {StepHeader} from './StepHeader'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {TextInput} from '../util/TextInput'
|
||||
import {Button} from '../../util/forms/Button'
|
||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||
import {isAndroid, isWeb} from 'platform/detection'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import parsePhoneNumber from 'libphonenumber-js'
|
||||
import {COUNTRY_CODES} from '#/lib/country-codes'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
createFullHandle,
|
||||
IsValidHandle,
|
||||
validateHandle,
|
||||
} from 'lib/strings/handles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
/** STEP 3: Your user handle
|
||||
* @field User handle
|
||||
*/
|
||||
export function Step2({
|
||||
uiState,
|
||||
uiDispatch,
|
||||
@@ -39,269 +30,111 @@ export function Step2({
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const t = useTheme()
|
||||
|
||||
const onPressRequest = React.useCallback(() => {
|
||||
const phoneNumber = parsePhoneNumber(
|
||||
uiState.verificationPhone,
|
||||
uiState.phoneCountry,
|
||||
)
|
||||
if (phoneNumber && phoneNumber.isValid()) {
|
||||
requestVerificationCode({uiState, uiDispatch, _})
|
||||
} else {
|
||||
uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(
|
||||
msg`There's something wrong with this number. Please choose your country and enter your full phone number!`,
|
||||
),
|
||||
})
|
||||
}
|
||||
}, [uiState, uiDispatch, _])
|
||||
const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
|
||||
handleChars: false,
|
||||
frontLength: false,
|
||||
totalLength: true,
|
||||
overall: false,
|
||||
})
|
||||
|
||||
const onPressRetry = React.useCallback(() => {
|
||||
uiDispatch({type: 'set-has-requested-verification-code', value: false})
|
||||
}, [uiDispatch])
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
setValidCheck(validateHandle(uiState.handle, uiState.userDomain))
|
||||
|
||||
const phoneNumberFormatted = React.useMemo(
|
||||
() =>
|
||||
uiState.hasRequestedVerificationCode
|
||||
? parsePhoneNumber(
|
||||
uiState.verificationPhone,
|
||||
uiState.phoneCountry,
|
||||
)?.formatInternational()
|
||||
: '',
|
||||
[
|
||||
uiState.hasRequestedVerificationCode,
|
||||
uiState.verificationPhone,
|
||||
uiState.phoneCountry,
|
||||
],
|
||||
// Disabling this, because we only want to run this when we focus the screen
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []),
|
||||
)
|
||||
|
||||
const onHandleChange = React.useCallback(
|
||||
(value: string) => {
|
||||
if (uiState.error) {
|
||||
uiDispatch({type: 'set-error', value: ''})
|
||||
}
|
||||
|
||||
setValidCheck(validateHandle(value, uiState.userDomain))
|
||||
uiDispatch({type: 'set-handle', value})
|
||||
},
|
||||
[uiDispatch, uiState.error, uiState.userDomain],
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<StepHeader uiState={uiState} title={_(msg`SMS verification`)} />
|
||||
|
||||
{!uiState.hasRequestedVerificationCode ? (
|
||||
<>
|
||||
<View style={s.pb10}>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={[pal.text, s.mb2]}
|
||||
nativeID="phoneCountry">
|
||||
<Trans>Country</Trans>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
|
||||
<View style={s.pb10}>
|
||||
<View style={s.mb20}>
|
||||
<TextInput
|
||||
testID="handleInput"
|
||||
icon="at"
|
||||
placeholder="e.g. alice"
|
||||
value={uiState.handle}
|
||||
editable
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
onChange={onHandleChange}
|
||||
// TODO: Add explicit text label
|
||||
accessibilityLabel={_(msg`User handle`)}
|
||||
accessibilityHint={_(msg`Input your user handle`)}
|
||||
/>
|
||||
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
|
||||
<Trans>Your full handle will be</Trans>{' '}
|
||||
<Text type="lg-bold" style={pal.text}>
|
||||
@{createFullHandle(uiState.handle, uiState.userDomain)}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
{position: 'relative'},
|
||||
isAndroid && {
|
||||
borderWidth: 1,
|
||||
borderColor: pal.border.borderColor,
|
||||
borderRadius: 4,
|
||||
},
|
||||
]}>
|
||||
<RNPickerSelect
|
||||
placeholder={{}}
|
||||
value={uiState.phoneCountry}
|
||||
onValueChange={value =>
|
||||
uiDispatch({type: 'set-phone-country', value})
|
||||
}
|
||||
items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({
|
||||
label: l.name,
|
||||
value: l.code2,
|
||||
key: l.code2,
|
||||
}))}
|
||||
style={{
|
||||
inputAndroid: {
|
||||
backgroundColor: pal.view.backgroundColor,
|
||||
color: pal.text.color,
|
||||
fontSize: 21,
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: '500',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
inputIOS: {
|
||||
backgroundColor: pal.view.backgroundColor,
|
||||
color: pal.text.color,
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: '500',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: pal.border.borderColor,
|
||||
borderRadius: 4,
|
||||
},
|
||||
inputWeb: {
|
||||
// @ts-ignore web only
|
||||
cursor: 'pointer',
|
||||
'-moz-appearance': 'none',
|
||||
'-webkit-appearance': 'none',
|
||||
appearance: 'none',
|
||||
outline: 0,
|
||||
borderWidth: 1,
|
||||
borderColor: pal.border.borderColor,
|
||||
backgroundColor: pal.view.backgroundColor,
|
||||
color: pal.text.color,
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: '500',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
accessibilityLabel={_(msg`Select your phone's country`)}
|
||||
accessibilityHint=""
|
||||
accessibilityLabelledBy="phoneCountry"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 1,
|
||||
right: 1,
|
||||
bottom: 1,
|
||||
width: 40,
|
||||
pointerEvents: 'none',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<FontAwesomeIcon
|
||||
icon="chevron-down"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
a.p_md,
|
||||
a.gap_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{uiState.error ? (
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon valid={false} />
|
||||
<Text style={[t.atoms.text, a.text_md, a.flex]}>
|
||||
{uiState.error}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={s.pb20}>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={[pal.text, s.mb2]}
|
||||
nativeID="phoneNumber">
|
||||
<Trans>Phone number</Trans>
|
||||
) : undefined}
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon valid={validCheck.handleChars} />
|
||||
<Text style={[t.atoms.text, a.text_md, a.flex]}>
|
||||
<Trans>May only contain letters and numbers</Trans>
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="phoneInput"
|
||||
icon="phone"
|
||||
placeholder={_(msg`Enter your phone number`)}
|
||||
value={uiState.verificationPhone}
|
||||
editable
|
||||
onChange={value =>
|
||||
uiDispatch({type: 'set-verification-phone', value})
|
||||
}
|
||||
accessibilityLabel={_(msg`Email`)}
|
||||
accessibilityHint={_(
|
||||
msg`Input phone number for SMS verification`,
|
||||
)}
|
||||
accessibilityLabelledBy="phoneNumber"
|
||||
keyboardType="phone-pad"
|
||||
autoCapitalize="none"
|
||||
autoComplete="tel"
|
||||
autoCorrect={false}
|
||||
autoFocus={true}
|
||||
</View>
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon
|
||||
valid={validCheck.frontLength && validCheck.totalLength}
|
||||
/>
|
||||
<Text type="sm" style={[pal.textLight, s.mt5]}>
|
||||
<Trans>
|
||||
Please enter a phone number that can receive SMS text messages.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={isMobile ? {} : {flexDirection: 'row'}}>
|
||||
{uiState.isProcessing ? (
|
||||
<ActivityIndicator />
|
||||
{!validCheck.totalLength ? (
|
||||
<Text style={[t.atoms.text]}>
|
||||
<Trans>May not be longer than 253 characters</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Button
|
||||
testID="requestCodeBtn"
|
||||
type="primary"
|
||||
label={_(msg`Request code`)}
|
||||
labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []}
|
||||
style={
|
||||
isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {}
|
||||
}
|
||||
onPress={onPressRequest}
|
||||
/>
|
||||
<Text style={[t.atoms.text, a.text_md]}>
|
||||
<Trans>Must be at least 3 characters</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View style={s.pb20}>
|
||||
<View
|
||||
style={[
|
||||
s.flexRow,
|
||||
s.mb5,
|
||||
s.alignCenter,
|
||||
{justifyContent: 'space-between'},
|
||||
]}>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={pal.text}
|
||||
nativeID="verificationCode">
|
||||
<Trans>Verification code</Trans>{' '}
|
||||
</Text>
|
||||
<TouchableWithoutFeedback
|
||||
onPress={onPressRetry}
|
||||
accessibilityLabel={_(msg`Retry.`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}>
|
||||
<View style={styles.touchable}>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={pal.link}
|
||||
nativeID="verificationCode">
|
||||
<Trans>Retry</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
<TextInput
|
||||
testID="codeInput"
|
||||
icon="hashtag"
|
||||
placeholder={_(msg`XXXXXX`)}
|
||||
value={uiState.verificationCode}
|
||||
editable
|
||||
onChange={value =>
|
||||
uiDispatch({type: 'set-verification-code', value})
|
||||
}
|
||||
accessibilityLabel={_(msg`Email`)}
|
||||
accessibilityHint={_(
|
||||
msg`Input the verification code we have texted to you`,
|
||||
)}
|
||||
accessibilityLabelledBy="verificationCode"
|
||||
keyboardType="phone-pad"
|
||||
autoCapitalize="none"
|
||||
autoComplete="one-time-code"
|
||||
textContentType="oneTimeCode"
|
||||
autoCorrect={false}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<Text type="sm" style={[pal.textLight, s.mt5]}>
|
||||
<Trans>
|
||||
Please enter the verification code sent to{' '}
|
||||
{phoneNumberFormatted}.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{uiState.error ? (
|
||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
||||
) : undefined}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
error: {
|
||||
borderRadius: 6,
|
||||
marginTop: 10,
|
||||
},
|
||||
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
|
||||
touchable: {
|
||||
...(isWeb && {cursor: 'pointer'}),
|
||||
},
|
||||
})
|
||||
function IsValidIcon({valid}: {valid: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!valid) {
|
||||
return <Check size="md" style={{color: t.palette.negative_500}} />
|
||||
}
|
||||
|
||||
return <Times size="md" style={{color: t.palette.positive_700}} />
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {CreateAccountState, CreateAccountDispatch} from './state'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {
|
||||
CreateAccountState,
|
||||
CreateAccountDispatch,
|
||||
useSubmitCreateAccount,
|
||||
} from './state'
|
||||
import {StepHeader} from './StepHeader'
|
||||
import {s} from 'lib/styles'
|
||||
import {TextInput} from '../util/TextInput'
|
||||
import {createFullHandle} from 'lib/strings/handles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
/** STEP 3: Your user handle
|
||||
* @field User handle
|
||||
*/
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {createFullHandle} from 'lib/strings/handles'
|
||||
|
||||
const CAPTCHA_PATH = '/gate/signup'
|
||||
|
||||
export function Step3({
|
||||
uiState,
|
||||
uiDispatch,
|
||||
@@ -21,33 +25,66 @@ export function Step3({
|
||||
uiState: CreateAccountState
|
||||
uiDispatch: CreateAccountDispatch
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const theme = useTheme()
|
||||
const submit = useSubmitCreateAccount(uiState, uiDispatch)
|
||||
|
||||
const [completed, setCompleted] = React.useState(false)
|
||||
|
||||
const stateParam = React.useMemo(() => nanoid(15), [])
|
||||
const url = React.useMemo(() => {
|
||||
const newUrl = new URL(uiState.serviceUrl)
|
||||
newUrl.pathname = CAPTCHA_PATH
|
||||
newUrl.searchParams.set(
|
||||
'handle',
|
||||
createFullHandle(uiState.handle, uiState.userDomain),
|
||||
)
|
||||
newUrl.searchParams.set('state', stateParam)
|
||||
newUrl.searchParams.set('colorScheme', theme.colorScheme)
|
||||
|
||||
console.log(newUrl)
|
||||
|
||||
return newUrl.href
|
||||
}, [
|
||||
uiState.serviceUrl,
|
||||
uiState.handle,
|
||||
uiState.userDomain,
|
||||
stateParam,
|
||||
theme.colorScheme,
|
||||
])
|
||||
|
||||
const onSuccess = React.useCallback(
|
||||
(code: string) => {
|
||||
setCompleted(true)
|
||||
submit(code)
|
||||
},
|
||||
[submit],
|
||||
)
|
||||
|
||||
const onError = React.useCallback(() => {
|
||||
uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Error receiving captcha response.`),
|
||||
})
|
||||
}, [_, uiDispatch])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
|
||||
<View style={s.pb10}>
|
||||
<TextInput
|
||||
testID="handleInput"
|
||||
icon="at"
|
||||
placeholder="e.g. alice"
|
||||
value={uiState.handle}
|
||||
editable
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
onChange={value => uiDispatch({type: 'set-handle', value})}
|
||||
// TODO: Add explicit text label
|
||||
accessibilityLabel={_(msg`User handle`)}
|
||||
accessibilityHint={_(msg`Input your user handle`)}
|
||||
/>
|
||||
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
|
||||
<Trans>Your full handle will be</Trans>{' '}
|
||||
<Text type="lg-bold" style={pal.text}>
|
||||
@{createFullHandle(uiState.handle, uiState.userDomain)}
|
||||
</Text>
|
||||
</Text>
|
||||
<StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} />
|
||||
<View style={[styles.container, completed && styles.center]}>
|
||||
{!completed ? (
|
||||
<CaptchaWebView
|
||||
url={url}
|
||||
stateParam={stateParam}
|
||||
uiState={uiState}
|
||||
onSuccess={onSuccess}
|
||||
onError={onError}
|
||||
/>
|
||||
) : (
|
||||
<ActivityIndicator size="large" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{uiState.error ? (
|
||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
||||
) : undefined}
|
||||
@@ -58,5 +95,20 @@ export function Step3({
|
||||
const styles = StyleSheet.create({
|
||||
error: {
|
||||
borderRadius: 6,
|
||||
marginTop: 10,
|
||||
},
|
||||
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
|
||||
touchable: {
|
||||
...(isWeb && {cursor: 'pointer'}),
|
||||
},
|
||||
container: {
|
||||
minHeight: 500,
|
||||
width: '100%',
|
||||
paddingBottom: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
center: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ export function StepHeader({
|
||||
children,
|
||||
}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
|
||||
const pal = usePalette('default')
|
||||
const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2
|
||||
const numSteps = 3
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View>
|
||||
|
||||
+125
-177
@@ -1,20 +1,23 @@
|
||||
import {useReducer} from 'react'
|
||||
import {useCallback, useReducer} from 'react'
|
||||
import {
|
||||
ComAtprotoServerDescribeServer,
|
||||
ComAtprotoServerCreateAccount,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {I18nContext, useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import * as EmailValidator from 'email-validator'
|
||||
import {getAge} from 'lib/strings/time'
|
||||
import {logger} from '#/logger'
|
||||
import {createFullHandle} from '#/lib/strings/handles'
|
||||
import {createFullHandle, validateHandle} from '#/lib/strings/handles'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
|
||||
import {ApiContext as SessionApiContext} from '#/state/session'
|
||||
import {DEFAULT_SERVICE} from '#/lib/constants'
|
||||
import parsePhoneNumber, {CountryCode} from 'libphonenumber-js'
|
||||
import {useOnboardingDispatch} from '#/state/shell/onboarding'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants'
|
||||
import {
|
||||
DEFAULT_PROD_FEEDS,
|
||||
usePreferencesSetBirthDateMutation,
|
||||
useSetSaveFeedsMutation,
|
||||
} from 'state/queries/preferences'
|
||||
|
||||
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
|
||||
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
|
||||
@@ -29,10 +32,6 @@ export type CreateAccountAction =
|
||||
| {type: 'set-invite-code'; value: string}
|
||||
| {type: 'set-email'; value: string}
|
||||
| {type: 'set-password'; value: string}
|
||||
| {type: 'set-phone-country'; value: CountryCode}
|
||||
| {type: 'set-verification-phone'; value: string}
|
||||
| {type: 'set-verification-code'; value: string}
|
||||
| {type: 'set-has-requested-verification-code'; value: boolean}
|
||||
| {type: 'set-handle'; value: string}
|
||||
| {type: 'set-birth-date'; value: Date}
|
||||
| {type: 'next'}
|
||||
@@ -49,10 +48,6 @@ export interface CreateAccountState {
|
||||
inviteCode: string
|
||||
email: string
|
||||
password: string
|
||||
phoneCountry: CountryCode
|
||||
verificationPhone: string
|
||||
verificationCode: string
|
||||
hasRequestedVerificationCode: boolean
|
||||
handle: string
|
||||
birthDate: Date
|
||||
|
||||
@@ -60,13 +55,14 @@ export interface CreateAccountState {
|
||||
canBack: boolean
|
||||
canNext: boolean
|
||||
isInviteCodeRequired: boolean
|
||||
isPhoneVerificationRequired: boolean
|
||||
isCaptchaRequired: boolean
|
||||
}
|
||||
|
||||
export type CreateAccountDispatch = (action: CreateAccountAction) => void
|
||||
|
||||
export function useCreateAccount() {
|
||||
const {_} = useLingui()
|
||||
|
||||
return useReducer(createReducer({_}), {
|
||||
step: 1,
|
||||
error: undefined,
|
||||
@@ -77,144 +73,126 @@ export function useCreateAccount() {
|
||||
inviteCode: '',
|
||||
email: '',
|
||||
password: '',
|
||||
phoneCountry: 'US',
|
||||
verificationPhone: '',
|
||||
verificationCode: '',
|
||||
hasRequestedVerificationCode: false,
|
||||
handle: '',
|
||||
birthDate: DEFAULT_DATE,
|
||||
|
||||
canBack: false,
|
||||
canNext: false,
|
||||
isInviteCodeRequired: false,
|
||||
isPhoneVerificationRequired: false,
|
||||
isCaptchaRequired: false,
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestVerificationCode({
|
||||
uiState,
|
||||
uiDispatch,
|
||||
_,
|
||||
}: {
|
||||
uiState: CreateAccountState
|
||||
uiDispatch: CreateAccountDispatch
|
||||
_: I18nContext['_']
|
||||
}) {
|
||||
const phoneNumber = parsePhoneNumber(
|
||||
uiState.verificationPhone,
|
||||
uiState.phoneCountry,
|
||||
)?.number
|
||||
if (!phoneNumber) {
|
||||
return
|
||||
}
|
||||
uiDispatch({type: 'set-error', value: ''})
|
||||
uiDispatch({type: 'set-processing', value: true})
|
||||
uiDispatch({type: 'set-verification-phone', value: phoneNumber})
|
||||
try {
|
||||
const agent = new BskyAgent({service: uiState.serviceUrl})
|
||||
await agent.com.atproto.temp.requestPhoneVerification({
|
||||
phoneNumber,
|
||||
})
|
||||
uiDispatch({type: 'set-has-requested-verification-code', value: true})
|
||||
} catch (e: any) {
|
||||
logger.error(
|
||||
`Failed to request sms verification code (${e.status} status)`,
|
||||
{message: e},
|
||||
)
|
||||
uiDispatch({type: 'set-error', value: cleanError(e.toString())})
|
||||
}
|
||||
uiDispatch({type: 'set-processing', value: false})
|
||||
}
|
||||
export function useSubmitCreateAccount(
|
||||
uiState: CreateAccountState,
|
||||
uiDispatch: CreateAccountDispatch,
|
||||
) {
|
||||
const {_} = useLingui()
|
||||
const {createAccount} = useSessionApi()
|
||||
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
|
||||
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
|
||||
export async function submit({
|
||||
createAccount,
|
||||
onboardingDispatch,
|
||||
uiState,
|
||||
uiDispatch,
|
||||
_,
|
||||
}: {
|
||||
createAccount: SessionApiContext['createAccount']
|
||||
onboardingDispatch: OnboardingDispatchContext
|
||||
uiState: CreateAccountState
|
||||
uiDispatch: CreateAccountDispatch
|
||||
_: I18nContext['_']
|
||||
}) {
|
||||
if (!uiState.email) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please enter your email.`),
|
||||
})
|
||||
}
|
||||
if (!EmailValidator.validate(uiState.email)) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Your email appears to be invalid.`),
|
||||
})
|
||||
}
|
||||
if (!uiState.password) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please choose your password.`),
|
||||
})
|
||||
}
|
||||
if (
|
||||
uiState.isPhoneVerificationRequired &&
|
||||
(!uiState.verificationPhone || !uiState.verificationCode)
|
||||
) {
|
||||
uiDispatch({type: 'set-step', value: 2})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please enter the code you received by SMS.`),
|
||||
})
|
||||
}
|
||||
if (!uiState.handle) {
|
||||
uiDispatch({type: 'set-step', value: 3})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please choose your handle.`),
|
||||
})
|
||||
}
|
||||
uiDispatch({type: 'set-error', value: ''})
|
||||
uiDispatch({type: 'set-processing', value: true})
|
||||
return useCallback(
|
||||
async (verificationCode?: string) => {
|
||||
if (!uiState.email) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
console.log('no email?')
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please enter your email.`),
|
||||
})
|
||||
}
|
||||
if (!EmailValidator.validate(uiState.email)) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Your email appears to be invalid.`),
|
||||
})
|
||||
}
|
||||
if (!uiState.password) {
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please choose your password.`),
|
||||
})
|
||||
}
|
||||
if (!uiState.handle) {
|
||||
uiDispatch({type: 'set-step', value: 2})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please choose your handle.`),
|
||||
})
|
||||
}
|
||||
if (uiState.isCaptchaRequired && !verificationCode) {
|
||||
uiDispatch({type: 'set-step', value: 3})
|
||||
return uiDispatch({
|
||||
type: 'set-error',
|
||||
value: _(msg`Please complete the verification captcha.`),
|
||||
})
|
||||
}
|
||||
uiDispatch({type: 'set-error', value: ''})
|
||||
uiDispatch({type: 'set-processing', value: true})
|
||||
|
||||
try {
|
||||
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
|
||||
await createAccount({
|
||||
service: uiState.serviceUrl,
|
||||
email: uiState.email,
|
||||
handle: createFullHandle(uiState.handle, uiState.userDomain),
|
||||
password: uiState.password,
|
||||
inviteCode: uiState.inviteCode.trim(),
|
||||
verificationPhone: uiState.verificationPhone.trim(),
|
||||
verificationCode: uiState.verificationCode.trim(),
|
||||
})
|
||||
} catch (e: any) {
|
||||
onboardingDispatch({type: 'skip'}) // undo starting the onboard
|
||||
let errMsg = e.toString()
|
||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
|
||||
errMsg = _(
|
||||
msg`Invite code not accepted. Check that you input it correctly and try again.`,
|
||||
)
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
} else if (e.error === 'InvalidPhoneVerification') {
|
||||
uiDispatch({type: 'set-step', value: 2})
|
||||
}
|
||||
try {
|
||||
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
|
||||
await createAccount({
|
||||
service: uiState.serviceUrl,
|
||||
email: uiState.email,
|
||||
handle: createFullHandle(uiState.handle, uiState.userDomain),
|
||||
password: uiState.password,
|
||||
inviteCode: uiState.inviteCode.trim(),
|
||||
verificationCode: uiState.isCaptchaRequired
|
||||
? verificationCode
|
||||
: undefined,
|
||||
})
|
||||
setBirthDate({birthDate: uiState.birthDate})
|
||||
if (IS_PROD_SERVICE(uiState.serviceUrl)) {
|
||||
setSavedFeeds(DEFAULT_PROD_FEEDS)
|
||||
}
|
||||
} catch (e: any) {
|
||||
onboardingDispatch({type: 'skip'}) // undo starting the onboard
|
||||
let errMsg = e.toString()
|
||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
|
||||
errMsg = _(
|
||||
msg`Invite code not accepted. Check that you input it correctly and try again.`,
|
||||
)
|
||||
uiDispatch({type: 'set-step', value: 1})
|
||||
}
|
||||
|
||||
if ([400, 429].includes(e.status)) {
|
||||
logger.warn('Failed to create account', {message: e})
|
||||
} else {
|
||||
logger.error(`Failed to create account (${e.status} status)`, {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
if ([400, 429].includes(e.status)) {
|
||||
logger.warn('Failed to create account', {message: e})
|
||||
} else {
|
||||
logger.error(`Failed to create account (${e.status} status)`, {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
|
||||
uiDispatch({type: 'set-processing', value: false})
|
||||
uiDispatch({type: 'set-error', value: cleanError(errMsg)})
|
||||
throw e
|
||||
}
|
||||
const error = cleanError(errMsg)
|
||||
const isHandleError = error.toLowerCase().includes('handle')
|
||||
|
||||
uiDispatch({type: 'set-processing', value: false})
|
||||
uiDispatch({type: 'set-error', value: cleanError(errMsg)})
|
||||
uiDispatch({type: 'set-step', value: isHandleError ? 2 : 1})
|
||||
}
|
||||
},
|
||||
[
|
||||
uiState.email,
|
||||
uiState.password,
|
||||
uiState.handle,
|
||||
uiState.isCaptchaRequired,
|
||||
uiState.serviceUrl,
|
||||
uiState.userDomain,
|
||||
uiState.inviteCode,
|
||||
uiState.birthDate,
|
||||
uiDispatch,
|
||||
_,
|
||||
onboardingDispatch,
|
||||
createAccount,
|
||||
setBirthDate,
|
||||
setSavedFeeds,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export function is13(state: CreateAccountState) {
|
||||
@@ -269,22 +247,6 @@ function createReducer({_}: {_: I18nContext['_']}) {
|
||||
case 'set-password': {
|
||||
return compute({...state, password: action.value})
|
||||
}
|
||||
case 'set-phone-country': {
|
||||
return compute({...state, phoneCountry: action.value})
|
||||
}
|
||||
case 'set-verification-phone': {
|
||||
return compute({
|
||||
...state,
|
||||
verificationPhone: action.value,
|
||||
hasRequestedVerificationCode: false,
|
||||
})
|
||||
}
|
||||
case 'set-verification-code': {
|
||||
return compute({...state, verificationCode: action.value.trim()})
|
||||
}
|
||||
case 'set-has-requested-verification-code': {
|
||||
return compute({...state, hasRequestedVerificationCode: action.value})
|
||||
}
|
||||
case 'set-handle': {
|
||||
return compute({...state, handle: action.value})
|
||||
}
|
||||
@@ -302,18 +264,10 @@ function createReducer({_}: {_: I18nContext['_']}) {
|
||||
})
|
||||
}
|
||||
}
|
||||
let increment = 1
|
||||
if (state.step === 1 && !state.isPhoneVerificationRequired) {
|
||||
increment = 2
|
||||
}
|
||||
return compute({...state, error: '', step: state.step + increment})
|
||||
return compute({...state, error: '', step: state.step + 1})
|
||||
}
|
||||
case 'back': {
|
||||
let decrement = 1
|
||||
if (state.step === 3 && !state.isPhoneVerificationRequired) {
|
||||
decrement = 2
|
||||
}
|
||||
return compute({...state, error: '', step: state.step - decrement})
|
||||
return compute({...state, error: '', step: state.step - 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,22 +283,16 @@ function compute(state: CreateAccountState): CreateAccountState {
|
||||
!!state.password
|
||||
} else if (state.step === 2) {
|
||||
canNext =
|
||||
!state.isPhoneVerificationRequired ||
|
||||
(!!state.verificationPhone &&
|
||||
isValidVerificationCode(state.verificationCode))
|
||||
!!state.handle && validateHandle(state.handle, state.userDomain).overall
|
||||
} else if (state.step === 3) {
|
||||
canNext = !!state.handle
|
||||
// Step 3 will automatically redirect as soon as the captcha completes
|
||||
canNext = false
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
canBack: state.step > 1,
|
||||
canNext,
|
||||
isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
|
||||
isPhoneVerificationRequired:
|
||||
!!state.serviceDescription?.phoneVerificationRequired,
|
||||
isCaptchaRequired: !!state.serviceDescription?.phoneVerificationRequired,
|
||||
}
|
||||
}
|
||||
|
||||
function isValidVerificationCode(str: string): boolean {
|
||||
return /[0-9]{6}/.test(str)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 {BSKY_SERVICE} from 'lib/constants'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
@@ -26,7 +26,7 @@ export function ServerInputDialog({
|
||||
const [pdsAddressHistory, setPdsAddressHistory] = React.useState<string[]>(
|
||||
persisted.get('pdsAddressHistory') || [],
|
||||
)
|
||||
const [fixedOption, setFixedOption] = React.useState([PROD_SERVICE])
|
||||
const [fixedOption, setFixedOption] = React.useState([BSKY_SERVICE])
|
||||
const [customAddress, setCustomAddress] = React.useState('')
|
||||
|
||||
const onClose = React.useCallback(() => {
|
||||
@@ -86,7 +86,7 @@ export function ServerInputDialog({
|
||||
label="Preferences"
|
||||
values={fixedOption}
|
||||
onChange={setFixedOption}>
|
||||
<ToggleButton.Button name={PROD_SERVICE} label={_(msg`Bluesky`)}>
|
||||
<ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}>
|
||||
{_(msg`Bluesky`)}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button
|
||||
|
||||
@@ -46,7 +46,7 @@ export function FeedPage({
|
||||
renderEmptyState: () => JSX.Element
|
||||
renderEndOfFeed?: () => JSX.Element
|
||||
}) {
|
||||
const {isSandbox, hasSession} = useSession()
|
||||
const {hasSession} = useSession()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation()
|
||||
@@ -119,7 +119,7 @@ export function FeedPage({
|
||||
style={[pal.text, {fontWeight: 'bold'}]}
|
||||
text={
|
||||
<>
|
||||
{isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
|
||||
Bluesky{' '}
|
||||
{hasNew && (
|
||||
<View
|
||||
style={{
|
||||
@@ -138,7 +138,7 @@ export function FeedPage({
|
||||
{hasSession && (
|
||||
<TextLink
|
||||
type="title-lg"
|
||||
href="/settings/home-feed"
|
||||
href="/settings/following-feed"
|
||||
style={{fontWeight: 'bold'}}
|
||||
accessibilityLabel={_(msg`Feed Preferences`)}
|
||||
accessibilityHint=""
|
||||
@@ -154,16 +154,7 @@ export function FeedPage({
|
||||
)
|
||||
}
|
||||
return <></>
|
||||
}, [
|
||||
isDesktop,
|
||||
pal.view,
|
||||
pal.text,
|
||||
pal.textLight,
|
||||
hasNew,
|
||||
_,
|
||||
isSandbox,
|
||||
hasSession,
|
||||
])
|
||||
}, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, hasSession])
|
||||
|
||||
return (
|
||||
<View testID={testID} style={s.h100pct}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user