Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc639f0cdc | |||
| c190fd58ec | |||
| b783745b2e | |||
| b26abf212e | |||
| 8b3017bca0 | |||
| 51e939ed3d | |||
| 9f657fbace | |||
| 4d28dcc48f | |||
| 3e88fdfbc4 | |||
| 19b1721ef3 | |||
| 4cc57f4bfd | |||
| 8e393b16f5 | |||
| b1bd7ab6e3 | |||
| 9d9c9095ee | |||
| 712768dd8f | |||
| 8cdd8394df | |||
| e51ccb46b8 | |||
| db3cd3e821 | |||
| af9b961411 | |||
| 7cf22e2047 | |||
| 7fb117d213 | |||
| a356b1be1a | |||
| cad0590694 | |||
| 93fbd91193 | |||
| 9d5c82d9a8 | |||
| 55e4ea84f8 | |||
| a32eb96210 | |||
| a4d4562aba | |||
| eda98679df | |||
| 8e882a4579 | |||
| 310e5ea35b |
@@ -1,3 +1,5 @@
|
||||
const bskyEslint = require('./eslint')
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
@@ -13,12 +15,43 @@ module.exports = {
|
||||
'react',
|
||||
'lingui',
|
||||
'simple-import-sort',
|
||||
'bsky-internal',
|
||||
],
|
||||
rules: {
|
||||
// Temporary until https://github.com/facebook/react-native/pull/43756 gets into a release.
|
||||
'prettier/prettier': 0,
|
||||
'react/no-unescaped-entities': 0,
|
||||
'react-native/no-inline-styles': 0,
|
||||
'bsky-internal/avoid-unwrapped-text': [
|
||||
'error',
|
||||
{
|
||||
impliedTextComponents: [
|
||||
'Button', // TODO: Not always safe.
|
||||
'ButtonText',
|
||||
'DateField.Label',
|
||||
'Description',
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'InlineLink',
|
||||
'Label',
|
||||
'P',
|
||||
'Prompt.Title',
|
||||
'Prompt.Description',
|
||||
'Prompt.Cancel', // TODO: Not always safe.
|
||||
'Prompt.Action', // TODO: Not always safe.
|
||||
'TextField.Label',
|
||||
'TextField.Suffix',
|
||||
'Title',
|
||||
'Toggle.Label',
|
||||
'ToggleButton.Button', // TODO: Not always safe.
|
||||
],
|
||||
impliedTextProps: ['FormContainer title'],
|
||||
},
|
||||
],
|
||||
'simple-import-sort/imports': [
|
||||
'warn',
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
type: choice
|
||||
description: Build profile to use
|
||||
options:
|
||||
- testflight-android
|
||||
- production
|
||||
|
||||
jobs:
|
||||
@@ -59,7 +60,41 @@ jobs:
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: yarn use-build-number-with-bump eas build -p android --profile production --local --output build.aab --non-interactive
|
||||
run: yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
|
||||
|
||||
- name: 🚀 Deploy
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
run: eas submit -p android --non-interactive --path build.aab
|
||||
|
||||
- name: ✍️ Rename bundle
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
run: mv build.aab build.apk
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: nanzm/get-time-action@master
|
||||
with:
|
||||
format: 'MM-DD-HH-mm-ss'
|
||||
|
||||
- name: 🚀 Upload Artifact
|
||||
id: upload-artifact
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
name: Bundle and Deploy EAS Update
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
@@ -20,7 +23,8 @@ jobs:
|
||||
name: Bundle and Deploy EAS Update
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
fingerprint-diff: ${{ steps.fingerprint.outputs.fingerprint-diff }}
|
||||
fingerprint-is-different: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different }}
|
||||
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -43,6 +47,7 @@ jobs:
|
||||
fetch-depth: 100
|
||||
|
||||
- name: ⬇️ Fetch commits from base branch
|
||||
if: ${{ github.ref != 'refs/heads/main' }}
|
||||
run: git fetch origin main:main --depth 100
|
||||
|
||||
# This should get the current production release's commit's hash to see if the update is compatible
|
||||
@@ -77,51 +82,61 @@ jobs:
|
||||
uses: expo/expo-github-action/fingerprint@main
|
||||
with:
|
||||
previous-git-commit: ${{ steps.base-commit.outputs.base-commit }}
|
||||
args:
|
||||
|
||||
- name: 👀 Debug fingerprint
|
||||
id: fingerprint-debug
|
||||
run: |
|
||||
echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}"
|
||||
echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}"
|
||||
|
||||
fingerprintDiff="${{ steps.fingerprint.outputs.fingerprint-diff }}"
|
||||
if [[ $fingerprintDiff =~ "bareRncliAutolinking" || $fingerprintDiff =~ "expoAutolinkingAndroid" || $fingerprintDiff =~ "expoAutolinkingIos" ]]; then
|
||||
echo fingerprint-is-different="true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo fingerprint-is-different="false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }}
|
||||
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⛏️ Setup Expo
|
||||
if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }}
|
||||
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
run: yarn global add eas-cli-local-build-plugin
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }}
|
||||
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
|
||||
- name: 🔤 Compile Translations
|
||||
if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }}
|
||||
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
run: yarn intl:build
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }}
|
||||
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ Create Bundle
|
||||
if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }}
|
||||
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
run: EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
|
||||
|
||||
- name: 📦 Package Bundle and 🚀 Deploy
|
||||
if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }}
|
||||
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
run: yarn use-build-number bash scripts/bundleUpdate.sh
|
||||
env:
|
||||
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
|
||||
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
||||
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
|
||||
|
||||
|
||||
# GitHub actions are horrible so let's just copy paste this in
|
||||
buildIfNecessaryIOS:
|
||||
name: Build and Submit iOS
|
||||
@@ -129,7 +144,7 @@ jobs:
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
# available here
|
||||
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-diff != '[]' }}
|
||||
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-is-different == 'true' }}
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -194,7 +209,7 @@ jobs:
|
||||
needs: [ bundleDeploy ]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
# available here
|
||||
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-diff != '[]' }}
|
||||
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-is-different == 'true' }}
|
||||
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Credit https://github.com/expo/expo
|
||||
# Credit for fingerprint action https://github.com/expo/expo
|
||||
# https://github.com/expo/expo/blob/main/.github/workflows/pr-labeler.yml
|
||||
---
|
||||
name: PR labeler
|
||||
@@ -14,9 +14,123 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
webpack-analyzer:
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
|
||||
- name: ⚙️ Install Dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Ensure tracking relevant branches and checkout base
|
||||
run: |
|
||||
git checkout ${{ github.head_ref }}
|
||||
git checkout ${{ github.base_ref }}
|
||||
|
||||
- name: Get the base commit
|
||||
id: base-commit
|
||||
run: echo base-commit=$(git log -n 1 ${{ github.base_ref }} --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Merge PR commit
|
||||
run: |
|
||||
# Have to set a git config for the merge to work
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git merge --no-edit ${{ github.head_ref }}
|
||||
|
||||
- name: 🔦 Generate stats file for PR
|
||||
run: |
|
||||
yarn generate-webpack-stats-file
|
||||
mv stats.json ../stats-new.json
|
||||
|
||||
- name: ⬇️ Get base stats from cache
|
||||
id: get-base-stats
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: stats-base.json
|
||||
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
|
||||
|
||||
- name: Restore to base commit
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
run: |
|
||||
git reset HEAD~
|
||||
git restore .
|
||||
|
||||
- name: 🔦 Generate stats file from base commit
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
run: |
|
||||
yarn generate-webpack-stats-file
|
||||
mv stats.json stats-base.json
|
||||
|
||||
- name: % Get diff
|
||||
id: get-diff
|
||||
uses: NejcZdovc/bundle-size-diff@v1
|
||||
with:
|
||||
base_path: 'stats-base.json'
|
||||
pr_path: '../stats-new.json'
|
||||
excluded_assets: '(.+).chunk.js|(.+).js.map|(.+).json|(.+).png'
|
||||
|
||||
- name: 🔍 Find old comment if it exists
|
||||
uses: peter-evans/find-comment@v2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
id: old_comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: <!-- webpack-analyzer comment -->
|
||||
|
||||
- name: 💬 Add comment with diff
|
||||
uses: actions/github-script@v6
|
||||
if: ${{ steps.old_comment.outputs.comment-id == '' }}
|
||||
with:
|
||||
script: |
|
||||
const body = `<!-- webpack-analyzer comment -->
|
||||
| Old size | New size | Diff |
|
||||
|----------|----------|-----------------------|
|
||||
| ${{ steps.get-diff.outputs.base_file_string }} | ${{ steps.get-diff.outputs.pr_file_string }} | ${{ steps.get-diff.outputs.diff_file_string }} (${{ steps.get-diff.outputs.percent }}%) |
|
||||
`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: body,
|
||||
});
|
||||
|
||||
- name: 💬 Update comment with fingerprint
|
||||
if: ${{ steps.old_comment.outputs.comment-id != '' }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const body = `<!-- webpack-analyzer comment -->
|
||||
| Old size | New size | Diff |
|
||||
|----------|----------|-----------------------|
|
||||
| ${{ steps.get-diff.outputs.base_file_string }} | ${{ steps.get-diff.outputs.pr_file_string }} | ${{ steps.get-diff.outputs.diff_file_string }} (${{ steps.get-diff.outputs.percent }}%) |
|
||||
`;
|
||||
|
||||
github.rest.issues.updateComment({
|
||||
issue_number: context.issue.number,
|
||||
comment_id: '${{ steps.old_comment.outputs.comment-id }}',
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: body,
|
||||
});
|
||||
|
||||
test-suite-fingerprint:
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' }}
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
|
||||
# REQUIRED: limit concurrency when pushing main(default) branch to prevent conflict for this action to update its fingerprint database
|
||||
concurrency: fingerprint-${{ github.event_name != 'pull_request' && 'main' || github.run_id }}
|
||||
permissions:
|
||||
@@ -45,9 +159,7 @@ jobs:
|
||||
|
||||
- name: Get the base commit
|
||||
id: base-commit
|
||||
run: |
|
||||
# Since we limit this pr-labeler workflow only triggered from limited paths, we should use custom base commit
|
||||
echo base-commit=$(git log -n 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
run: echo base-commit=$(git log -n 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 📷 Check fingerprint
|
||||
id: fingerprint
|
||||
|
||||
@@ -77,6 +77,7 @@ dist/
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
stats.json
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
const {RuleTester} = require('eslint')
|
||||
const avoidUnwrappedText = require('../avoid-unwrapped-text')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 6,
|
||||
sourceType: 'module',
|
||||
},
|
||||
})
|
||||
|
||||
describe('avoid-unwrapped-text', () => {
|
||||
const tests = {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
foo
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
<Trans>
|
||||
foo
|
||||
</Trans>
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
<>
|
||||
foo
|
||||
</>
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
{foo && <Trans>foo</Trans>}
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
{foo ? <Trans>foo</Trans> : <Trans>bar</Trans>}
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Trans>
|
||||
<Text>
|
||||
foo
|
||||
</Text>
|
||||
</Trans>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Trans>
|
||||
{foo && <Text>foo</Text>}
|
||||
</Trans>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Trans>
|
||||
{foo ? <Text>foo</Text> : <Text>bar</Text>}
|
||||
</Trans>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<CustomText>
|
||||
foo
|
||||
</CustomText>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<CustomText>
|
||||
<Trans>
|
||||
foo
|
||||
</Trans>
|
||||
</CustomText>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
{bar}
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{bar}
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
foo {bar}
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text>
|
||||
foo
|
||||
</Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text>
|
||||
{bar}
|
||||
</Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text>
|
||||
foo {bar}
|
||||
</Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<CustomText>
|
||||
foo
|
||||
</CustomText>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
<Text>foo</Text>
|
||||
}>
|
||||
<Bar />
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
foo && <Text>foo</Text>
|
||||
}>
|
||||
<Bar />
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
foo ? <Text>foo</Text> : <Text>bar</Text>
|
||||
}>
|
||||
<Bar />
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
<Trans><Text>foo</Text></Trans>
|
||||
}>
|
||||
<Bar />
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
<Text><Trans>foo</Trans></Text>
|
||||
}>
|
||||
<Bar />
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Foo propText={
|
||||
<Trans>foo</Trans>
|
||||
}>
|
||||
<Bar />
|
||||
</Foo>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Foo propText={
|
||||
foo && <Trans>foo</Trans>
|
||||
}>
|
||||
<Bar />
|
||||
</Foo>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Foo propText={
|
||||
foo ? <Trans>foo</Trans> : <Trans>bar</Trans>
|
||||
}>
|
||||
<Bar />
|
||||
</Foo>
|
||||
`,
|
||||
},
|
||||
],
|
||||
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
<View> </View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
foo
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<>
|
||||
foo
|
||||
</>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>
|
||||
foo
|
||||
</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{foo && <Trans>foo</Trans>}
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{foo ? <Trans>foo</Trans> : <Trans>bar</Trans>}
|
||||
</View>
|
||||
`,
|
||||
errors: 2,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Trans>
|
||||
<View>
|
||||
foo
|
||||
</View>
|
||||
</Trans>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
foo {bar}
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<View>
|
||||
foo
|
||||
</View>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
<View>
|
||||
foo
|
||||
</View>
|
||||
</Text>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text prop={
|
||||
<View>foo</View>
|
||||
}>
|
||||
<Bar />
|
||||
</Text>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text prop={
|
||||
foo && <View>foo</View>
|
||||
}>
|
||||
<Bar />
|
||||
</Text>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text prop={
|
||||
foo ? <View>foo</View> : <View>bar</View>
|
||||
}>
|
||||
<Bar />
|
||||
</Text>
|
||||
`,
|
||||
errors: 2,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Foo prop={
|
||||
<Trans>foo</Trans>
|
||||
}>
|
||||
<Bar />
|
||||
</Foo>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// For easier local testing
|
||||
if (!process.env.CI) {
|
||||
let only = []
|
||||
let skipped = []
|
||||
;[...tests.valid, ...tests.invalid].forEach(t => {
|
||||
if (t.skip) {
|
||||
delete t.skip
|
||||
skipped.push(t)
|
||||
}
|
||||
if (t.only) {
|
||||
delete t.only
|
||||
only.push(t)
|
||||
}
|
||||
})
|
||||
const predicate = t => {
|
||||
if (only.length > 0) {
|
||||
return only.indexOf(t) !== -1
|
||||
}
|
||||
if (skipped.length > 0) {
|
||||
return skipped.indexOf(t) === -1
|
||||
}
|
||||
return true
|
||||
}
|
||||
tests.valid = tests.valid.filter(predicate)
|
||||
tests.invalid = tests.invalid.filter(predicate)
|
||||
}
|
||||
ruleTester.run('avoid-unwrapped-text', avoidUnwrappedText, tests)
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
'use strict'
|
||||
|
||||
// Partially based on eslint-plugin-react-native.
|
||||
// Portions of code by Alex Zhukov, MIT license.
|
||||
|
||||
function hasOnlyLineBreak(value) {
|
||||
return /^[\r\n\t\f\v]+$/.test(value.replace(/ /g, ''))
|
||||
}
|
||||
|
||||
function getTagName(node) {
|
||||
const reversedIdentifiers = []
|
||||
if (
|
||||
node.type === 'JSXElement' &&
|
||||
node.openingElement.type === 'JSXOpeningElement'
|
||||
) {
|
||||
let object = node.openingElement.name
|
||||
while (object.type === 'JSXMemberExpression') {
|
||||
if (object.property.type === 'JSXIdentifier') {
|
||||
reversedIdentifiers.push(object.property.name)
|
||||
}
|
||||
object = object.object
|
||||
}
|
||||
|
||||
if (object.type === 'JSXIdentifier') {
|
||||
reversedIdentifiers.push(object.name)
|
||||
}
|
||||
}
|
||||
|
||||
return reversedIdentifiers.reverse().join('.')
|
||||
}
|
||||
|
||||
exports.create = function create(context) {
|
||||
const options = context.options[0] || {}
|
||||
const impliedTextProps = options.impliedTextProps ?? []
|
||||
const impliedTextComponents = options.impliedTextComponents ?? []
|
||||
const textProps = [...impliedTextProps]
|
||||
const textComponents = ['Text', ...impliedTextComponents]
|
||||
return {
|
||||
JSXText(node) {
|
||||
if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (textComponents.includes(tagName) || tagName.endsWith('Text')) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Skip over it and check above.
|
||||
// TODO: Maybe validate that it's present.
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
let message = 'Wrap this string in <Text>.'
|
||||
if (tagName !== 'View') {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXAttribute' &&
|
||||
parent.name.type === 'JSXIdentifier' &&
|
||||
parent.parent.type === 'JSXOpeningElement' &&
|
||||
parent.parent.parent.type === 'JSXElement'
|
||||
) {
|
||||
const tagName = getTagName(parent.parent.parent)
|
||||
const propName = parent.name.name
|
||||
if (
|
||||
textProps.includes(tagName + ' ' + propName) ||
|
||||
propName === 'text' ||
|
||||
propName.endsWith('Text')
|
||||
) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
const message =
|
||||
'Wrap this string in <Text>.' +
|
||||
' If `' +
|
||||
propName +
|
||||
'` is guaranteed to be wrapped in <Text>, ' +
|
||||
'rename it to `' +
|
||||
propName +
|
||||
'Text' +
|
||||
'` or add it to impliedTextProps.'
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
||||
},
|
||||
}
|
||||
+4
-2
@@ -2,20 +2,22 @@
|
||||
module.exports = {
|
||||
locales: [
|
||||
'en',
|
||||
'ca',
|
||||
'de',
|
||||
'es',
|
||||
'fi',
|
||||
'fr',
|
||||
'ga',
|
||||
'hi',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'pt-BR',
|
||||
'tr',
|
||||
'uk',
|
||||
'ca',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'it',
|
||||
],
|
||||
catalogs: [
|
||||
{
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# React Native UITextView
|
||||
|
||||
Drop in replacement for `<Text>` that renders a `UITextView`, support selection and native translation features on iOS.
|
||||
|
||||
## Installation
|
||||
|
||||
In this project, no installation is required. The pod will be installed automatically during a `pod install`.
|
||||
|
||||
In another project, clone the repo and copy the `modules/react-native-ui-text-view` directory to your own project
|
||||
directory. Afterward, run `pod install`.
|
||||
|
||||
## Usage
|
||||
|
||||
Replace the outermost `<Text>` with `<UITextView>`. Styles and press events should be handled the same way they would
|
||||
with `<Text>`. Both `<UITextView>` and `<Text>` are supported as children of the root `<UITextView>`.
|
||||
|
||||
## Technical
|
||||
|
||||
React Native's `Text` component allows for "infinite" nesting of further `Text` components. To make a true "drop-in",
|
||||
we want to do the same thing.
|
||||
|
||||
To achieve this, we first need to handle determining if we are dealing with an ancestor or root `UITextView` component.
|
||||
We can implement similar logic to the `Text` component [see Text.js](https://github.com/facebook/react-native/blob/7f2529de7bc9ab1617eaf571e950d0717c3102a6/packages/react-native/Libraries/Text/Text.js).
|
||||
|
||||
We create a context that contains a boolean to tell us if we have already rendered the root `UITextView`. We also store
|
||||
the root styles so that we can apply those styles if the ancestor `UITextView`s have not overwritten those styles.
|
||||
|
||||
All of our children are placed into `RNUITextView`, which is the main native view that will display the iOS `UITextView`.
|
||||
|
||||
We next map each child into the view. We have to be careful here to check if the child's `children` prop is a string. If
|
||||
it is, that means we have encountered what was once an RN `Text` component. RN doesn't let us pass plain text as
|
||||
children outside of `Text`, so we instead just pass the text into the `text` prop on `RNUITextViewChild`. We continue
|
||||
down the tree, until we run out of children.
|
||||
|
||||
On the native side, we make use of the shadow view to calculate text container dimensions before the views are mounted.
|
||||
We cannot simply set the `UITextView` text first, since React will not have properly measured the layout before this
|
||||
occurs.
|
||||
|
||||
|
||||
As for `Text` props, the following props are implemented:
|
||||
|
||||
- All accessibility props
|
||||
- `allowFontScaling`
|
||||
- `adjustsFontSizeToFit`
|
||||
- `ellipsizeMode`
|
||||
- `numberOfLines`
|
||||
- `onLayout`
|
||||
- `onPress`
|
||||
- `onTextLayout`
|
||||
- `selectable`
|
||||
|
||||
All `ViewStyle` props will apply to the root `UITextView`. Individual children will respect these `TextStyle` styles:
|
||||
|
||||
- `color`
|
||||
- `fontSize`
|
||||
- `fontStyle`
|
||||
- `fontWeight`
|
||||
- `fontVariant`
|
||||
- `letterSpacing`
|
||||
- `lineHeight`
|
||||
- `textDecorationLine`
|
||||
@@ -1,3 +0,0 @@
|
||||
#import <React/RCTViewManager.h>
|
||||
#import <React/RCTBridge.h>
|
||||
#import <React/RCTBridge+Private.h>
|
||||
@@ -1,153 +0,0 @@
|
||||
class RNUITextView: UIView {
|
||||
var textView: UITextView
|
||||
|
||||
@objc var numberOfLines: Int = 0 {
|
||||
didSet {
|
||||
textView.textContainer.maximumNumberOfLines = numberOfLines
|
||||
}
|
||||
}
|
||||
@objc var selectable: Bool = true {
|
||||
didSet {
|
||||
textView.isSelectable = selectable
|
||||
}
|
||||
}
|
||||
@objc var ellipsizeMode: String = "tail" {
|
||||
didSet {
|
||||
textView.textContainer.lineBreakMode = self.getLineBreakMode()
|
||||
}
|
||||
}
|
||||
@objc var onTextLayout: RCTDirectEventBlock?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
if #available(iOS 16.0, *) {
|
||||
textView = UITextView(usingTextLayoutManager: false)
|
||||
} else {
|
||||
textView = UITextView()
|
||||
}
|
||||
|
||||
// Disable scrolling
|
||||
textView.isScrollEnabled = false
|
||||
// Remove all the padding
|
||||
textView.textContainerInset = .zero
|
||||
textView.textContainer.lineFragmentPadding = 0
|
||||
|
||||
// Remove other properties
|
||||
textView.isEditable = false
|
||||
textView.backgroundColor = .clear
|
||||
|
||||
// Init
|
||||
super.init(frame: frame)
|
||||
self.clipsToBounds = true
|
||||
|
||||
// Add the view
|
||||
addSubview(textView)
|
||||
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
||||
tapGestureRecognizer.isEnabled = true
|
||||
textView.addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// Resolves some animation issues
|
||||
override func reactSetFrame(_ frame: CGRect) {
|
||||
UIView.performWithoutAnimation {
|
||||
super.reactSetFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
func setText(string: NSAttributedString, size: CGSize, numberOfLines: Int) -> Void {
|
||||
self.textView.frame.size = size
|
||||
self.textView.textContainer.maximumNumberOfLines = numberOfLines
|
||||
self.textView.attributedText = string
|
||||
self.textView.selectedTextRange = nil
|
||||
|
||||
if let onTextLayout = self.onTextLayout {
|
||||
var lines: [String] = []
|
||||
textView.layoutManager.enumerateLineFragments(
|
||||
forGlyphRange: NSRange(location: 0, length: textView.attributedText.length))
|
||||
{ (rect, usedRect, textContainer, glyphRange, stop) in
|
||||
let characterRange = self.textView.layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
|
||||
let line = (self.textView.text as NSString).substring(with: characterRange)
|
||||
lines.append(line)
|
||||
}
|
||||
|
||||
onTextLayout([
|
||||
"lines": lines
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
|
||||
// If we find a child, then call onPress
|
||||
if let child = getPressed(sender) {
|
||||
if textView.selectedTextRange == nil, let onPress = child.onPress {
|
||||
onPress(["": ""])
|
||||
} else {
|
||||
// Clear the selected text range if we are not pressing on a link
|
||||
textView.selectedTextRange = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get the pressed segment
|
||||
func getPressed(_ sender: UITapGestureRecognizer) -> RNUITextViewChild? {
|
||||
let layoutManager = textView.layoutManager
|
||||
var location = sender.location(in: textView)
|
||||
|
||||
// Remove the padding
|
||||
location.x -= textView.textContainerInset.left
|
||||
location.y -= textView.textContainerInset.top
|
||||
|
||||
// Get the index of the char
|
||||
let charIndex = layoutManager.characterIndex(
|
||||
for: location,
|
||||
in: textView.textContainer,
|
||||
fractionOfDistanceBetweenInsertionPoints: nil
|
||||
)
|
||||
|
||||
var lastUpperBound: String.Index? = nil
|
||||
for child in self.reactSubviews() {
|
||||
if let child = child as? RNUITextViewChild, let childText = child.text {
|
||||
let fullText = self.textView.attributedText.string
|
||||
|
||||
// We want to skip over the children we have already checked, otherwise we could run into
|
||||
// collisions of similar strings (i.e. links that get shortened to the same hostname but
|
||||
// different paths)
|
||||
let range = fullText.range(of: childText, options: [], range: (lastUpperBound ?? String.Index(utf16Offset: 0, in: fullText) )..<fullText.endIndex)
|
||||
|
||||
if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound {
|
||||
let lowerOffset = lowerBound.utf16Offset(in: fullText)
|
||||
let upperOffset = upperBound.utf16Offset(in: fullText)
|
||||
|
||||
if charIndex >= lowerOffset,
|
||||
charIndex <= upperOffset
|
||||
{
|
||||
return child
|
||||
} else {
|
||||
lastUpperBound = upperBound
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLineBreakMode() -> NSLineBreakMode {
|
||||
switch self.ellipsizeMode {
|
||||
case "head":
|
||||
return .byTruncatingHead
|
||||
case "middle":
|
||||
return .byTruncatingMiddle
|
||||
case "tail":
|
||||
return .byTruncatingTail
|
||||
case "clip":
|
||||
return .byClipping
|
||||
default:
|
||||
return .byTruncatingTail
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
class RNUITextViewChild: UIView {
|
||||
@objc var text: String?
|
||||
@objc var onPress: RCTDirectEventBlock?
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// We want all of our props to be available in the child's shadow view so we
|
||||
// can create the attributed text before mount and calculate the needed size
|
||||
// for the view.
|
||||
class RNUITextViewChildShadow: RCTShadowView {
|
||||
@objc var text: String = ""
|
||||
@objc var color: UIColor = .black
|
||||
@objc var fontSize: CGFloat = 16.0
|
||||
@objc var fontStyle: String = "normal"
|
||||
@objc var fontWeight: String = "normal"
|
||||
@objc var letterSpacing: CGFloat = 0.0
|
||||
@objc var lineHeight: CGFloat = 0.0
|
||||
@objc var pointerEvents: NSString?
|
||||
|
||||
override func isYogaLeafNode() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func didSetProps(_ changedProps: [String]!) {
|
||||
guard let superview = self.superview as? RNUITextViewShadow else {
|
||||
return
|
||||
}
|
||||
|
||||
if !YGNodeIsDirty(superview.yogaNode) {
|
||||
superview.setAttributedText()
|
||||
}
|
||||
}
|
||||
|
||||
func getFontWeight() -> UIFont.Weight {
|
||||
switch self.fontWeight {
|
||||
case "bold":
|
||||
return .bold
|
||||
case "normal":
|
||||
return .regular
|
||||
case "100":
|
||||
return .ultraLight
|
||||
case "200":
|
||||
return .ultraLight
|
||||
case "300":
|
||||
return .light
|
||||
case "400":
|
||||
return .regular
|
||||
case "500":
|
||||
return .medium
|
||||
case "600":
|
||||
return .semibold
|
||||
case "700":
|
||||
return .semibold
|
||||
case "800":
|
||||
return .bold
|
||||
case "900":
|
||||
return .heavy
|
||||
default:
|
||||
return .regular
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
#import <React/RCTViewManager.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE(RNUITextViewManager, RCTViewManager)
|
||||
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)
|
||||
|
||||
@end
|
||||
|
||||
@interface RCT_EXTERN_MODULE(RNUITextViewChildManager, RCTViewManager)
|
||||
RCT_REMAP_SHADOW_PROPERTY(text, text, NSString)
|
||||
RCT_REMAP_SHADOW_PROPERTY(color, color, UIColor)
|
||||
RCT_REMAP_SHADOW_PROPERTY(fontSize, fontSize, CGFloat)
|
||||
RCT_REMAP_SHADOW_PROPERTY(fontStyle, fontStyle, NSString)
|
||||
RCT_REMAP_SHADOW_PROPERTY(fontWeight, fontWeight, NSString)
|
||||
RCT_REMAP_SHADOW_PROPERTY(letterSpacing, letterSpacing, CGFloat)
|
||||
RCT_REMAP_SHADOW_PROPERTY(lineHeight, lineHeight, CGFloat)
|
||||
RCT_REMAP_SHADOW_PROPERTY(pointerEvents, pointerEvents, NSString)
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(text, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onPress, RCTBubblingEventBlock)
|
||||
@end
|
||||
@@ -1,30 +0,0 @@
|
||||
@objc(RNUITextViewManager)
|
||||
class RNUITextViewManager: RCTViewManager {
|
||||
override func view() -> (RNUITextView) {
|
||||
return RNUITextView()
|
||||
}
|
||||
|
||||
@objc override static func requiresMainQueueSetup() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func shadowView() -> RCTShadowView {
|
||||
// Pass the bridge to the shadow view
|
||||
return RNUITextViewShadow(bridge: self.bridge)
|
||||
}
|
||||
}
|
||||
|
||||
@objc(RNUITextViewChildManager)
|
||||
class RNUITextViewChildManager: RCTViewManager {
|
||||
override func view() -> (RNUITextViewChild) {
|
||||
return RNUITextViewChild()
|
||||
}
|
||||
|
||||
@objc override static func requiresMainQueueSetup() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func shadowView() -> RCTShadowView {
|
||||
return RNUITextViewChildShadow()
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
class RNUITextViewShadow: RCTShadowView {
|
||||
// Props
|
||||
@objc var numberOfLines: Int = 0 {
|
||||
didSet {
|
||||
if !YGNodeIsDirty(self.yogaNode) {
|
||||
self.setAttributedText()
|
||||
}
|
||||
}
|
||||
}
|
||||
@objc var allowsFontScaling: Bool = true
|
||||
|
||||
var attributedText: NSAttributedString = NSAttributedString()
|
||||
var frameSize: CGSize = CGSize()
|
||||
|
||||
var lineHeight: CGFloat = 0
|
||||
|
||||
var bridge: RCTBridge
|
||||
|
||||
init(bridge: RCTBridge) {
|
||||
self.bridge = bridge
|
||||
super.init()
|
||||
|
||||
// We need to set a custom measure func here to calculate the height correctly
|
||||
YGNodeSetMeasureFunc(self.yogaNode) { node, width, widthMode, height, heightMode in
|
||||
// Get the shadowview and determine the needed size to set
|
||||
let shadowView = Unmanaged<RNUITextViewShadow>.fromOpaque(YGNodeGetContext(node)).takeUnretainedValue()
|
||||
return shadowView.getNeededSize(maxWidth: width)
|
||||
}
|
||||
|
||||
// Subscribe to ynamic type size changes
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(preferredContentSizeChanged(_:)),
|
||||
name: UIContentSizeCategory.didChangeNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc func preferredContentSizeChanged(_ notification: Notification) {
|
||||
self.setAttributedText()
|
||||
}
|
||||
|
||||
// Returning true here will tell Yoga to not use flexbox and instead use our custom measure func.
|
||||
override func isYogaLeafNode() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Every time the subviews change, we need to reformat and render the text.
|
||||
override func didUpdateReactSubviews() {
|
||||
self.setAttributedText()
|
||||
}
|
||||
|
||||
// Whenever we layout, update the UI
|
||||
override func layoutSubviews(with layoutContext: RCTLayoutContext) {
|
||||
// Don't do anything if the layout is dirty
|
||||
if(YGNodeIsDirty(self.yogaNode)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
textView.setText(string: self.attributedText, size: self.frameSize, numberOfLines: self.numberOfLines)
|
||||
}
|
||||
}
|
||||
|
||||
override func dirtyLayout() {
|
||||
super.dirtyLayout()
|
||||
YGNodeMarkDirty(self.yogaNode)
|
||||
}
|
||||
|
||||
// Update the attributed text whenever changes are made to the subviews.
|
||||
func setAttributedText() -> Void {
|
||||
// Create an attributed string to store each of the segments
|
||||
let finalAttributedString = NSMutableAttributedString()
|
||||
|
||||
self.reactSubviews().forEach { child in
|
||||
guard let child = child as? RNUITextViewChildShadow else {
|
||||
return
|
||||
}
|
||||
let scaledFontSize = self.allowsFontScaling ?
|
||||
UIFontMetrics.default.scaledValue(for: child.fontSize) : child.fontSize
|
||||
let font = UIFont.systemFont(ofSize: scaledFontSize, weight: child.getFontWeight())
|
||||
|
||||
// Set some generic attributes that don't need ranges
|
||||
let attributes: [NSAttributedString.Key:Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: child.color,
|
||||
]
|
||||
|
||||
// Create the attributed string with the generic attributes
|
||||
let string = NSMutableAttributedString(string: child.text, attributes: attributes)
|
||||
|
||||
// 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 {
|
||||
// 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)
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
finalAttributedString.append(string)
|
||||
}
|
||||
|
||||
self.attributedText = finalAttributedString
|
||||
self.dirtyLayout()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
|
||||
let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
|
||||
|
||||
var totalLines = Int(ceil(textSize.height / self.lineHeight))
|
||||
|
||||
if self.numberOfLines != 0, totalLines > self.numberOfLines {
|
||||
totalLines = self.numberOfLines
|
||||
}
|
||||
|
||||
self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight))
|
||||
return YGSize(width: Float(self.frameSize.width), height: Float(self.frameSize.height))
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "react-native-ui-text-view",
|
||||
"version": "0.1.0",
|
||||
"description": "UITextView in React Native on iOS",
|
||||
"main": "src/index",
|
||||
"author": "haileyok",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/bluesky-social/social-app/modules/react-native-ui-text-view"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
require "json"
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
||||
folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32'
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "react-native-ui-text-view"
|
||||
s.version = package["version"]
|
||||
s.summary = package["description"]
|
||||
s.homepage = package["homepage"]
|
||||
s.license = package["license"]
|
||||
s.authors = package["author"]
|
||||
|
||||
s.platforms = { :ios => "11.0" }
|
||||
s.source = { :git => ".git", :tag => "#{s.version}" }
|
||||
|
||||
s.source_files = "ios/**/*.{h,m,mm,swift}"
|
||||
|
||||
# Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0.
|
||||
# See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79.
|
||||
if respond_to?(:install_modules_dependencies, true)
|
||||
install_modules_dependencies(s)
|
||||
else
|
||||
s.dependency "React-Core"
|
||||
|
||||
# Don't install the dependencies when we run `pod install` in the old architecture.
|
||||
if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then
|
||||
s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1"
|
||||
s.pod_target_xcconfig = {
|
||||
"HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"",
|
||||
"OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1",
|
||||
"CLANG_CXX_LANGUAGE_STANDARD" => "c++17"
|
||||
}
|
||||
s.dependency "React-RCTFabric"
|
||||
s.dependency "React-Codegen"
|
||||
s.dependency "RCT-Folly"
|
||||
s.dependency "RCTRequired"
|
||||
s.dependency "RCTTypeSafety"
|
||||
s.dependency "ReactCommon/turbomodule/core"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from 'react'
|
||||
import {Platform, StyleSheet, TextProps, ViewStyle} from 'react-native'
|
||||
import {RNUITextView, RNUITextViewChild} from './index'
|
||||
|
||||
const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([
|
||||
false,
|
||||
StyleSheet.create({}),
|
||||
])
|
||||
const useTextAncestorContext = () => React.useContext(TextAncestorContext)
|
||||
|
||||
const textDefaults: TextProps = {
|
||||
allowFontScaling: true,
|
||||
selectable: true,
|
||||
}
|
||||
|
||||
export function UITextView({style, children, ...rest}: TextProps) {
|
||||
const [isAncestor, rootStyle] = useTextAncestorContext()
|
||||
|
||||
// Flatten the styles, and apply the root styles when needed
|
||||
const flattenedStyle = React.useMemo(
|
||||
() => StyleSheet.flatten([rootStyle, style]),
|
||||
[rootStyle, style],
|
||||
)
|
||||
|
||||
if (Platform.OS !== 'ios') {
|
||||
throw new Error('UITextView is only available on iOS')
|
||||
}
|
||||
|
||||
if (!isAncestor) {
|
||||
return (
|
||||
<TextAncestorContext.Provider value={[true, flattenedStyle]}>
|
||||
<RNUITextView
|
||||
{...textDefaults}
|
||||
{...rest}
|
||||
ellipsizeMode={rest.ellipsizeMode ?? rest.lineBreakMode ?? 'tail'}
|
||||
style={[{flex: 1}, flattenedStyle]}
|
||||
onPress={undefined} // We want these to go to the children only
|
||||
onLongPress={undefined}>
|
||||
{React.Children.toArray(children).map((c, index) => {
|
||||
if (React.isValidElement(c)) {
|
||||
return c
|
||||
} else if (typeof c === 'string') {
|
||||
return (
|
||||
<RNUITextViewChild
|
||||
key={index}
|
||||
style={flattenedStyle}
|
||||
text={c}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</RNUITextView>
|
||||
</TextAncestorContext.Provider>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{React.Children.toArray(children).map((c, index) => {
|
||||
if (React.isValidElement(c)) {
|
||||
return c
|
||||
} else if (typeof c === 'string') {
|
||||
return (
|
||||
<RNUITextViewChild
|
||||
key={index}
|
||||
style={flattenedStyle}
|
||||
text={c}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import {
|
||||
requireNativeComponent,
|
||||
UIManager,
|
||||
Platform,
|
||||
type ViewStyle,
|
||||
TextProps,
|
||||
} from 'react-native'
|
||||
|
||||
const LINKING_ERROR =
|
||||
`The package 'react-native-ui-text-view' doesn't seem to be linked. Make sure: \n\n` +
|
||||
Platform.select({ios: "- You have run 'pod install'\n", default: ''}) +
|
||||
'- You rebuilt the app after installing the package\n' +
|
||||
'- You are not using Expo Go\n'
|
||||
|
||||
export interface RNUITextViewProps extends TextProps {
|
||||
children: React.ReactNode
|
||||
style: ViewStyle[]
|
||||
}
|
||||
|
||||
export interface RNUITextViewChildProps extends TextProps {
|
||||
text: string
|
||||
onTextPress?: (...args: any[]) => void
|
||||
onTextLongPress?: (...args: any[]) => void
|
||||
}
|
||||
|
||||
export const RNUITextView =
|
||||
UIManager.getViewManagerConfig &&
|
||||
UIManager.getViewManagerConfig('RNUITextView') != null
|
||||
? requireNativeComponent<RNUITextViewProps>('RNUITextView')
|
||||
: () => {
|
||||
throw new Error(LINKING_ERROR)
|
||||
}
|
||||
|
||||
export const RNUITextViewChild =
|
||||
UIManager.getViewManagerConfig &&
|
||||
UIManager.getViewManagerConfig('RNUITextViewChild') != null
|
||||
? requireNativeComponent<RNUITextViewChildProps>('RNUITextViewChild')
|
||||
: () => {
|
||||
throw new Error(LINKING_ERROR)
|
||||
}
|
||||
|
||||
export * from './UITextView'
|
||||
+7
-3
@@ -39,12 +39,14 @@
|
||||
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
|
||||
"perf:measure": "NODE_ENV=test flashlight measure",
|
||||
"intl:build": "yarn intl:extract && yarn intl:compile",
|
||||
"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",
|
||||
"update-extensions": "bash scripts/updateExtensions.sh",
|
||||
"export": "npx expo export"
|
||||
"export": "npx expo export",
|
||||
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
|
||||
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 yarn build-web",
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.2",
|
||||
@@ -174,7 +176,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-uitextview": "^1.1.6",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"react-native-uuid": "^2.0.1",
|
||||
"react-native-version-number": "^0.3.6",
|
||||
@@ -234,6 +236,7 @@
|
||||
"babel-preset-expo": "^10.0.0",
|
||||
"detox": "^20.14.8",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-bsky-internal": "link:./eslint",
|
||||
"eslint-plugin-detox": "^1.0.0",
|
||||
"eslint-plugin-ft-flow": "^2.0.3",
|
||||
"eslint-plugin-lingui": "^0.2.0",
|
||||
@@ -257,6 +260,7 @@
|
||||
"typescript": "^5.3.3",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
"webpack-cli": "^5.0.1",
|
||||
"webpack-dev-server": "^4.11.1"
|
||||
},
|
||||
|
||||
+50
-44
@@ -54,12 +54,10 @@ SplashScreen.preventAutoHideAsync()
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const queryClient = useQueryClient()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
useIntentHandler()
|
||||
useNotificationsListener(queryClient)
|
||||
useOTAUpdates()
|
||||
|
||||
// init
|
||||
@@ -79,25 +77,29 @@ function InnerApp() {
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PushNotificationsListener>
|
||||
<StatsigProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</PushNotificationsListener>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</Splash>
|
||||
</Alf>
|
||||
@@ -105,6 +107,12 @@ function InnerApp() {
|
||||
)
|
||||
}
|
||||
|
||||
function PushNotificationsListener({children}: {children: React.ReactNode}) {
|
||||
const queryClient = useQueryClient()
|
||||
useNotificationsListener(queryClient)
|
||||
return children
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
@@ -121,29 +129,27 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<QueryProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</QueryProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+42
-42
@@ -54,25 +54,27 @@ function InnerApp() {
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
<ToastContainer />
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
<ToastContainer />
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</Alf>
|
||||
)
|
||||
@@ -94,29 +96,27 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<QueryProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</QueryProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
|
||||
import {Button} from './Button'
|
||||
import {Text} from './Typography'
|
||||
|
||||
export function AccountList({
|
||||
onSelectAccount,
|
||||
onSelectOther,
|
||||
otherLabel,
|
||||
}: {
|
||||
onSelectAccount: (account: SessionAccount) => void
|
||||
onSelectOther: () => void
|
||||
otherLabel?: string
|
||||
}) {
|
||||
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const onPressAddAccount = useCallback(() => {
|
||||
onSelectOther()
|
||||
}, [onSelectOther])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{accounts.map(account => (
|
||||
<React.Fragment key={account.did}>
|
||||
<AccountItem
|
||||
account={account}
|
||||
onSelect={onSelectAccount}
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Button
|
||||
testID="chooseAddAccountBtn"
|
||||
style={[a.flex_1]}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
|
||||
label={_(msg`Login to account that is not listed`)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed || isSwitchingAccounts) &&
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.align_baseline,
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
{paddingLeft: 48},
|
||||
]}>
|
||||
{otherLabel ?? <Trans>Other account</Trans>}
|
||||
</Text>
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountItem({
|
||||
account,
|
||||
onSelect,
|
||||
isCurrentAccount,
|
||||
}: {
|
||||
account: SessionAccount
|
||||
onSelect: (account: SessionAccount) => void
|
||||
isCurrentAccount: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {data: profile} = useProfileQuery({did: account.did})
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
onSelect(account)
|
||||
}, [account, onSelect])
|
||||
|
||||
return (
|
||||
<Button
|
||||
testID={`chooseAccountBtn-${account.handle}`}
|
||||
key={account.did}
|
||||
style={[a.flex_1]}
|
||||
onPress={onPress}
|
||||
label={
|
||||
isCurrentAccount
|
||||
? _(msg`Continue as ${account.handle} (currently signed in)`)
|
||||
: _(msg`Sign in as ${account.handle}`)
|
||||
}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={a.p_md}>
|
||||
<UserAvatar avatar={profile?.avatar} size={24} />
|
||||
</View>
|
||||
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
|
||||
<Text style={[a.font_bold]}>
|
||||
{profile?.displayName || account.handle}{' '}
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text>
|
||||
</Text>
|
||||
{isCurrentAccount ? (
|
||||
<Check
|
||||
size="sm"
|
||||
style={[{color: t.palette.positive_600}, a.mr_md]}
|
||||
/>
|
||||
) : (
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Button
|
||||
|
||||
`Button` is intended to be very flexible and can be used to create consistent
|
||||
button styles, as seen in the Storybook, or can be used to create custom
|
||||
buttons with totally independent styles.
|
||||
|
||||
## Usage
|
||||
|
||||
Typical usage looks like this, where the `variant`, `size`, `color`, and/or
|
||||
`shape` are defined. Normal usage also should make use of the `ButtonText` and
|
||||
`ButtonIcon` components so that they inherit styles from the top level props
|
||||
like `color`.
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
label='Typical button'
|
||||
onPress={() => {}}
|
||||
size='large'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
>
|
||||
<ButtonIcon icon={Plus} position='left' />
|
||||
<ButtonText>Hello world</ButtonText>
|
||||
</Button>
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
label='Typical button'
|
||||
onPress={() => {}}
|
||||
size='large'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
>
|
||||
<ButtonText>Hello world</ButtonText>
|
||||
<ButtonIcon icon={Plus} position='right' />
|
||||
</Button>
|
||||
```
|
||||
|
||||
Each of those top-level props props is optional in order to allow for "custom"
|
||||
buttons, like this:
|
||||
|
||||
```tsx
|
||||
<Button label='Custom button' onPress={() => {}}>
|
||||
{({ hovered, focused, pressed, disabled }) => (
|
||||
<View style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
hovered && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<Text>{disabled ? 'Disabled' : 'Click me!'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</Button>
|
||||
```
|
||||
|
||||
In the custom button case, you have the _option_ to use `ButtonIcon` and
|
||||
`ButtonText`, but without top-level props like `color`, they serve little
|
||||
purpose, and it may be perferrable to define your own components for these
|
||||
purposes.
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/core'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/core'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
|
||||
@@ -1,47 +1,54 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
function renderItem({item}: {item: GetLikes.Like}) {
|
||||
return <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||
}
|
||||
|
||||
function keyExtractor(item: GetLikes.Like) {
|
||||
return item.actor.did
|
||||
}
|
||||
|
||||
export function LikedByList({uri}: {uri: string}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
|
||||
const {
|
||||
data: resolvedUri,
|
||||
error: resolveError,
|
||||
isFetching: isFetchingResolvedUri,
|
||||
isLoading: isUriLoading,
|
||||
} = useResolveUriQuery(uri)
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isRefetching,
|
||||
isLoading: isLikedByLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isError,
|
||||
error: likedByError,
|
||||
refetch,
|
||||
} = useLikedByQuery(resolvedUri?.uri)
|
||||
|
||||
const error = resolveError || likedByError
|
||||
const isError = !!resolveError || !!likedByError
|
||||
|
||||
const likes = React.useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return data.pages.flatMap(page => page.likes)
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const error = resolveError || likedByError
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setIsPTRing(true)
|
||||
@@ -54,56 +61,47 @@ export function LikedByList({uri}: {uri: string}) {
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more likes', {message: err})
|
||||
}
|
||||
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
||||
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => {
|
||||
if (likes.length < 1) {
|
||||
return (
|
||||
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (isFetchingResolvedUri || !isFetched) {
|
||||
return (
|
||||
<View style={[a.w_full, a.align_center, a.p_lg]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isUriLoading || isLikedByLoading}
|
||||
isError={isError}
|
||||
emptyType="results"
|
||||
emptyMessage={_(
|
||||
msg`Nobody has liked this yet. Maybe you should be the first!`,
|
||||
)}
|
||||
errorMessage={cleanError(resolveError || error)}
|
||||
onRetry={isError ? refetch : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return likes.length ? (
|
||||
return (
|
||||
<List
|
||||
data={likes}
|
||||
keyExtractor={item => item.actor.did}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={3}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={initialNumToRender}
|
||||
ListFooterComponent={() => (
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetching={isFetching && !isRefetching}
|
||||
isError={isError}
|
||||
error={error ? error.toString() : undefined}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
)}
|
||||
}
|
||||
onEndReachedThreshold={3}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
) : (
|
||||
<View style={[a.p_lg]}>
|
||||
<View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Nobody has liked this yet. Maybe you should be the first!
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
+43
-9
@@ -1,23 +1,24 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent} from 'react-native'
|
||||
import {useLinkProps, StackActions} from '@react-navigation/native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {StackActions, useLinkProps} from '@react-navigation/native'
|
||||
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
import {AllNavigatorParams} from '#/lib/routes/types'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {router} from '#/routes'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {useOpenLink} from 'state/preferences/in-app-browser'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {router} from '#/routes'
|
||||
|
||||
/**
|
||||
* Only available within a `Link`, since that inherits from `Button`.
|
||||
@@ -60,6 +61,11 @@ type BaseLinkProps = Pick<
|
||||
* Web-only attribute. Sets `download` attr on web.
|
||||
*/
|
||||
download?: string
|
||||
|
||||
/**
|
||||
* Native-only attribute. If true, will open the share sheet on long press.
|
||||
*/
|
||||
shareOnLongPress?: boolean
|
||||
}
|
||||
|
||||
export function useLink({
|
||||
@@ -68,6 +74,7 @@ export function useLink({
|
||||
action = 'push',
|
||||
disableMismatchWarning,
|
||||
onPress: outerOnPress,
|
||||
shareOnLongPress,
|
||||
}: BaseLinkProps & {
|
||||
displayText: string
|
||||
}) {
|
||||
@@ -157,10 +164,34 @@ export function useLink({
|
||||
],
|
||||
)
|
||||
|
||||
const handleLongPress = React.useCallback(() => {
|
||||
const requiresWarning = Boolean(
|
||||
!disableMismatchWarning &&
|
||||
displayText &&
|
||||
isExternal &&
|
||||
linkRequiresWarning(href, displayText),
|
||||
)
|
||||
|
||||
if (requiresWarning) {
|
||||
openModal({
|
||||
name: 'link-warning',
|
||||
text: displayText,
|
||||
href: href,
|
||||
share: true,
|
||||
})
|
||||
} else {
|
||||
shareUrl(href)
|
||||
}
|
||||
}, [disableMismatchWarning, displayText, href, isExternal, openModal])
|
||||
|
||||
const onLongPress =
|
||||
isNative && isExternal && shareOnLongPress ? handleLongPress : undefined
|
||||
|
||||
return {
|
||||
isExternal,
|
||||
href,
|
||||
onPress,
|
||||
onLongPress,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,16 +260,18 @@ export function InlineLink({
|
||||
download,
|
||||
selectable,
|
||||
label,
|
||||
shareOnLongPress,
|
||||
...rest
|
||||
}: InlineLinkProps) {
|
||||
const t = useTheme()
|
||||
const stringChildren = typeof children === 'string'
|
||||
const {href, isExternal, onPress} = useLink({
|
||||
const {href, isExternal, onPress, onLongPress} = useLink({
|
||||
to,
|
||||
displayText: stringChildren ? children : '',
|
||||
action,
|
||||
disableMismatchWarning,
|
||||
onPress: outerOnPress,
|
||||
shareOnLongPress,
|
||||
})
|
||||
const {
|
||||
state: hovered,
|
||||
@@ -270,6 +303,7 @@ export function InlineLink({
|
||||
]}
|
||||
role="link"
|
||||
onPress={download ? undefined : onPress}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
|
||||
+25
-31
@@ -1,25 +1,23 @@
|
||||
import React from 'react'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Error} from '#/components/Error'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ListFooter({
|
||||
isFetching,
|
||||
isError,
|
||||
isFetchingNextPage,
|
||||
error,
|
||||
onRetry,
|
||||
height,
|
||||
}: {
|
||||
isFetching?: boolean
|
||||
isError?: boolean
|
||||
isFetchingNextPage?: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
height?: number
|
||||
@@ -36,32 +34,26 @@ export function ListFooter({
|
||||
t.atoms.border_contrast_low,
|
||||
{height: height ?? 180, paddingTop: 30},
|
||||
]}>
|
||||
{isFetching ? (
|
||||
{isFetchingNextPage ? (
|
||||
<Loader size="xl" />
|
||||
) : (
|
||||
<ListFooterMaybeError
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
<ListFooterMaybeError error={error} onRetry={onRetry} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ListFooterMaybeError({
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
isError?: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
if (!isError) return null
|
||||
if (!error) return null
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.px_lg]}>
|
||||
@@ -128,7 +120,7 @@ export function ListHeaderDesktop({
|
||||
|
||||
export function ListMaybePlaceholder({
|
||||
isLoading,
|
||||
isEmpty,
|
||||
noEmpty,
|
||||
isError,
|
||||
emptyTitle,
|
||||
emptyMessage,
|
||||
@@ -138,7 +130,7 @@ export function ListMaybePlaceholder({
|
||||
onRetry,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
isEmpty?: boolean
|
||||
noEmpty?: boolean
|
||||
isError?: boolean
|
||||
emptyTitle?: string
|
||||
emptyMessage?: string
|
||||
@@ -151,16 +143,6 @@ export function ListMaybePlaceholder({
|
||||
const {_} = useLingui()
|
||||
const {gtMobile, gtTablet} = useBreakpoints()
|
||||
|
||||
if (!isLoading && isError) {
|
||||
return (
|
||||
<Error
|
||||
title={errorTitle ?? _(msg`Oops!`)}
|
||||
message={errorMessage ?? _(`Something went wrong!`)}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CenteredView
|
||||
@@ -180,7 +162,17 @@ export function ListMaybePlaceholder({
|
||||
)
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
if (isError) {
|
||||
return (
|
||||
<Error
|
||||
title={errorTitle ?? _(msg`Oops!`)}
|
||||
message={errorMessage ?? _(`Something went wrong!`)}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!noEmpty) {
|
||||
return (
|
||||
<Error
|
||||
title={
|
||||
@@ -197,4 +189,6 @@ export function ListMaybePlaceholder({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React from 'react'
|
||||
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {toShortUrl} from '#/lib/strings/url-helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
|
||||
@@ -105,7 +105,8 @@ export function RichText({
|
||||
to={link.uri}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}>
|
||||
dataSet={WORD_WRAP}
|
||||
shareOnLongPress>
|
||||
{toShortUrl(segment.text)}
|
||||
</InlineLink>,
|
||||
)
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Text as RNText,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
TextProps as RNTextProps,
|
||||
} from 'react-native'
|
||||
import {UITextView} from 'react-native-ui-text-view'
|
||||
import {StyleProp, TextProps as RNTextProps, TextStyle} from 'react-native'
|
||||
import {UITextView} from 'react-native-uitextview'
|
||||
|
||||
import {useTheme, atoms, web, flatten} from '#/alf'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms, flatten, useTheme, web} from '#/alf'
|
||||
|
||||
export type TextProps = RNTextProps & {
|
||||
/**
|
||||
@@ -61,11 +56,8 @@ export function normalizeTextStyles(styles: StyleProp<TextStyle>) {
|
||||
export function Text({style, selectable, ...rest}: TextProps) {
|
||||
const t = useTheme()
|
||||
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
|
||||
return selectable && isIOS ? (
|
||||
<UITextView style={s} {...rest} />
|
||||
) : (
|
||||
<RNText selectable={selectable} style={s} {...rest} />
|
||||
)
|
||||
|
||||
return <UITextView selectable={selectable} uiTextView style={s} {...rest} />
|
||||
}
|
||||
|
||||
export function createHeadingElement({level}: {level: number}) {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import React from 'react'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '../Typography'
|
||||
import {DateInput} from '#/view/com/util/forms/DateInput'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetBirthDateMutation,
|
||||
UsePreferencesQueryResponse,
|
||||
usePreferencesSetBirthDateMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {DateInput} from '#/view/com/util/forms/DateInput'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function BirthDateSettingsDialog({
|
||||
control,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {AccountList} from '../AccountList'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function SwitchAccountDialog({
|
||||
control,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
const onSelectAccount = useCallback(
|
||||
(account: SessionAccount) => {
|
||||
if (account.did === currentAccount?.did) {
|
||||
control.close()
|
||||
} else {
|
||||
onPressSwitchAccount(account, 'SwitchAccount')
|
||||
}
|
||||
},
|
||||
[currentAccount, control, onPressSwitchAccount],
|
||||
)
|
||||
|
||||
const onPressAddAccount = useCallback(() => {
|
||||
setShowLoggedOut(true)
|
||||
closeAllActiveElements()
|
||||
}, [setShowLoggedOut, closeAllActiveElements])
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Switch Account</Trans>
|
||||
</Text>
|
||||
|
||||
<AccountList
|
||||
onSelectAccount={onSelectAccount}
|
||||
onSelectOther={onPressAddAccount}
|
||||
otherLabel={_(msg`Add account`)}
|
||||
/>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,26 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
TouchableWithoutFeedback,
|
||||
StyleProp,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ScreenHider({
|
||||
testID,
|
||||
@@ -125,7 +124,15 @@ export function ScreenHider({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Learn more about this warning`)}
|
||||
accessibilityHint="">
|
||||
<Text style={[a.text_lg, {color: t.palette.primary_500}]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
{
|
||||
color: t.palette.primary_500,
|
||||
// @ts-ignore web only -prf
|
||||
cursor: 'pointer',
|
||||
},
|
||||
]}>
|
||||
<Trans>Learn More</Trans>
|
||||
</Text>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
|
||||
import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
|
||||
import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg'
|
||||
|
||||
export function GridIcon({
|
||||
style,
|
||||
@@ -141,8 +141,8 @@ export function MagnifyingGlassIcon2({
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Ellipse cx="12" cy="11" rx="9" ry="9" />
|
||||
<Line x1="19" y1="17.3" x2="23.5" y2="21" strokeLinecap="round" />
|
||||
<Ellipse cx="12" cy="10.5" rx="9" ry="9" />
|
||||
<Line x1="18.5" y1="17" x2="22" y2="20.5" strokeLinecap="round" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@@ -167,14 +167,14 @@ export function MagnifyingGlassIcon2Solid({
|
||||
style={style}>
|
||||
<Ellipse
|
||||
cx="12"
|
||||
cy="11"
|
||||
rx="7"
|
||||
ry="7"
|
||||
cy="10.5"
|
||||
rx="6.5"
|
||||
ry="6.5"
|
||||
stroke="none"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Ellipse cx="12" cy="11" rx="9" ry="9" />
|
||||
<Line x1="19" y1="17.3" x2="23.5" y2="21" strokeLinecap="round" />
|
||||
<Ellipse cx="12" cy="10.5" rx="9" ry="9" />
|
||||
<Line x1="18.5" y1="17" x2="22" y2="20.5" strokeLinecap="round" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useMemo} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMemo} from 'react'
|
||||
|
||||
export type GlobalLabelStrings = Record<
|
||||
string,
|
||||
@@ -31,7 +31,7 @@ export function useGlobalLabelStrings(): GlobalLabelStrings {
|
||||
),
|
||||
},
|
||||
porn: {
|
||||
name: _(msg`Pornography`),
|
||||
name: _(msg`Adult Content`),
|
||||
description: _(msg`Explicit sexual images.`),
|
||||
},
|
||||
sexual: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMemo} from 'react'
|
||||
import {ComAtprotoModerationDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
export interface ReportOption {
|
||||
reason: string
|
||||
@@ -68,7 +68,7 @@ export function useReportOptions(): ReportOptions {
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONSEXUAL,
|
||||
title: _(msg`Unwanted Sexual Content`),
|
||||
description: _(msg`Nudity or pornography not labeled as such`),
|
||||
description: _(msg`Nudity or adult content not labeled as such`),
|
||||
},
|
||||
...common,
|
||||
],
|
||||
|
||||
+66
-31
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, {useRef, useState} from 'react'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
// any query keys in this array will be persisted to AsyncStorage
|
||||
const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info']
|
||||
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
|
||||
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
|
||||
|
||||
focusManager.setEventListener(onFocus => {
|
||||
if (isNative) {
|
||||
@@ -39,46 +40,80 @@ focusManager.setEventListener(onFocus => {
|
||||
}
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// NOTE
|
||||
// refetchOnWindowFocus breaks some UIs (like feeds)
|
||||
// so we only selectively want to enable this
|
||||
// -prf
|
||||
refetchOnWindowFocus: false,
|
||||
// Structural sharing between responses makes it impossible to rely on
|
||||
// "first seen" timestamps on objects to determine if they're fresh.
|
||||
// Disable this optimization so that we can rely on "first seen" timestamps.
|
||||
structuralSharing: false,
|
||||
// We don't want to retry queries by default, because in most cases we
|
||||
// want to fail early and show a response to the user. There are
|
||||
// exceptions, and those can be made on a per-query basis. For others, we
|
||||
// should give users controls to retry.
|
||||
retry: false,
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// NOTE
|
||||
// refetchOnWindowFocus breaks some UIs (like feeds)
|
||||
// so we only selectively want to enable this
|
||||
// -prf
|
||||
refetchOnWindowFocus: false,
|
||||
// Structural sharing between responses makes it impossible to rely on
|
||||
// "first seen" timestamps on objects to determine if they're fresh.
|
||||
// Disable this optimization so that we can rely on "first seen" timestamps.
|
||||
structuralSharing: false,
|
||||
// We don't want to retry queries by default, because in most cases we
|
||||
// want to fail early and show a response to the user. There are
|
||||
// exceptions, and those can be made on a per-query basis. For others, we
|
||||
// should give users controls to retry.
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const asyncStoragePersister = createAsyncStoragePersister({
|
||||
storage: AsyncStorage,
|
||||
key: 'queryCache',
|
||||
})
|
||||
})
|
||||
|
||||
const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] =
|
||||
{
|
||||
shouldDehydrateMutation: (_: any) => false,
|
||||
shouldDehydrateQuery: query => {
|
||||
return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0]))
|
||||
return STORED_CACHE_QUERY_KEY_ROOTS.includes(String(query.queryKey[0]))
|
||||
},
|
||||
}
|
||||
|
||||
const persistOptions = {
|
||||
persister: asyncStoragePersister,
|
||||
dehydrateOptions,
|
||||
export function QueryProvider({
|
||||
children,
|
||||
currentDid,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
currentDid: string | undefined
|
||||
}) {
|
||||
return (
|
||||
<QueryProviderInner
|
||||
// Enforce we never reuse cache between users.
|
||||
// These two props MUST stay in sync.
|
||||
key={currentDid}
|
||||
currentDid={currentDid}>
|
||||
{children}
|
||||
</QueryProviderInner>
|
||||
)
|
||||
}
|
||||
|
||||
export function QueryProvider({children}: {children: React.ReactNode}) {
|
||||
function QueryProviderInner({
|
||||
children,
|
||||
currentDid,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
currentDid: string | undefined
|
||||
}) {
|
||||
const initialDid = useRef(currentDid)
|
||||
if (currentDid !== initialDid.current) {
|
||||
throw Error(
|
||||
'Something is very wrong. Expected did to be stable due to key above.',
|
||||
)
|
||||
}
|
||||
// We create the query client here so that it's scoped to a specific DID.
|
||||
// Do not move the query client creation outside of this component.
|
||||
const [queryClient, _setQueryClient] = useState(() => createQueryClient())
|
||||
const [persistOptions, _setPersistOptions] = useState(() => {
|
||||
const asyncPersister = createAsyncStoragePersister({
|
||||
storage: AsyncStorage,
|
||||
key: 'queryClient-' + (currentDid ?? 'logged-out'),
|
||||
})
|
||||
return {
|
||||
persister: asyncPersister,
|
||||
dehydrateOptions,
|
||||
}
|
||||
})
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
|
||||
+4
-3
@@ -1,8 +1,9 @@
|
||||
import {isIOS, isAndroid} from 'platform/detection'
|
||||
import {Share} from 'react-native'
|
||||
// import * as Sharing from 'expo-sharing'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import * as Toast from '../view/com/util/Toast'
|
||||
import {Share} from 'react-native'
|
||||
|
||||
import {isAndroid, isIOS} from 'platform/detection'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
/**
|
||||
* This function shares a URL using the native Share API if available, or copies it to the clipboard
|
||||
|
||||
+11
-6
@@ -1,7 +1,8 @@
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
|
||||
import lande from 'lande'
|
||||
import {hasProp} from 'lib/type-guards'
|
||||
import * as bcp47Match from 'bcp-47-match'
|
||||
import lande from 'lande'
|
||||
|
||||
import {hasProp} from 'lib/type-guards'
|
||||
import {
|
||||
AppLanguage,
|
||||
LANGUAGES_MAP_CODE2,
|
||||
@@ -118,6 +119,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
||||
switch (lang) {
|
||||
case 'en':
|
||||
return AppLanguage.en
|
||||
case 'ca':
|
||||
return AppLanguage.ca
|
||||
case 'de':
|
||||
return AppLanguage.de
|
||||
case 'es':
|
||||
@@ -126,26 +129,28 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
||||
return AppLanguage.fi
|
||||
case 'fr':
|
||||
return AppLanguage.fr
|
||||
case 'ga':
|
||||
return AppLanguage.ga
|
||||
case 'hi':
|
||||
return AppLanguage.hi
|
||||
case 'id':
|
||||
return AppLanguage.id
|
||||
case 'it':
|
||||
return AppLanguage.it
|
||||
case 'ja':
|
||||
return AppLanguage.ja
|
||||
case 'ko':
|
||||
return AppLanguage.ko
|
||||
case 'pt-BR':
|
||||
return AppLanguage.pt_BR
|
||||
case 'tr':
|
||||
return AppLanguage.tr
|
||||
case 'uk':
|
||||
return AppLanguage.uk
|
||||
case 'ca':
|
||||
return AppLanguage.ca
|
||||
case 'zh-CN':
|
||||
return AppLanguage.zh_CN
|
||||
case 'zh-TW':
|
||||
return AppLanguage.zh_TW
|
||||
case 'it':
|
||||
return AppLanguage.it
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
+26
-16
@@ -1,31 +1,36 @@
|
||||
import {useEffect} from 'react'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {messages as messagesEn} from '#/locale/locales/en/messages'
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {AppLanguage} from '#/locale/languages'
|
||||
import {messages as messagesCa} from '#/locale/locales/ca/messages'
|
||||
import {messages as messagesDe} from '#/locale/locales/de/messages'
|
||||
import {messages as messagesId} from '#/locale/locales/id/messages'
|
||||
import {messages as messagesEn} from '#/locale/locales/en/messages'
|
||||
import {messages as messagesEs} from '#/locale/locales/es/messages'
|
||||
import {messages as messagesFi} from '#/locale/locales/fi/messages'
|
||||
import {messages as messagesFr} from '#/locale/locales/fr/messages'
|
||||
import {messages as messagesGa} from '#/locale/locales/ga/messages'
|
||||
import {messages as messagesHi} from '#/locale/locales/hi/messages'
|
||||
import {messages as messagesId} from '#/locale/locales/id/messages'
|
||||
import {messages as messagesIt} from '#/locale/locales/it/messages'
|
||||
import {messages as messagesJa} from '#/locale/locales/ja/messages'
|
||||
import {messages as messagesKo} from '#/locale/locales/ko/messages'
|
||||
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
|
||||
import {messages as messagesTr} from '#/locale/locales/tr/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 messagesZh_TW} from '#/locale/locales/zh-TW/messages'
|
||||
import {messages as messagesIt} from '#/locale/locales/it/messages'
|
||||
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {AppLanguage} from '#/locale/languages'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
|
||||
/**
|
||||
* We do a dynamic import of just the catalog that we need
|
||||
*/
|
||||
export async function dynamicActivate(locale: AppLanguage) {
|
||||
switch (locale) {
|
||||
case AppLanguage.ca: {
|
||||
i18n.loadAndActivate({locale, messages: messagesCa})
|
||||
break
|
||||
}
|
||||
case AppLanguage.de: {
|
||||
i18n.loadAndActivate({locale, messages: messagesDe})
|
||||
break
|
||||
@@ -42,6 +47,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
i18n.loadAndActivate({locale, messages: messagesFr})
|
||||
break
|
||||
}
|
||||
case AppLanguage.ga: {
|
||||
i18n.loadAndActivate({locale, messages: messagesGa})
|
||||
break
|
||||
}
|
||||
case AppLanguage.hi: {
|
||||
i18n.loadAndActivate({locale, messages: messagesHi})
|
||||
break
|
||||
@@ -50,6 +59,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
i18n.loadAndActivate({locale, messages: messagesId})
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
i18n.loadAndActivate({locale, messages: messagesIt})
|
||||
break
|
||||
}
|
||||
case AppLanguage.ja: {
|
||||
i18n.loadAndActivate({locale, messages: messagesJa})
|
||||
break
|
||||
@@ -62,24 +75,21 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
i18n.loadAndActivate({locale, messages: messagesPt_BR})
|
||||
break
|
||||
}
|
||||
case AppLanguage.uk: {
|
||||
i18n.loadAndActivate({locale, messages: messagesUk})
|
||||
case AppLanguage.tr: {
|
||||
i18n.loadAndActivate({locale, messages: messagesTr})
|
||||
break
|
||||
}
|
||||
case AppLanguage.ca: {
|
||||
i18n.loadAndActivate({locale, messages: messagesCa})
|
||||
case AppLanguage.uk: {
|
||||
i18n.loadAndActivate({locale, messages: messagesUk})
|
||||
break
|
||||
}
|
||||
case AppLanguage.zh_CN: {
|
||||
i18n.loadAndActivate({locale, messages: messagesZh_CN})
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
i18n.loadAndActivate({locale, messages: messagesIt})
|
||||
break
|
||||
}
|
||||
case AppLanguage.zh_TW: {
|
||||
i18n.loadAndActivate({locale, messages: messagesZh_TW})
|
||||
break
|
||||
}
|
||||
default: {
|
||||
i18n.loadAndActivate({locale, messages: messagesEn})
|
||||
|
||||
+17
-9
@@ -1,9 +1,9 @@
|
||||
import {useEffect} from 'react'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {AppLanguage} from '#/locale/languages'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
|
||||
/**
|
||||
* We do a dynamic import of just the catalog that we need
|
||||
@@ -12,6 +12,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
let mod: any
|
||||
|
||||
switch (locale) {
|
||||
case AppLanguage.ca: {
|
||||
mod = await import(`./locales/ca/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.de: {
|
||||
mod = await import(`./locales/de/messages`)
|
||||
break
|
||||
@@ -28,6 +32,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
mod = await import(`./locales/fr/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.ga: {
|
||||
mod = await import(`./locales/ga/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.hi: {
|
||||
mod = await import(`./locales/hi/messages`)
|
||||
break
|
||||
@@ -36,6 +44,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
mod = await import(`./locales/id/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
mod = await import(`./locales/it/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.ja: {
|
||||
mod = await import(`./locales/ja/messages`)
|
||||
break
|
||||
@@ -48,12 +60,12 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
mod = await import(`./locales/pt-BR/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.uk: {
|
||||
mod = await import(`./locales/uk/messages`)
|
||||
case AppLanguage.tr: {
|
||||
mod = await import(`./locales/tr/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.ca: {
|
||||
mod = await import(`./locales/ca/messages`)
|
||||
case AppLanguage.uk: {
|
||||
mod = await import(`./locales/uk/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.zh_CN: {
|
||||
@@ -64,10 +76,6 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
mod = await import(`./locales/zh-TW/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
mod = await import(`./locales/it/messages`)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
mod = await import(`./locales/en/messages`)
|
||||
break
|
||||
|
||||
+10
-6
@@ -6,20 +6,22 @@ interface Language {
|
||||
|
||||
export enum AppLanguage {
|
||||
en = 'en',
|
||||
ca = 'ca',
|
||||
de = 'de',
|
||||
es = 'es',
|
||||
fi = 'fi',
|
||||
fr = 'fr',
|
||||
ga = 'ga',
|
||||
hi = 'hi',
|
||||
id = 'id',
|
||||
it = 'it',
|
||||
ja = 'ja',
|
||||
ko = 'ko',
|
||||
pt_BR = 'pt-BR',
|
||||
tr = 'tr',
|
||||
uk = 'uk',
|
||||
ca = 'ca',
|
||||
zh_CN = 'zh-CN',
|
||||
zh_TW = 'zh-TW',
|
||||
it = 'it',
|
||||
}
|
||||
|
||||
interface AppLanguageConfig {
|
||||
@@ -29,20 +31,22 @@ interface AppLanguageConfig {
|
||||
|
||||
export const APP_LANGUAGES: AppLanguageConfig[] = [
|
||||
{code2: AppLanguage.en, name: 'English'},
|
||||
{code2: AppLanguage.ca, name: 'Català – Catalan'},
|
||||
{code2: AppLanguage.de, name: 'Deutsch – German'},
|
||||
{code2: AppLanguage.es, name: 'Español – Spanish'},
|
||||
{code2: AppLanguage.fi, name: 'Suomi – Finnish'},
|
||||
{code2: AppLanguage.fr, name: 'Français – French'},
|
||||
{code2: AppLanguage.ga, name: 'Gaeilge – Irish'},
|
||||
{code2: AppLanguage.hi, name: 'हिंदी – Hindi'},
|
||||
{code2: AppLanguage.id, name: 'Bahasa Indonesia – Indonesian'},
|
||||
{code2: AppLanguage.it, name: 'Italiano – Italian'},
|
||||
{code2: AppLanguage.ja, name: '日本語 – Japanese'},
|
||||
{code2: AppLanguage.ko, name: '한국어 – Korean'},
|
||||
{code2: AppLanguage.pt_BR, name: 'Português (BR) – Portuguese (BR)'},
|
||||
{code2: AppLanguage.tr, name: 'Türkçe – Turkish'},
|
||||
{code2: AppLanguage.uk, name: 'Українська – Ukrainian'},
|
||||
{code2: AppLanguage.ca, name: 'Català – Catalan'},
|
||||
{code2: AppLanguage.zh_CN, name: '简体中文(中国) – Chinese (Simplified)'},
|
||||
{code2: AppLanguage.zh_TW, name: '繁體中文(臺灣) – Chinese (Traditional)'},
|
||||
{code2: AppLanguage.it, name: 'Italiano - Italian'},
|
||||
{code2: AppLanguage.zh_CN, name: '简体中文(中国)– Chinese (Simplified)'},
|
||||
{code2: AppLanguage.zh_TW, name: '繁體中文(臺灣)– Chinese (Traditional)'},
|
||||
]
|
||||
|
||||
export const LANGUAGES: Language[] = [
|
||||
|
||||
+411
-411
File diff suppressed because it is too large
Load Diff
+276
-276
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+366
-366
File diff suppressed because it is too large
Load Diff
+550
-629
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,3 +6,24 @@ findLast.shim()
|
||||
|
||||
// @ts-ignore whatever typescript wants to complain about here, I dont care about -prf
|
||||
window.setImmediate = (cb: () => void) => setTimeout(cb, 0)
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// In development, react-native-web's <View> tries to validate that
|
||||
// text is wrapped into <Text>. It doesn't catch all cases but is useful.
|
||||
// Unfortunately, it only does that via console.error so it's easy to miss.
|
||||
// This is a hack to get it showing as a redbox on the web so we catch it early.
|
||||
const realConsoleError = console.error
|
||||
const thrownErrors = new WeakSet()
|
||||
console.error = function consoleErrorWrapper(msgOrError) {
|
||||
if (
|
||||
typeof msgOrError === 'string' &&
|
||||
msgOrError.startsWith('Unexpected text node')
|
||||
) {
|
||||
const err = new Error(msgOrError)
|
||||
thrownErrors.add(err)
|
||||
throw err
|
||||
} else if (!thrownErrors.has(msgOrError)) {
|
||||
return realConsoleError.apply(this, arguments as any)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-33
@@ -1,28 +1,30 @@
|
||||
import React from 'react'
|
||||
import {ListRenderItemInfo, Pressable} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {useSetMinimalShellMode} from 'state/shell'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {useSearchPostsQuery} from 'state/queries/search-posts'
|
||||
import {Post} from 'view/com/post/Post'
|
||||
import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {enforceLen} from 'lib/strings/helpers'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useSearchPostsQuery} from 'state/queries/search-posts'
|
||||
import {useSetMinimalShellMode} from 'state/shell'
|
||||
import {Post} from 'view/com/post/Post'
|
||||
import {List} from 'view/com/util/List'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox'
|
||||
import {
|
||||
ListFooter,
|
||||
ListHeaderDesktop,
|
||||
ListMaybePlaceholder,
|
||||
} from '#/components/Lists'
|
||||
import {List} from 'view/com/util/List'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
|
||||
const renderItem = ({item}: ListRenderItemInfo<PostView>) => {
|
||||
return <Post post={item} />
|
||||
@@ -61,9 +63,8 @@ export default function HashtagScreen({
|
||||
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
@@ -97,9 +98,9 @@ export default function HashtagScreen({
|
||||
}, [refetch])
|
||||
|
||||
const onEndReached = React.useCallback(() => {
|
||||
if (isFetching || !hasNextPage || error) return
|
||||
if (isFetchingNextPage || !hasNextPage || error) return
|
||||
fetchNextPage()
|
||||
}, [isFetching, hasNextPage, error, fetchNextPage])
|
||||
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -123,16 +124,16 @@ export default function HashtagScreen({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoading || isRefetching}
|
||||
isError={isError}
|
||||
isEmpty={posts.length < 1}
|
||||
onRetry={refetch}
|
||||
emptyTitle="results"
|
||||
emptyMessage={_(msg`We couldn't find any results for that hashtag.`)}
|
||||
/>
|
||||
{!isLoading && posts.length > 0 && (
|
||||
<List<PostView>
|
||||
{posts.length < 1 ? (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
emptyType="results"
|
||||
emptyMessage={_(msg`We couldn't find any results for that hashtag.`)}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
data={posts}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
@@ -150,9 +151,8 @@ export default function HashtagScreen({
|
||||
}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetching={isFetching && !isRefetching}
|
||||
isError={isError}
|
||||
error={error?.name}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -5,76 +5,15 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {colors} from '#/lib/styles'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {AccountList} from '#/components/AccountList'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {FormContainer} from './FormContainer'
|
||||
|
||||
function AccountItem({
|
||||
account,
|
||||
onSelect,
|
||||
isCurrentAccount,
|
||||
}: {
|
||||
account: SessionAccount
|
||||
onSelect: (account: SessionAccount) => void
|
||||
isCurrentAccount: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {data: profile} = useProfileQuery({did: account.did})
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
onSelect(account)
|
||||
}, [account, onSelect])
|
||||
|
||||
return (
|
||||
<Button
|
||||
testID={`chooseAccountBtn-${account.handle}`}
|
||||
key={account.did}
|
||||
style={[a.flex_1]}
|
||||
onPress={onPress}
|
||||
label={
|
||||
isCurrentAccount
|
||||
? _(msg`Continue as ${account.handle} (currently signed in)`)
|
||||
: _(msg`Sign in as ${account.handle}`)
|
||||
}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={a.p_md}>
|
||||
<UserAvatar avatar={profile?.avatar} size={24} />
|
||||
</View>
|
||||
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
|
||||
<Text style={[a.font_bold]}>
|
||||
{profile?.displayName || account.handle}{' '}
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text>
|
||||
</Text>
|
||||
{isCurrentAccount ? (
|
||||
<Check size="sm" style={[{color: colors.green3}, a.mr_md]} />
|
||||
) : (
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
export const ChooseAccountForm = ({
|
||||
onSelectAccount,
|
||||
onPressBack,
|
||||
@@ -84,8 +23,7 @@ export const ChooseAccountForm = ({
|
||||
}) => {
|
||||
const {track, screen} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {accounts, currentAccount} = useSession()
|
||||
const {currentAccount} = useSession()
|
||||
const {initSession} = useSessionApi()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
|
||||
@@ -125,52 +63,10 @@ export const ChooseAccountForm = ({
|
||||
<TextField.Label>
|
||||
<Trans>Sign in as...</Trans>
|
||||
</TextField.Label>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{accounts.map(account => (
|
||||
<React.Fragment key={account.did}>
|
||||
<AccountItem
|
||||
account={account}
|
||||
onSelect={onSelect}
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Button
|
||||
testID="chooseNewAccountBtn"
|
||||
style={[a.flex_1]}
|
||||
onPress={() => onSelectAccount(undefined)}
|
||||
label={_(msg`Login to account that is not listed`)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.align_baseline,
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
{paddingLeft: 48},
|
||||
]}>
|
||||
<Trans>Other account</Trans>
|
||||
</Text>
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
<AccountList
|
||||
onSelectAccount={onSelect}
|
||||
onSelectOther={() => onSelectAccount()}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_row]}>
|
||||
<Button
|
||||
|
||||
@@ -242,6 +242,8 @@ let ProfileHeaderLabeler = ({
|
||||
style={[a.text_md]}
|
||||
numberOfLines={15}
|
||||
value={descriptionRT}
|
||||
enableTags
|
||||
authorHandle={profile.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
@@ -2,39 +2,38 @@ import React, {memo, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
ModerationOpts,
|
||||
moderateProfile,
|
||||
ModerationOpts,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useSession, useRequireAuth} from '#/state/session'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {
|
||||
useProfileFollowMutationQueue,
|
||||
useProfileBlockMutationQueue,
|
||||
} from '#/state/queries/profile'
|
||||
import {logger} from '#/logger'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {
|
||||
useProfileBlockMutationQueue,
|
||||
useProfileFollowMutationQueue,
|
||||
} from '#/state/queries/profile'
|
||||
import {useRequireAuth, useSession} from '#/state/session'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {ProfileHeaderShell} from './Shell'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
|
||||
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {ProfileHeaderDisplayName} from './DisplayName'
|
||||
import {ProfileHeaderHandle} from './Handle'
|
||||
import {ProfileHeaderMetrics} from './Metrics'
|
||||
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {ProfileHeaderShell} from './Shell'
|
||||
|
||||
interface Props {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
@@ -248,6 +247,8 @@ let ProfileHeaderStandard = ({
|
||||
style={[a.text_md]}
|
||||
numberOfLines={15}
|
||||
value={descriptionRT}
|
||||
enableTags
|
||||
authorHandle={profile.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
@@ -4,13 +4,11 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {LikedByList} from '#/components/LikedByList'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
|
||||
export function ProfileLabelerLikedByScreen({
|
||||
route,
|
||||
@@ -19,7 +17,6 @@ export function ProfileLabelerLikedByScreen({
|
||||
const {name: handleOrDid} = route.params
|
||||
const uri = makeRecordUri(handleOrDid, 'app.bsky.labeler.service', 'self')
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
@@ -28,17 +25,7 @@ export function ProfileLabelerLikedByScreen({
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.w_full,
|
||||
a.h_full_vh,
|
||||
gtMobile && [
|
||||
{
|
||||
maxWidth: 600,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
<View style={{flex: 1}}>
|
||||
<ViewHeader title={_(msg`Liked By`)} />
|
||||
<LikedByList uri={uri} />
|
||||
</View>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||
|
||||
import {ImageModel} from '#/state/models/media/image'
|
||||
import {GalleryModel} from '#/state/models/media/gallery'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {EmbedPlayerSource} from '#/lib/strings/embed-player'
|
||||
import {GalleryModel} from '#/state/models/media/gallery'
|
||||
import {ImageModel} from '#/state/models/media/image'
|
||||
import {ThreadgateSetting} from '../queries/threadgate'
|
||||
|
||||
export interface EditProfileModal {
|
||||
@@ -118,14 +118,11 @@ export interface ChangePasswordModal {
|
||||
name: 'change-password'
|
||||
}
|
||||
|
||||
export interface SwitchAccountModal {
|
||||
name: 'switch-account'
|
||||
}
|
||||
|
||||
export interface LinkWarningModal {
|
||||
name: 'link-warning'
|
||||
text: string
|
||||
href: string
|
||||
share?: boolean
|
||||
}
|
||||
|
||||
export interface EmbedConsentModal {
|
||||
@@ -148,7 +145,6 @@ export type Modal =
|
||||
| VerifyEmailModal
|
||||
| ChangeEmailModal
|
||||
| ChangePasswordModal
|
||||
| SwitchAccountModal
|
||||
|
||||
// Curation
|
||||
| ContentLanguagesSettingsModal
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs, ModerationOpts, moderateProfile} from '@atproto/api'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {useMyFollowsQuery} from '#/state/queries/my-follows'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences'
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {isJustAMute} from '#/lib/moderation'
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useMyFollowsQuery} from '#/state/queries/my-follows'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences'
|
||||
|
||||
const DEFAULT_MOD_OPTS = {
|
||||
userDid: undefined,
|
||||
prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
|
||||
}
|
||||
|
||||
export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
|
||||
const RQKEY_ROOT = 'actor-autocomplete'
|
||||
export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix]
|
||||
|
||||
export function useActorAutocompleteQuery(prefix: string) {
|
||||
const {data: follows, isFetching} = useMyFollowsQuery()
|
||||
@@ -29,7 +30,7 @@ export function useActorAutocompleteQuery(prefix: string) {
|
||||
async queryFn() {
|
||||
const res = prefix
|
||||
? await getAgent().searchActorsTypeahead({
|
||||
term: prefix,
|
||||
q: prefix,
|
||||
limit: 8,
|
||||
})
|
||||
: undefined
|
||||
@@ -67,7 +68,7 @@ export function useActorAutocompleteFn() {
|
||||
queryKey: RQKEY(query || ''),
|
||||
queryFn: () =>
|
||||
getAgent().searchActorsTypeahead({
|
||||
term: query,
|
||||
q: query,
|
||||
limit,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {QueryClient, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export const RQKEY = (prefix: string) => ['actor-search', prefix]
|
||||
const RQKEY_ROOT = 'actor-search'
|
||||
export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix]
|
||||
|
||||
export function useActorSearch(prefix: string) {
|
||||
return useQuery<AppBskyActorDefs.ProfileView[]>({
|
||||
@@ -12,7 +13,7 @@ export function useActorSearch(prefix: string) {
|
||||
queryKey: RQKEY(prefix || ''),
|
||||
async queryFn() {
|
||||
const res = await getAgent().searchActors({
|
||||
term: prefix,
|
||||
q: prefix,
|
||||
})
|
||||
return res.data.actors
|
||||
},
|
||||
@@ -26,7 +27,7 @@ export function* findAllProfilesInQueryData(
|
||||
) {
|
||||
const queryDatas = queryClient.getQueriesData<AppBskyActorDefs.ProfileView[]>(
|
||||
{
|
||||
queryKey: ['actor-search'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
},
|
||||
)
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {ComAtprotoServerCreateAppPassword} from '@atproto/api'
|
||||
import {useQuery, useQueryClient, useMutation} from '@tanstack/react-query'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {STALE} from '#/state/queries'
|
||||
import {getAgent} from '../session'
|
||||
|
||||
export const RQKEY = () => ['app-passwords']
|
||||
const RQKEY_ROOT = 'app-passwords'
|
||||
export const RQKEY = () => [RQKEY_ROOT]
|
||||
|
||||
export function useAppPasswordsQuery() {
|
||||
return useQuery({
|
||||
|
||||
+17
-14
@@ -1,24 +1,24 @@
|
||||
import {
|
||||
useQuery,
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
useMutation,
|
||||
} from '@tanstack/react-query'
|
||||
import {
|
||||
AtUri,
|
||||
RichText,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
AppBskyUnspeccedGetPopularFeedGenerators,
|
||||
AtUri,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {router} from '#/routes'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export type FeedSourceFeedInfo = {
|
||||
type: 'feed'
|
||||
@@ -56,8 +56,9 @@ export type FeedSourceListInfo = {
|
||||
|
||||
export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo
|
||||
|
||||
const feedSourceInfoQueryKeyRoot = 'getFeedSourceInfo'
|
||||
export const feedSourceInfoQueryKey = ({uri}: {uri: string}) => [
|
||||
'getFeedSourceInfo',
|
||||
feedSourceInfoQueryKeyRoot,
|
||||
uri,
|
||||
]
|
||||
|
||||
@@ -216,6 +217,8 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
|
||||
likeUri: '',
|
||||
}
|
||||
|
||||
const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
|
||||
|
||||
export function usePinnedFeedsInfos() {
|
||||
const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
|
||||
const pinnedUris = preferences?.feeds?.pinned ?? []
|
||||
@@ -223,7 +226,7 @@ export function usePinnedFeedsInfos() {
|
||||
return useQuery({
|
||||
staleTime: STALE.INFINITY,
|
||||
enabled: !isLoadingPrefs,
|
||||
queryKey: ['pinnedFeedsInfos', pinnedUris.join(',')],
|
||||
queryKey: [pinnedFeedInfosQueryKeyRoot, pinnedUris.join(',')],
|
||||
queryFn: async () => {
|
||||
let resolved = new Map()
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import React from 'react'
|
||||
import {useQueryClient, useMutation} from '@tanstack/react-query'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
const fetchHandleQueryKey = (handleOrDid: string) => ['handle', handleOrDid]
|
||||
const fetchDidQueryKey = (handleOrDid: string) => ['did', handleOrDid]
|
||||
const handleQueryKeyRoot = 'handle'
|
||||
const fetchHandleQueryKey = (handleOrDid: string) => [
|
||||
handleQueryKeyRoot,
|
||||
handleOrDid,
|
||||
]
|
||||
const didQueryKeyRoot = 'did'
|
||||
const fetchDidQueryKey = (handleOrDid: string) => [didQueryKeyRoot, handleOrDid]
|
||||
|
||||
export function useFetchHandle() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import {ComAtprotoServerDefs} from '@atproto/api'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean {
|
||||
return invite.available - invite.uses.length > 0 && !invite.disabled
|
||||
}
|
||||
|
||||
const inviteCodesQueryKeyRoot = 'inviteCodes'
|
||||
|
||||
export type InviteCodesQueryResponse = Exclude<
|
||||
ReturnType<typeof useInviteCodesQuery>['data'],
|
||||
undefined
|
||||
@@ -16,7 +18,7 @@ export type InviteCodesQueryResponse = Exclude<
|
||||
export function useInviteCodesQuery() {
|
||||
return useQuery({
|
||||
staleTime: STALE.MINUTES.FIVE,
|
||||
queryKey: ['inviteCodes'],
|
||||
queryKey: [inviteCodesQueryKeyRoot],
|
||||
queryFn: async () => {
|
||||
const res = await getAgent()
|
||||
.com.atproto.server.getAccountInviteCodes({})
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import {z} from 'zod'
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||
import {labelersDetailedInfoQueryKeyRoot} from '#/lib/react-query'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export const labelerInfoQueryKey = (did: string) => ['labeler-info', did]
|
||||
export const labelersInfoQueryKey = (dids: string[]) => [
|
||||
'labelers-info',
|
||||
dids.sort(),
|
||||
const labelerInfoQueryKeyRoot = 'labeler-info'
|
||||
export const labelerInfoQueryKey = (did: string) => [
|
||||
labelerInfoQueryKeyRoot,
|
||||
did,
|
||||
]
|
||||
|
||||
const labelersInfoQueryKeyRoot = 'labelers-info'
|
||||
export const labelersInfoQueryKey = (dids: string[]) => [
|
||||
labelersInfoQueryKeyRoot,
|
||||
dids.slice().sort(),
|
||||
]
|
||||
|
||||
export const labelersDetailedInfoQueryKey = (dids: string[]) => [
|
||||
'labelers-detailed-info',
|
||||
labelersDetailedInfoQueryKeyRoot,
|
||||
dids,
|
||||
]
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import {AppBskyActorDefs, AppBskyGraphGetList} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
export const RQKEY = (uri: string) => ['list-members', uri]
|
||||
const RQKEY_ROOT = 'list-members'
|
||||
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
|
||||
|
||||
export function useListMembersQuery(uri: string) {
|
||||
return useInfiniteQuery<
|
||||
@@ -44,7 +45,7 @@ export function* findAllProfilesInQueryData(
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<AppBskyGraphGetList.OutputSchema>
|
||||
>({
|
||||
queryKey: ['list-members'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData) {
|
||||
|
||||
@@ -17,16 +17,17 @@
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useSession, getAgent} from '#/state/session'
|
||||
import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members'
|
||||
import {getAgent, useSession} from '#/state/session'
|
||||
|
||||
// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records
|
||||
const SANITY_PAGE_LIMIT = 1000
|
||||
const PAGE_SIZE = 100
|
||||
// ...which comes 100,000k list members
|
||||
|
||||
export const RQKEY = () => ['list-memberships']
|
||||
const RQKEY_ROOT = 'list-memberships'
|
||||
export const RQKEY = () => [RQKEY_ROOT]
|
||||
|
||||
export interface ListMembersip {
|
||||
membershipUri: string
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {
|
||||
AtUri,
|
||||
AppBskyGraphDefs,
|
||||
AppBskyGraphGetList,
|
||||
AppBskyGraphList,
|
||||
AppBskyGraphDefs,
|
||||
AtUri,
|
||||
Facet,
|
||||
} from '@atproto/api'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import chunk from 'lodash.chunk'
|
||||
import {useSession, getAgent} from '../session'
|
||||
import {invalidate as invalidateMyLists} from './my-lists'
|
||||
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
|
||||
|
||||
import {uploadBlob} from '#/lib/api'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {getAgent, useSession} from '../session'
|
||||
import {invalidate as invalidateMyLists} from './my-lists'
|
||||
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
|
||||
|
||||
export const RQKEY = (uri: string) => ['list', uri]
|
||||
const RQKEY_ROOT = 'list'
|
||||
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
|
||||
|
||||
export function useListQuery(uri?: string) {
|
||||
return useQuery<AppBskyGraphDefs.ListView, Error>({
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import {AppBskyActorDefs, AppBskyGraphGetBlocks} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export const RQKEY = () => ['my-blocked-accounts']
|
||||
const RQKEY_ROOT = 'my-blocked-accounts'
|
||||
export const RQKEY = () => [RQKEY_ROOT]
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
export function useMyBlockedAccountsQuery() {
|
||||
@@ -39,7 +40,7 @@ export function* findAllProfilesInQueryData(
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<AppBskyGraphGetBlocks.OutputSchema>
|
||||
>({
|
||||
queryKey: ['my-blocked-accounts'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {useSession, getAgent} from '../session'
|
||||
|
||||
import {STALE} from '#/state/queries'
|
||||
import {getAgent, useSession} from '../session'
|
||||
|
||||
// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records
|
||||
const SANITY_PAGE_LIMIT = 1000
|
||||
const PAGE_SIZE = 100
|
||||
// ...which comes 10,000k follows
|
||||
|
||||
export const RQKEY = () => ['my-follows']
|
||||
const RQKEY_ROOT = 'my-follows'
|
||||
export const RQKEY = () => [RQKEY_ROOT]
|
||||
|
||||
export function useMyFollowsQuery() {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import {AppBskyGraphDefs} from '@atproto/api'
|
||||
import {useQuery, QueryClient} from '@tanstack/react-query'
|
||||
import {QueryClient, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {accumulate} from '#/lib/async/accumulate'
|
||||
import {useSession, getAgent} from '#/state/session'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {getAgent, useSession} from '#/state/session'
|
||||
|
||||
export type MyListsFilter =
|
||||
| 'all'
|
||||
| 'curate'
|
||||
| 'mod'
|
||||
| 'all-including-subscribed'
|
||||
export const RQKEY = (filter: MyListsFilter) => ['my-lists', filter]
|
||||
|
||||
const RQKEY_ROOT = 'my-lists'
|
||||
export const RQKEY = (filter: MyListsFilter) => [RQKEY_ROOT, filter]
|
||||
|
||||
export function useMyListsQuery(filter: MyListsFilter) {
|
||||
const {currentAccount} = useSession()
|
||||
@@ -91,6 +93,6 @@ export function invalidate(qc: QueryClient, filter?: MyListsFilter) {
|
||||
if (filter) {
|
||||
qc.invalidateQueries({queryKey: RQKEY(filter)})
|
||||
} else {
|
||||
qc.invalidateQueries({queryKey: ['my-lists']})
|
||||
qc.invalidateQueries({queryKey: [RQKEY_ROOT]})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import {AppBskyActorDefs, AppBskyGraphGetMutes} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export const RQKEY = () => ['my-muted-accounts']
|
||||
const RQKEY_ROOT = 'my-muted-accounts'
|
||||
export const RQKEY = () => [RQKEY_ROOT]
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
export function useMyMutedAccountsQuery() {
|
||||
@@ -39,7 +40,7 @@ export function* findAllProfilesInQueryData(
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<AppBskyGraphGetMutes.OutputSchema>
|
||||
>({
|
||||
queryKey: ['my-muted-accounts'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
|
||||
@@ -19,28 +19,30 @@
|
||||
import {useEffect, useRef} from 'react'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
useQueryClient,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import {useModerationOpts} from '../preferences'
|
||||
import {useUnreadNotificationsApi} from './unread'
|
||||
import {fetchPage} from './util'
|
||||
import {FeedPage} from './types'
|
||||
|
||||
import {useMutedThreads} from '#/state/muted-threads'
|
||||
import {STALE} from '..'
|
||||
import {useModerationOpts} from '../preferences'
|
||||
import {embedViewRecordToPostView, getEmbeddedPost} from '../util'
|
||||
import {FeedPage} from './types'
|
||||
import {useUnreadNotificationsApi} from './unread'
|
||||
import {fetchPage} from './util'
|
||||
|
||||
export type {NotificationType, FeedNotification, FeedPage} from './types'
|
||||
export type {FeedNotification, FeedPage, NotificationType} from './types'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
const RQKEY_ROOT = 'notification-feed'
|
||||
export function RQKEY() {
|
||||
return ['notification-feed']
|
||||
return [RQKEY_ROOT]
|
||||
}
|
||||
|
||||
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||
@@ -138,7 +140,7 @@ export function* findAllPostsInQueryData(
|
||||
uri: string,
|
||||
): Generator<AppBskyFeedDefs.PostView, void> {
|
||||
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
|
||||
queryKey: ['notification-feed'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
|
||||
@@ -58,8 +58,9 @@ export interface FeedParams {
|
||||
|
||||
type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
|
||||
|
||||
const RQKEY_ROOT = 'post-feed'
|
||||
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
|
||||
return ['post-feed', feedDesc, params || {}]
|
||||
return [RQKEY_ROOT, feedDesc, params || {}]
|
||||
}
|
||||
|
||||
export interface FeedPostSliceItem {
|
||||
@@ -402,7 +403,7 @@ export function* findAllPostsInQueryData(
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<FeedPageUnselected>
|
||||
>({
|
||||
queryKey: ['post-feed'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
@@ -467,7 +468,7 @@ export function resetProfilePostsQueries(
|
||||
queryClient.resetQueries({
|
||||
predicate: query =>
|
||||
!!(
|
||||
query.queryKey[0] === 'post-feed' &&
|
||||
query.queryKey[0] === RQKEY_ROOT &&
|
||||
(query.queryKey[1] as string)?.includes(did)
|
||||
),
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {AppBskyActorDefs, AppBskyFeedGetLikes} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
@@ -12,7 +12,8 @@ const PAGE_SIZE = 30
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
// TODO refactor invalidate on mutate?
|
||||
export const RQKEY = (resolvedUri: string) => ['liked-by', resolvedUri]
|
||||
const RQKEY_ROOT = 'liked-by'
|
||||
export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri]
|
||||
|
||||
export function useLikedByQuery(resolvedUri: string | undefined) {
|
||||
return useInfiniteQuery<
|
||||
@@ -44,7 +45,7 @@ export function* findAllProfilesInQueryData(
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<AppBskyFeedGetLikes.OutputSchema>
|
||||
>({
|
||||
queryKey: ['post-liked-by'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {AppBskyActorDefs, AppBskyFeedGetRepostedBy} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
@@ -12,7 +12,8 @@ const PAGE_SIZE = 30
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
// TODO refactor invalidate on mutate?
|
||||
export const RQKEY = (resolvedUri: string) => ['post-reposted-by', resolvedUri]
|
||||
const RQKEY_ROOT = 'post-reposted-by'
|
||||
export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri]
|
||||
|
||||
export function usePostRepostedByQuery(resolvedUri: string | undefined) {
|
||||
return useInfiniteQuery<
|
||||
@@ -44,7 +45,7 @@ export function* findAllProfilesInQueryData(
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<AppBskyFeedGetRepostedBy.OutputSchema>
|
||||
>({
|
||||
queryKey: ['post-reposted-by'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedGetPostThread,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetPostThread,
|
||||
AppBskyFeedPost,
|
||||
} from '@atproto/api'
|
||||
import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
|
||||
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
|
||||
import {precacheThreadPostProfiles} from './profile'
|
||||
import {getEmbeddedPost} from './util'
|
||||
|
||||
export const RQKEY = (uri: string) => ['post-thread', uri]
|
||||
const RQKEY_ROOT = 'post-thread'
|
||||
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
|
||||
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
|
||||
|
||||
export interface ThreadCtx {
|
||||
@@ -233,7 +234,7 @@ export function* findAllPostsInQueryData(
|
||||
uri: string,
|
||||
): Generator<ThreadNode, void> {
|
||||
const queryDatas = queryClient.getQueriesData<ThreadNode>({
|
||||
queryKey: ['post-thread'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData) {
|
||||
|
||||
@@ -9,7 +9,8 @@ import {updatePostShadow} from '#/state/cache/post-shadow'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export const RQKEY = (postUri: string) => ['post', postUri]
|
||||
const RQKEY_ROOT = 'post'
|
||||
export const RQKEY = (postUri: string) => [RQKEY_ROOT, postUri]
|
||||
|
||||
export function usePostQuery(uri: string | undefined) {
|
||||
return useQuery<AppBskyFeedDefs.PostView>({
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
import {useMemo, createContext, useContext} from 'react'
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {
|
||||
LabelPreference,
|
||||
BskyFeedViewPreference,
|
||||
ModerationOpts,
|
||||
AppBskyActorDefs,
|
||||
BSKY_LABELER_DID,
|
||||
BskyFeedViewPreference,
|
||||
LabelPreference,
|
||||
ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {getAgent, useSession} from '#/state/session'
|
||||
import {
|
||||
UsePreferencesQueryResponse,
|
||||
ThreadViewPreferences,
|
||||
} from '#/state/queries/preferences/types'
|
||||
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {
|
||||
DEFAULT_HOME_FEED_PREFS,
|
||||
DEFAULT_THREAD_VIEW_PREFS,
|
||||
DEFAULT_LOGGED_OUT_PREFERENCES,
|
||||
DEFAULT_THREAD_VIEW_PREFS,
|
||||
} from '#/state/queries/preferences/const'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
|
||||
import {
|
||||
ThreadViewPreferences,
|
||||
UsePreferencesQueryResponse,
|
||||
} from '#/state/queries/preferences/types'
|
||||
import {getAgent, useSession} from '#/state/session'
|
||||
import {saveLabelers} from '#/state/session/agent-config'
|
||||
|
||||
export * from '#/state/queries/preferences/types'
|
||||
export * from '#/state/queries/preferences/moderation'
|
||||
export * from '#/state/queries/preferences/const'
|
||||
export * from '#/state/queries/preferences/moderation'
|
||||
export * from '#/state/queries/preferences/types'
|
||||
|
||||
export const preferencesQueryKey = ['getPreferences']
|
||||
const preferencesQueryKeyRoot = 'getPreferences'
|
||||
export const preferencesQueryKey = [preferencesQueryKeyRoot]
|
||||
|
||||
export function usePreferencesQuery() {
|
||||
return useQuery({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AppBskyFeedGetActorFeeds} from '@atproto/api'
|
||||
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
|
||||
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
@@ -7,7 +7,8 @@ const PAGE_SIZE = 30
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
// TODO refactor invalidate on mutate?
|
||||
export const RQKEY = (did: string) => ['profile-feedgens', did]
|
||||
const RQKEY_ROOT = 'profile-feedgens'
|
||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||
|
||||
export function useProfileFeedgensQuery(
|
||||
did: string,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {AppBskyActorDefs, AppBskyGraphGetFollowers} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
@@ -11,7 +11,8 @@ import {getAgent} from '#/state/session'
|
||||
const PAGE_SIZE = 30
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
export const RQKEY = (did: string) => ['profile-followers', did]
|
||||
const RQKEY_ROOT = 'profile-followers'
|
||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||
|
||||
export function useProfileFollowersQuery(did: string | undefined) {
|
||||
return useInfiniteQuery<
|
||||
@@ -43,7 +44,7 @@ export function* findAllProfilesInQueryData(
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<AppBskyGraphGetFollowers.OutputSchema>
|
||||
>({
|
||||
queryKey: ['profile-followers'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import {AppBskyActorDefs, AppBskyGraphGetFollows} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
// TODO refactor invalidate on mutate?
|
||||
export const RQKEY = (did: string) => ['profile-follows', did]
|
||||
const RQKEY_ROOT = 'profile-follows'
|
||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||
|
||||
export function useProfileFollowsQuery(did: string | undefined) {
|
||||
return useInfiniteQuery<
|
||||
@@ -46,7 +47,7 @@ export function* findAllProfilesInQueryData(
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<AppBskyGraphGetFollows.OutputSchema>
|
||||
>({
|
||||
queryKey: ['profile-follows'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {AppBskyGraphGetLists} from '@atproto/api'
|
||||
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
|
||||
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
export const RQKEY = (did: string) => ['profile-lists', did]
|
||||
const RQKEY_ROOT = 'profile-lists'
|
||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||
|
||||
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
||||
const enabled = opts?.enabled !== false
|
||||
|
||||
@@ -30,10 +30,18 @@ import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
|
||||
import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
|
||||
import {ThreadNode} from './post-thread'
|
||||
|
||||
export const RQKEY = (did: string) => ['profile', did]
|
||||
export const profilesQueryKey = (handles: string[]) => ['profiles', handles]
|
||||
const RQKEY_ROOT = 'profile'
|
||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||
|
||||
const profilesQueryKeyRoot = 'profiles'
|
||||
export const profilesQueryKey = (handles: string[]) => [
|
||||
profilesQueryKeyRoot,
|
||||
handles,
|
||||
]
|
||||
|
||||
const profileBasicQueryKeyRoot = 'profileBasic'
|
||||
export const profileBasicQueryKey = (didOrHandle: string) => [
|
||||
'profileBasic',
|
||||
profileBasicQueryKeyRoot,
|
||||
didOrHandle,
|
||||
]
|
||||
|
||||
@@ -511,7 +519,7 @@ export function* findAllProfilesInQueryData(
|
||||
): Generator<AppBskyActorDefs.ProfileViewDetailed, void> {
|
||||
const queryDatas =
|
||||
queryClient.getQueriesData<AppBskyActorDefs.ProfileViewDetailed>({
|
||||
queryKey: ['profile'],
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {AppBskyActorDefs, AtUri} from '@atproto/api'
|
||||
import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query'
|
||||
import {AtUri, AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
import {profileBasicQueryKey as RQKEY_PROFILE_BASIC} from './profile'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {profileBasicQueryKey as RQKEY_PROFILE_BASIC} from './profile'
|
||||
|
||||
export const RQKEY = (didOrHandle: string) => ['resolved-did', didOrHandle]
|
||||
const RQKEY_ROOT = 'resolved-did'
|
||||
export const RQKEY = (didOrHandle: string) => [RQKEY_ROOT, didOrHandle]
|
||||
|
||||
type UriUseQueryResult = UseQueryResult<{did: string; uri: string}, Error>
|
||||
export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import {AppBskyFeedDefs, AppBskyFeedSearchPosts} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
|
||||
|
||||
const searchPostsQueryKeyRoot = 'search-posts'
|
||||
const searchPostsQueryKey = ({query}: {query: string}) => [
|
||||
'search-posts',
|
||||
searchPostsQueryKeyRoot,
|
||||
query,
|
||||
]
|
||||
|
||||
@@ -43,7 +44,7 @@ export function* findAllPostsInQueryData(
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<AppBskyFeedSearchPosts.OutputSchema>
|
||||
>({
|
||||
queryKey: ['search-posts'],
|
||||
queryKey: [searchPostsQueryKeyRoot],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
export const RQKEY = (serviceUrl: string) => ['service', serviceUrl]
|
||||
const RQKEY_ROOT = 'service'
|
||||
export const RQKEY = (serviceUrl: string) => [RQKEY_ROOT, serviceUrl]
|
||||
|
||||
export function useServiceQuery(serviceUrl: string) {
|
||||
return useQuery({
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
|
||||
import {AppBskyFeedGetSuggestedFeeds} from '@atproto/api'
|
||||
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export const suggestedFeedsQueryKey = ['suggestedFeeds']
|
||||
const suggestedFeedsQueryKeyRoot = 'suggestedFeeds'
|
||||
export const suggestedFeedsQueryKey = [suggestedFeedsQueryKeyRoot]
|
||||
|
||||
export function useSuggestedFeedsQuery() {
|
||||
return useInfiniteQuery<
|
||||
|
||||
@@ -6,21 +6,24 @@ import {
|
||||
moderateProfile,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useQueryClient,
|
||||
useQuery,
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {useSession, getAgent} from '#/state/session'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {getAgent, useSession} from '#/state/session'
|
||||
|
||||
const suggestedFollowsQueryKey = ['suggested-follows']
|
||||
const suggestedFollowsQueryKeyRoot = 'suggested-follows'
|
||||
const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot]
|
||||
|
||||
const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor'
|
||||
const suggestedFollowsByActorQueryKey = (did: string) => [
|
||||
'suggested-follows-by-actor',
|
||||
suggestedFollowsByActorQueryKeyRoot,
|
||||
did,
|
||||
]
|
||||
|
||||
@@ -125,7 +128,7 @@ function* findAllProfilesInSuggestedFollowsQueryData(
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<AppBskyActorGetSuggestions.OutputSchema>
|
||||
>({
|
||||
queryKey: ['suggested-follows'],
|
||||
queryKey: [suggestedFollowsQueryKeyRoot],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
@@ -148,7 +151,7 @@ function* findAllProfilesInSuggestedFollowsByActorQueryData(
|
||||
const queryDatas =
|
||||
queryClient.getQueriesData<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema>(
|
||||
{
|
||||
queryKey: ['suggested-follows-by-actor'],
|
||||
queryKey: [suggestedFollowsByActorQueryKeyRoot],
|
||||
},
|
||||
)
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
BSKY_LABELER_DID,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {jwtDecode} from 'jwt-decode'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
@@ -178,7 +177,6 @@ function createPersistSessionHandler(
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const queryClient = useQueryClient()
|
||||
const isDirty = React.useRef(false)
|
||||
const [state, setState] = React.useState<SessionState>({
|
||||
isInitialLoad: true,
|
||||
@@ -211,12 +209,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const clearCurrentAccount = React.useCallback(() => {
|
||||
logger.warn(`session: clear current account`)
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
queryClient.clear()
|
||||
setStateAndPersist(s => ({
|
||||
...s,
|
||||
currentAccount: undefined,
|
||||
}))
|
||||
}, [setStateAndPersist, queryClient])
|
||||
}, [setStateAndPersist])
|
||||
|
||||
const createAccount = React.useCallback<ApiContext['createAccount']>(
|
||||
async ({
|
||||
@@ -286,14 +283,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
__globalAgent = agent
|
||||
queryClient.clear()
|
||||
upsertAccount(account)
|
||||
|
||||
logger.debug(`session: created account`, {}, logger.DebugContext.session)
|
||||
track('Create Account')
|
||||
logEvent('account:create:success', {})
|
||||
},
|
||||
[upsertAccount, queryClient, clearCurrentAccount],
|
||||
[upsertAccount, clearCurrentAccount],
|
||||
)
|
||||
|
||||
const login = React.useCallback<ApiContext['login']>(
|
||||
@@ -334,7 +330,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
__globalAgent = agent
|
||||
// @ts-ignore
|
||||
if (IS_DEV && isWeb) window.agent = agent
|
||||
queryClient.clear()
|
||||
upsertAccount(account)
|
||||
|
||||
logger.debug(`session: logged in`, {}, logger.DebugContext.session)
|
||||
@@ -342,7 +337,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
track('Sign In', {resumedSession: false})
|
||||
logEvent('account:loggedIn', {logContext, withPassword: true})
|
||||
},
|
||||
[upsertAccount, queryClient, clearCurrentAccount],
|
||||
[upsertAccount, clearCurrentAccount],
|
||||
)
|
||||
|
||||
const logout = React.useCallback<ApiContext['logout']>(
|
||||
@@ -411,7 +406,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
agent.session = prevSession
|
||||
__globalAgent = agent
|
||||
queryClient.clear()
|
||||
upsertAccount(account)
|
||||
|
||||
if (prevSession.deactivated) {
|
||||
@@ -448,7 +442,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
try {
|
||||
const freshAccount = await resumeSessionWithFreshAccount()
|
||||
__globalAgent = agent
|
||||
queryClient.clear()
|
||||
upsertAccount(freshAccount)
|
||||
} catch (e) {
|
||||
/*
|
||||
@@ -489,7 +482,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[upsertAccount, queryClient, clearCurrentAccount],
|
||||
[upsertAccount, clearCurrentAccount],
|
||||
)
|
||||
|
||||
const resumeSession = React.useCallback<ApiContext['resumeSession']>(
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import React from 'react'
|
||||
import {SafeAreaView, StyleSheet, View} from 'react-native'
|
||||
import {ScrollView} from './util'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers'
|
||||
import {colors, s} from '#/lib/styles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {Button} from '#/view/com/util/forms/Button'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {ScrollView} from './util'
|
||||
|
||||
export const snapPoints = ['50%']
|
||||
|
||||
export function Component({text, href}: {text: string; href: string}) {
|
||||
export function Component({
|
||||
text,
|
||||
href,
|
||||
share,
|
||||
}: {
|
||||
text: string
|
||||
href: string
|
||||
share?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {closeModal} = useModalControls()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
@@ -26,7 +36,11 @@ export function Component({text, href}: {text: string; href: string}) {
|
||||
|
||||
const onPressVisit = () => {
|
||||
closeModal()
|
||||
openLink(href)
|
||||
if (share) {
|
||||
shareUrl(href)
|
||||
} else {
|
||||
openLink(href)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -72,9 +86,13 @@ export function Component({text, href}: {text: string; href: string}) {
|
||||
testID="confirmBtn"
|
||||
type="primary"
|
||||
onPress={onPressVisit}
|
||||
accessibilityLabel={_(msg`Visit Site`)}
|
||||
accessibilityHint={_(msg`Opens the linked website`)}
|
||||
label={_(msg`Visit Site`)}
|
||||
accessibilityLabel={share ? _(msg`Share Link`) : _(msg`Visit Site`)}
|
||||
accessibilityHint={
|
||||
share
|
||||
? _(msg`Shares the linked website`)
|
||||
: _(msg`Opens the linked website`)
|
||||
}
|
||||
label={share ? _(msg`Share Link`) : _(msg`Visit Site`)}
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,6 @@ import * as LinkWarningModal from './LinkWarning'
|
||||
import * as ListAddUserModal from './ListAddRemoveUsers'
|
||||
import * as RepostModal from './Repost'
|
||||
import * as SelfLabelModal from './SelfLabel'
|
||||
import * as SwitchAccountModal from './SwitchAccount'
|
||||
import * as ThreadgateModal from './Threadgate'
|
||||
import * as UserAddRemoveListsModal from './UserAddRemoveLists'
|
||||
import * as VerifyEmailModal from './VerifyEmail'
|
||||
@@ -114,9 +113,6 @@ export function ModalsContainer() {
|
||||
} else if (activeModal?.name === 'change-password') {
|
||||
snapPoints = ChangePasswordModal.snapPoints
|
||||
element = <ChangePasswordModal.Component />
|
||||
} else if (activeModal?.name === 'switch-account') {
|
||||
snapPoints = SwitchAccountModal.snapPoints
|
||||
element = <SwitchAccountModal.Component />
|
||||
} else if (activeModal?.name === 'link-warning') {
|
||||
snapPoints = LinkWarningModal.snapPoints
|
||||
element = <LinkWarningModal.Component {...activeModal} />
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {BottomSheetScrollView} from '@discord/bottom-sheet/src'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {Haptics} from 'lib/haptics'
|
||||
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {s} from 'lib/styles'
|
||||
import {AccountDropdownBtn} from '../util/AccountDropdownBtn'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
|
||||
export const snapPoints = ['40%', '90%']
|
||||
|
||||
function SwitchAccountCard({account}: {account: SessionAccount}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {track} = useAnalytics()
|
||||
const {isSwitchingAccounts, currentAccount} = useSession()
|
||||
const {logout} = useSessionApi()
|
||||
const {data: profile} = useProfileQuery({did: account.did})
|
||||
const isCurrentAccount = account.did === currentAccount?.did
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
const onPressSignout = React.useCallback(() => {
|
||||
track('Settings:SignOutButtonClicked')
|
||||
closeAllActiveElements()
|
||||
// needs to be in timeout or the modal re-opens
|
||||
setTimeout(() => logout('SwitchAccount'), 0)
|
||||
}, [track, logout, closeAllActiveElements])
|
||||
|
||||
const contents = (
|
||||
<View style={[pal.view, styles.linkCard]}>
|
||||
<View style={styles.avi}>
|
||||
<UserAvatar
|
||||
size={40}
|
||||
avatar={profile?.avatar}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<View style={[s.flex1]}>
|
||||
<Text type="md-bold" style={pal.text} numberOfLines={1}>
|
||||
{profile?.displayName || account?.handle}
|
||||
</Text>
|
||||
<Text type="sm" style={pal.textLight} numberOfLines={1}>
|
||||
{account?.handle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isCurrentAccount ? (
|
||||
<TouchableOpacity
|
||||
testID="signOutBtn"
|
||||
onPress={isSwitchingAccounts ? undefined : onPressSignout}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Sign out`)}
|
||||
accessibilityHint={_(
|
||||
msg`Signs ${profile?.displayName} out of Bluesky`,
|
||||
)}>
|
||||
<Text type="lg" style={pal.link}>
|
||||
<Trans>Sign out</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<AccountDropdownBtn account={account} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
|
||||
return isCurrentAccount ? (
|
||||
<Link
|
||||
href={makeProfileLink({
|
||||
did: currentAccount.did,
|
||||
handle: currentAccount.handle,
|
||||
})}
|
||||
title={_(msg`Your profile`)}
|
||||
noFeedback>
|
||||
{contents}
|
||||
</Link>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
testID={`switchToAccountBtn-${account.handle}`}
|
||||
key={account.did}
|
||||
style={[isSwitchingAccounts && styles.dimmed]}
|
||||
onPress={
|
||||
isSwitchingAccounts
|
||||
? undefined
|
||||
: () => onPressSwitchAccount(account, 'SwitchAccount')
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Switch to ${account.handle}`)}
|
||||
accessibilityHint={_(msg`Switches the account you are logged in to`)}>
|
||||
{contents}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
export function Component({}: {}) {
|
||||
const pal = usePalette('default')
|
||||
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
|
||||
|
||||
React.useEffect(() => {
|
||||
Haptics.default()
|
||||
})
|
||||
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
style={[styles.container, pal.view]}
|
||||
contentContainerStyle={[styles.innerContainer, pal.view]}>
|
||||
<Text type="title-xl" style={[styles.title, pal.text]}>
|
||||
<Trans>Switch Account</Trans>
|
||||
</Text>
|
||||
|
||||
{isSwitchingAccounts || !currentAccount ? (
|
||||
<View style={[pal.view, styles.linkCard]}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<SwitchAccountCard account={currentAccount} />
|
||||
)}
|
||||
|
||||
{accounts
|
||||
.filter(a => a.did !== currentAccount?.did)
|
||||
.map(account => (
|
||||
<SwitchAccountCard key={account.did} account={account} />
|
||||
))}
|
||||
</BottomSheetScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
innerContainer: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginTop: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
linkCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 18,
|
||||
marginBottom: 1,
|
||||
},
|
||||
avi: {
|
||||
marginRight: 12,
|
||||
},
|
||||
dimmed: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
})
|
||||
@@ -368,47 +368,52 @@ export function PostThread({
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
if (error || !thread) {
|
||||
return (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={(!preferences || !thread) && !error}
|
||||
isError={!!error}
|
||||
noEmpty
|
||||
onRetry={refetch}
|
||||
errorTitle={error?.title}
|
||||
errorMessage={error?.message}
|
||||
/>
|
||||
{!error && thread && (
|
||||
<List
|
||||
ref={ref}
|
||||
data={posts}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
|
||||
onStartReached={onStartReached}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={2}
|
||||
onMomentumScrollEnd={onMomentumScrollEnd}
|
||||
onScrollToTop={onScrollToTop}
|
||||
maintainVisibleContentPosition={
|
||||
isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
|
||||
}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
removeClippedSubviews={isAndroid ? false : undefined}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetching={isFetching}
|
||||
onRetry={refetch}
|
||||
// 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
|
||||
// work without causing weird jumps on web or glitches on native
|
||||
height={windowHeight - 200}
|
||||
/>
|
||||
}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
ref={ref}
|
||||
data={posts}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
|
||||
onStartReached={onStartReached}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={2}
|
||||
onMomentumScrollEnd={onMomentumScrollEnd}
|
||||
onScrollToTop={onScrollToTop}
|
||||
maintainVisibleContentPosition={
|
||||
isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
|
||||
}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
removeClippedSubviews={isAndroid ? false : undefined}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
// Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on
|
||||
// initial render
|
||||
isFetchingNextPage={isFetching}
|
||||
error={cleanError(threadError)}
|
||||
onRetry={refetch}
|
||||
// 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
|
||||
// work without causing weird jumps on web or glitches on native
|
||||
height={windowHeight - 200}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||
import {List} from '../util/List'
|
||||
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
|
||||
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
||||
import {logger} from '#/logger'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {useSession} from 'state/session'
|
||||
import {
|
||||
ListFooter,
|
||||
ListHeaderDesktop,
|
||||
ListMaybePlaceholder,
|
||||
} from '#/components/Lists'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSession} from 'state/session'
|
||||
import {View} from 'react-native'
|
||||
import {List} from '../util/List'
|
||||
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
||||
|
||||
function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) {
|
||||
return <ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
@@ -39,7 +39,6 @@ export function ProfileFollowers({name}: {name: string}) {
|
||||
const {
|
||||
data,
|
||||
isLoading: isFollowersLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
@@ -47,14 +46,8 @@ export function ProfileFollowers({name}: {name: string}) {
|
||||
refetch,
|
||||
} = useProfileFollowersQuery(resolvedDid)
|
||||
|
||||
const isError = React.useMemo(
|
||||
() => !!resolveError || !!error,
|
||||
[resolveError, error],
|
||||
)
|
||||
|
||||
const isMe = React.useMemo(() => {
|
||||
return resolvedDid === currentAccount?.did
|
||||
}, [resolvedDid, currentAccount?.did])
|
||||
const isError = !!resolveError || !!error
|
||||
const isMe = resolvedDid === currentAccount?.did
|
||||
|
||||
const followers = React.useMemo(() => {
|
||||
if (data?.pages) {
|
||||
@@ -73,20 +66,19 @@ export function ProfileFollowers({name}: {name: string}) {
|
||||
setIsPTRing(false)
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = async () => {
|
||||
if (isFetching || !hasNextPage || !!error) return
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetchingNextPage || !hasNextPage || !!error) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more followers', {message: err})
|
||||
}
|
||||
}
|
||||
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
if (followers.length < 1) {
|
||||
return (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isDidLoading || isFollowersLoading}
|
||||
isEmpty={followers.length < 1}
|
||||
isError={isError}
|
||||
emptyType="results"
|
||||
emptyMessage={
|
||||
@@ -97,23 +89,30 @@ export function ProfileFollowers({name}: {name: string}) {
|
||||
errorMessage={cleanError(resolveError || error)}
|
||||
onRetry={isError ? refetch : undefined}
|
||||
/>
|
||||
{followers.length > 0 && (
|
||||
<List
|
||||
data={followers}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Followers`)} />}
|
||||
ListFooterComponent={<ListFooter isFetching={isFetchingNextPage} />}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
data={followers}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Followers`)} />}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||
import {List} from '../util/List'
|
||||
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
||||
import {logger} from '#/logger'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {useSession} from 'state/session'
|
||||
import {
|
||||
ListFooter,
|
||||
ListHeaderDesktop,
|
||||
ListMaybePlaceholder,
|
||||
} from '#/components/Lists'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {useSession} from 'state/session'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {List} from '../util/List'
|
||||
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
||||
|
||||
function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) {
|
||||
return <ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
@@ -38,7 +39,6 @@ export function ProfileFollows({name}: {name: string}) {
|
||||
const {
|
||||
data,
|
||||
isLoading: isFollowsLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
@@ -46,14 +46,8 @@ export function ProfileFollows({name}: {name: string}) {
|
||||
refetch,
|
||||
} = useProfileFollowsQuery(resolvedDid)
|
||||
|
||||
const isError = React.useMemo(
|
||||
() => !!resolveError || !!error,
|
||||
[resolveError, error],
|
||||
)
|
||||
|
||||
const isMe = React.useMemo(() => {
|
||||
return resolvedDid === currentAccount?.did
|
||||
}, [resolvedDid, currentAccount?.did])
|
||||
const isError = !!resolveError || !!error
|
||||
const isMe = resolvedDid === currentAccount?.did
|
||||
|
||||
const follows = React.useMemo(() => {
|
||||
if (data?.pages) {
|
||||
@@ -72,20 +66,19 @@ export function ProfileFollows({name}: {name: string}) {
|
||||
setIsPTRing(false)
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = async () => {
|
||||
if (isFetching || !hasNextPage || !!error) return
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetchingNextPage || !hasNextPage || !!error) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more follows', {error: err})
|
||||
}
|
||||
}
|
||||
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
if (follows.length < 1) {
|
||||
return (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isDidLoading || isFollowsLoading}
|
||||
isEmpty={follows.length < 1}
|
||||
isError={isError}
|
||||
emptyType="results"
|
||||
emptyMessage={
|
||||
@@ -96,23 +89,30 @@ export function ProfileFollows({name}: {name: string}) {
|
||||
errorMessage={cleanError(resolveError || error)}
|
||||
onRetry={isError ? refetch : undefined}
|
||||
/>
|
||||
{follows.length > 0 && (
|
||||
<List
|
||||
data={follows}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Following`)} />}
|
||||
ListFooterComponent={<ListFooter isFetching={isFetchingNextPage} />}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
data={follows}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Following`)} />}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import {Text as RNText, TextProps} from 'react-native'
|
||||
import {s, lh} from 'lib/styles'
|
||||
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
||||
import {UITextView} from 'react-native-uitextview'
|
||||
|
||||
import {lh, s} from 'lib/styles'
|
||||
import {TypographyVariant, useTheme} from 'lib/ThemeContext'
|
||||
import {isIOS, isWeb} from 'platform/detection'
|
||||
import {UITextView} from 'react-native-ui-text-view'
|
||||
|
||||
export type CustomTextProps = TextProps & {
|
||||
type?: TypographyVariant
|
||||
@@ -36,6 +37,8 @@ export function Text({
|
||||
return (
|
||||
<UITextView
|
||||
style={[s.black, typography, lineHeightStyle, style]}
|
||||
selectable={selectable}
|
||||
uiTextView
|
||||
{...props}>
|
||||
{children}
|
||||
</UITextView>
|
||||
|
||||
@@ -1,47 +1,49 @@
|
||||
import React, {ComponentProps} from 'react'
|
||||
import {GestureResponderEvent, TouchableOpacity, View} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
import {BottomTabBarProps} from '@react-navigation/bottom-tabs'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {clamp} from 'lib/numbers'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {BottomTabBarProps} from '@react-navigation/bottom-tabs'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {Haptics} from '#/lib/haptics'
|
||||
import {useDedupe} from '#/lib/hooks/useDedupe'
|
||||
import {useMinimalShellMode} from '#/lib/hooks/useMinimalShellMode'
|
||||
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {
|
||||
BellIcon,
|
||||
BellIconSolid,
|
||||
HashtagIcon,
|
||||
HomeIcon,
|
||||
HomeIconSolid,
|
||||
MagnifyingGlassIcon2,
|
||||
MagnifyingGlassIcon2Solid,
|
||||
HashtagIcon,
|
||||
BellIcon,
|
||||
BellIconSolid,
|
||||
} from 'lib/icons'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {getTabState, TabState} from 'lib/routes/helpers'
|
||||
import {styles} from './BottomBarStyles'
|
||||
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
||||
import {useNavigationTabState} from 'lib/hooks/useNavigationTabState'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
} from '#/lib/icons'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {getTabState, TabState} from '#/lib/routes/helpers'
|
||||
import {s} from '#/lib/styles'
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {Button} from '#/view/com/util/forms/Button'
|
||||
import {s} from 'lib/styles'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {useDedupe} from 'lib/hooks/useDedupe'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
|
||||
import {styles} from './BottomBarStyles'
|
||||
|
||||
type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
|
||||
|
||||
export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
const {openModal} = useModalControls()
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
@@ -56,6 +58,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const dedupe = useDedupe()
|
||||
const accountSwitchControl = useDialogControl()
|
||||
|
||||
const showSignIn = React.useCallback(() => {
|
||||
closeAllActiveElements()
|
||||
@@ -99,204 +102,213 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
const onPressProfile = React.useCallback(() => {
|
||||
onPressTab('MyProfile')
|
||||
}, [onPressTab])
|
||||
|
||||
const onLongPressProfile = React.useCallback(() => {
|
||||
openModal({name: 'switch-account'})
|
||||
}, [openModal])
|
||||
Haptics.default()
|
||||
accountSwitchControl.open()
|
||||
}, [accountSwitchControl])
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
pal.view,
|
||||
pal.border,
|
||||
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
onLayout={e => {
|
||||
footerHeight.value = e.nativeEvent.layout.height
|
||||
}}>
|
||||
{hasSession ? (
|
||||
<>
|
||||
<Btn
|
||||
testID="bottomBarHomeBtn"
|
||||
icon={
|
||||
isAtHome ? (
|
||||
<HomeIconSolid
|
||||
strokeWidth={4}
|
||||
size={24}
|
||||
style={[styles.ctrlIcon, pal.text, styles.homeIcon]}
|
||||
/>
|
||||
) : (
|
||||
<HomeIcon
|
||||
strokeWidth={4}
|
||||
size={24}
|
||||
style={[styles.ctrlIcon, pal.text, styles.homeIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={onPressHome}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={_(msg`Home`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<Btn
|
||||
testID="bottomBarSearchBtn"
|
||||
icon={
|
||||
isAtSearch ? (
|
||||
<MagnifyingGlassIcon2Solid
|
||||
size={25}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
) : (
|
||||
<MagnifyingGlassIcon2
|
||||
size={25}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={onPressSearch}
|
||||
accessibilityRole="search"
|
||||
accessibilityLabel={_(msg`Search`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<Btn
|
||||
testID="bottomBarFeedsBtn"
|
||||
icon={
|
||||
isAtFeeds ? (
|
||||
<HashtagIcon
|
||||
size={24}
|
||||
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
) : (
|
||||
<HashtagIcon
|
||||
size={24}
|
||||
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
|
||||
strokeWidth={2.25}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={onPressFeeds}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={_(msg`Feeds`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<Btn
|
||||
testID="bottomBarNotificationsBtn"
|
||||
icon={
|
||||
isAtNotifications ? (
|
||||
<BellIconSolid
|
||||
size={24}
|
||||
strokeWidth={1.9}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
|
||||
/>
|
||||
) : (
|
||||
<BellIcon
|
||||
size={24}
|
||||
strokeWidth={1.9}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={onPressNotifications}
|
||||
notificationCount={numUnreadNotifications}
|
||||
accessible={true}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={_(msg`Notifications`)}
|
||||
accessibilityHint={
|
||||
numUnreadNotifications === ''
|
||||
? ''
|
||||
: `${numUnreadNotifications} unread`
|
||||
}
|
||||
/>
|
||||
<Btn
|
||||
testID="bottomBarProfileBtn"
|
||||
icon={
|
||||
<View style={styles.ctrlIconSizingWrapper}>
|
||||
{isAtMyProfile ? (
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
pal.text,
|
||||
styles.profileIcon,
|
||||
styles.onProfile,
|
||||
{borderColor: pal.text.color},
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={27}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<>
|
||||
<SwitchAccountDialog control={accountSwitchControl} />
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
pal.view,
|
||||
pal.border,
|
||||
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
onLayout={e => {
|
||||
footerHeight.value = e.nativeEvent.layout.height
|
||||
}}>
|
||||
{hasSession ? (
|
||||
<>
|
||||
<Btn
|
||||
testID="bottomBarHomeBtn"
|
||||
icon={
|
||||
isAtHome ? (
|
||||
<HomeIconSolid
|
||||
strokeWidth={4}
|
||||
size={24}
|
||||
style={[styles.ctrlIcon, pal.text, styles.homeIcon]}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.ctrlIcon, pal.text, styles.profileIcon]}>
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={28}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<HomeIcon
|
||||
strokeWidth={4}
|
||||
size={24}
|
||||
style={[styles.ctrlIcon, pal.text, styles.homeIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={onPressHome}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={_(msg`Home`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<Btn
|
||||
testID="bottomBarSearchBtn"
|
||||
icon={
|
||||
isAtSearch ? (
|
||||
<MagnifyingGlassIcon2Solid
|
||||
size={25}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
) : (
|
||||
<MagnifyingGlassIcon2
|
||||
size={25}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={onPressSearch}
|
||||
accessibilityRole="search"
|
||||
accessibilityLabel={_(msg`Search`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<Btn
|
||||
testID="bottomBarFeedsBtn"
|
||||
icon={
|
||||
isAtFeeds ? (
|
||||
<HashtagIcon
|
||||
size={24}
|
||||
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
) : (
|
||||
<HashtagIcon
|
||||
size={24}
|
||||
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
|
||||
strokeWidth={2.25}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={onPressFeeds}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={_(msg`Feeds`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<Btn
|
||||
testID="bottomBarNotificationsBtn"
|
||||
icon={
|
||||
isAtNotifications ? (
|
||||
<BellIconSolid
|
||||
size={24}
|
||||
strokeWidth={1.9}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
|
||||
/>
|
||||
) : (
|
||||
<BellIcon
|
||||
size={24}
|
||||
strokeWidth={1.9}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={onPressNotifications}
|
||||
notificationCount={numUnreadNotifications}
|
||||
accessible={true}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={_(msg`Notifications`)}
|
||||
accessibilityHint={
|
||||
numUnreadNotifications === ''
|
||||
? ''
|
||||
: `${numUnreadNotifications} unread`
|
||||
}
|
||||
/>
|
||||
<Btn
|
||||
testID="bottomBarProfileBtn"
|
||||
icon={
|
||||
<View style={styles.ctrlIconSizingWrapper}>
|
||||
{isAtMyProfile ? (
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
pal.text,
|
||||
styles.profileIcon,
|
||||
styles.onProfile,
|
||||
{borderColor: pal.text.color},
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={27}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={[styles.ctrlIcon, pal.text, styles.profileIcon]}>
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={28}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
onPress={onPressProfile}
|
||||
onLongPress={onLongPressProfile}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={_(msg`Profile`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 14,
|
||||
paddingBottom: 2,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 6,
|
||||
gap: 8,
|
||||
}}>
|
||||
<View
|
||||
style={{flexDirection: 'row', alignItems: 'center', gap: 8}}>
|
||||
<Logo width={28} />
|
||||
<View style={{paddingTop: 4}}>
|
||||
<Logotype width={80} fill={pal.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
onPress={onPressProfile}
|
||||
onLongPress={onLongPressProfile}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={_(msg`Profile`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 14,
|
||||
paddingBottom: 2,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 6,
|
||||
gap: 8,
|
||||
}}>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 8}}>
|
||||
<Logo width={28} />
|
||||
<View style={{paddingTop: 4}}>
|
||||
<Logotype width={80} fill={pal.text.color} />
|
||||
|
||||
<View
|
||||
style={{flexDirection: 'row', alignItems: 'center', gap: 4}}>
|
||||
<Button
|
||||
onPress={showCreateAccount}
|
||||
accessibilityHint={_(msg`Sign up`)}
|
||||
accessibilityLabel={_(msg`Sign up`)}>
|
||||
<Text type="md" style={[{color: 'white'}, s.bold]}>
|
||||
<Trans>Sign up</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="default"
|
||||
onPress={showSignIn}
|
||||
accessibilityHint={_(msg`Sign in`)}
|
||||
accessibilityLabel={_(msg`Sign in`)}>
|
||||
<Text type="md" style={[pal.text, s.bold]}>
|
||||
<Trans>Sign in</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 4}}>
|
||||
<Button
|
||||
onPress={showCreateAccount}
|
||||
accessibilityHint={_(msg`Sign up`)}
|
||||
accessibilityLabel={_(msg`Sign up`)}>
|
||||
<Text type="md" style={[{color: 'white'}, s.bold]}>
|
||||
<Trans>Sign up</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="default"
|
||||
onPress={showSignIn}
|
||||
accessibilityHint={_(msg`Sign in`)}
|
||||
accessibilityLabel={_(msg`Sign in`)}>
|
||||
<Text type="md" style={[pal.text, s.bold]}>
|
||||
<Trans>Sign in</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const createExpoWebpackConfigAsync = require('@expo/webpack-config')
|
||||
const {withAlias} = require('@expo/webpack-config/addons')
|
||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin')
|
||||
const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer')
|
||||
|
||||
const GENERATE_STATS = process.env.EXPO_PUBLIC_GENERATE_STATS === '1'
|
||||
const OPEN_ANALYZER = process.env.EXPO_PUBLIC_OPEN_ANALYZER === '1'
|
||||
|
||||
const reactNativeWebWebviewConfiguration = {
|
||||
test: /postMock.html$/,
|
||||
@@ -26,5 +30,17 @@ module.exports = async function (env, argv) {
|
||||
if (env.mode === 'development') {
|
||||
config.plugins.push(new ReactRefreshWebpackPlugin())
|
||||
}
|
||||
|
||||
if (GENERATE_STATS || OPEN_ANALYZER) {
|
||||
config.plugins.push(
|
||||
new BundleAnalyzerPlugin({
|
||||
openAnalyzer: OPEN_ANALYZER,
|
||||
generateStatsFile: true,
|
||||
statsFilename: '../stats.json',
|
||||
analyzerMode: OPEN_ANALYZER ? 'server' : 'json',
|
||||
defaultSizes: 'parsed',
|
||||
}),
|
||||
)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user