Compare commits

...

31 Commits

Author SHA1 Message Date
Eric Bailey fc639f0cdc Button docs 2024-04-04 15:27:58 -05:00
Hailey c190fd58ec replace deprecated term with q in search actor (#3405) 2024-04-04 11:13:11 -07:00
dan b783745b2e Extract query key roots to constants (#3404)
* Extract query key roots to constants

* Dedupe labelers-detailed-info

* Align naming
2024-04-04 18:57:38 +01:00
Paul Frazee b26abf212e Enable tags on profile descriptions (close #3081) (#3397) 2024-04-04 10:52:35 -07:00
surfdude29 8b3017bca0 Alphabetise languages by language code (#3350)
* Alphabetise languages in lingui.config.js

* Alphabetise languages in helpers.ts

* Alphabetise languages in i18n.ts

* Alphabetise languages in i18n.ts

* Alphabetise languages in i18n.web.ts

* Alphabetise languages in languages.ts
2024-04-04 10:51:54 -07:00
Jan-Olof Eriksson 51e939ed3d Bug fixes and some improvements to Finnish localization (#3306)
* Update messages.po

Couple of nasty bugs.

* Update messages.po

Bugs and improvements

* Update messages.po

Hashtag = aihetunniste, not just "tunniste"

* Update messages.po

typo

* Update messages.po

Liked by, tykännyt

* Update messages.po

More translations.

* Update messages.po

* Update messages.po

* Update messages.po

Following - Seurataan

* Update messages.po

one typo

* Update messages.po

line 352 fixed
2024-04-04 10:49:04 -07:00
Samuel Newman 9f657fbace Open share sheet when long pressing link (#3317)
* uitextview use library w/ fixes

bump

bump

multiple uitextview fixes

* bump

* Open share sheet on link long press

* rm package manager field

* add link warning to longpress

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-04-04 10:37:57 -07:00
Hailey 4d28dcc48f rm 'pornography' label wording (#3402)
* rm 'pornography'

* add `adult content` to `REASONSEXUAL`
2024-04-04 10:15:04 -07:00
dan 3e88fdfbc4 Don't mutate array (#3403) 2024-04-04 18:14:44 +01:00
dan 19b1721ef3 Fix profile follow from Likes (#3401) 2024-04-04 18:03:58 +01:00
dan 4cc57f4bfd Lint against strings without wrapping <Text> (#3398)
* Add a rudimentary rule

* Get the rule passing

* Support special-casing text props

* More tests
2024-04-04 17:32:50 +01:00
Hailey 8e393b16f5 Simplify list logic further to prevent misuse (#3334)
* simplify list logic further

more simplification

simplify by removing `isEmpty`

use `isFetchingNextPage` everywhere for clarity

change `isFetching` to `isFetchingNextPage` for clarity

remove some useless `useMemo`s

move `renderItem` and `keyExtractor` out of component

* clean bundle size check

* update deploy

* adjust

* adjust

* one test

* try now

* test it

* done
2024-04-03 20:59:33 -07:00
Hailey b1bd7ab6e3 Add webpack analyzer, PR comments for webpack bundle size (#3383)
try that again 9

try that again 8

try that again 7

try that again 6

try that again 5

try that again 4

try that again 3

try that again 2

try that again

merge base 3

merge base 2

merge base

use latest main commit rather than tag

webpack diff analyzer

use cache v4

use cache

again...

blegh

blegh

try cache again

cache

try again

one more adjutment

adjust

okay again maybe

maybe now?

maybe now

oops again

oops

add diff comments

add open analyzer script

add webpack analyzer
2024-04-03 19:31:29 -07:00
Gildásio Filho 9d9c9095ee Update pt-BR localization to latest version (#3371)
* Update messages.po

* Update messages.po

* Update src/locale/locales/pt-BR/messages.po

Co-authored-by: Gleydson Rodrigues <gleydsonsr@gmail.com>

* Update src/locale/locales/pt-BR/messages.po

Co-authored-by: Gleydson Rodrigues <gleydsonsr@gmail.com>

* Update src/locale/locales/pt-BR/messages.po

Co-authored-by: Gleydson Rodrigues <gleydsonsr@gmail.com>

* Update src/locale/locales/pt-BR/messages.po

Co-authored-by: Gleydson Rodrigues <gleydsonsr@gmail.com>

* Update src/locale/locales/pt-BR/messages.po

Co-authored-by: Gleydson Rodrigues <gleydsonsr@gmail.com>

* Update messages.po

---------

Co-authored-by: Gleydson Rodrigues <gleydsonsr@gmail.com>
2024-04-03 19:22:10 -07:00
Samuel Newman 712768dd8f Use ALF for the account quick switch dialog (#3327)
* Use ALF for account quick switch

* clean up modal type

* add haptics to dialog opening

* move account list to it's own component and share

* make tick slightly darker
2024-04-03 19:18:14 -07:00
Ivan Beà 8cdd8394df Update catalan localization messages.po (#3307)
* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

Apply corrections suggested by @surfdude29, thanks

* Update messages.po

adding @jordimas corrections
2024-04-03 18:53:58 -07:00
dan e51ccb46b8 Scope query client per DID (#3333)
* Move QueryProvider inside the key

* Pull useQueryClient-dependent code down in App.native

* Remove useQueryClient dependency from session provider

* Scope query client per DID
2024-04-04 02:51:10 +01:00
Hima db3cd3e821 Fix & Add: Japanese Translation (6) (#3304)
* Create test.txt

* Delete src/locale/locales/ja/messages.po

* Add files via upload

* Delete src/locale/locales/ja/test.txt

* messages.po を更新

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* messages.po を更新

* messages.po を更新

* messages.po を更新

* messages.po を更新
2024-04-03 18:49:48 -07:00
Minseo Lee af9b961411 Fix magnify icon (#3292)
* Update icons.tsx

* Update icons.tsx
2024-04-03 18:49:14 -07:00
Minseo Lee 7cf22e2047 Update Korean localization (#3281)
* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po
2024-04-03 18:47:04 -07:00
Hailey 7fb117d213 Upgrade UITextView to latest (#3090)
* uitextview use library w/ fixes

bump

bump

multiple uitextview fixes

* bump

* update to latest

* cleanup
2024-04-03 18:05:03 -07:00
Hailey a356b1be1a Work around GitHub false-positive for passing secret to another action (#3396)
* run bundle/deploy on main pushes

* don't fetch from origin if not necessary

* fix false-postive for secret in outputs

rm test

test 'false'

rm test

use in both places

workaround "secret" string

add a manual testflight deployment option for android

add a manual testflight deployment option for android

add a manual testflight deployment option for android

* rm test code
2024-04-03 18:04:48 -07:00
Kevin Scannell cad0590694 Irish PO file thanks to @kingdonncha1.bsky.social (#2924)
* Irish PO file thanks to @kingdonncha1.bsky.social

* feat: add Gaeilge options added by @kscanne into the GUI

---------

Co-authored-by: Kat Gaea <kat.knight@newgaea.net>
2024-04-03 17:32:53 -07:00
Paul Frazee 93fbd91193 Add cursor to 'learn more' on screenhider (close #2871) (#3395) 2024-04-03 17:28:31 -07:00
Paul Frazee 9d5c82d9a8 Merge branch 'atiksoftware-main' into main 2024-04-03 17:15:33 -07:00
Paul Frazee 55e4ea84f8 Add turkish to app languages 2024-04-03 16:50:15 -07:00
dan a32eb96210 Show redbox in DEV on string <View> child for web (#3394) 2024-04-04 00:46:49 +01:00
Paul Frazee a4d4562aba Merge branch 'main' of https://github.com/atiksoftware/social-app into atiksoftware-main 2024-04-03 16:31:13 -07:00
Hailey eda98679df don't fetch from origin if unnecessary when performing OTA deploy (#3392)
* run bundle/deploy on main pushes

* don't fetch from origin if not necessary
2024-04-03 16:27:17 -07:00
Hailey 8e882a4579 run bundle/deploy on main pushes (#3391) 2024-04-03 16:00:52 -07:00
Atiksoftware Corporation 310e5ea35b Create messages.po 2024-02-13 15:02:20 +03:00
101 changed files with 13092 additions and 3800 deletions
+33
View File
@@ -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',
{
+36 -1
View File
@@ -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
+25 -10
View File
@@ -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
+117 -5
View File
@@ -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
+1
View File
@@ -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)
})
+111
View File
@@ -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
}
},
}
}
+7
View File
@@ -0,0 +1,7 @@
'use strict'
module.exports = {
rules: {
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
},
}
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+141
View File
@@ -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>
)
}
+61
View File
@@ -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.
+2 -2
View File
@@ -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'
+47 -49
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+9 -8
View File
@@ -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>,
)
+6 -14
View File
@@ -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}) {
+10 -10
View File
@@ -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,
+61
View File
@@ -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>
)
}
+15 -8
View File
@@ -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
View File
@@ -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>
)
}
+2 -2
View File
@@ -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: {
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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[] = [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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
View File
@@ -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}
/>
}
+7 -111
View File
@@ -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 -17
View File
@@ -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>
+4 -8
View File
@@ -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
+11 -10
View File
@@ -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,
}),
})
+5 -4
View File
@@ -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) {
+3 -2
View File
@@ -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
View File
@@ -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()
+9 -4
View File
@@ -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()
+5 -3
View File
@@ -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({})
+17 -9
View File
@@ -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,
]
+5 -4
View File
@@ -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) {
+4 -3
View File
@@ -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
+10 -8
View File
@@ -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>({
+4 -3
View File
@@ -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) {
+4 -2
View File
@@ -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()
+6 -4
View File
@@ -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]})
}
}
+4 -3
View File
@@ -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) {
+12 -10
View File
@@ -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) {
+4 -3
View File
@@ -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)
),
})
+4 -3
View File
@@ -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) {
+4 -3
View File
@@ -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) {
+9 -8
View File
@@ -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) {
+2 -1
View File
@@ -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>({
+17 -16
View File
@@ -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({
+3 -2
View File
@@ -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,
+4 -3
View File
@@ -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) {
+5 -4
View File
@@ -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) {
+4 -2
View File
@@ -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
+12 -4
View File
@@ -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) {
+5 -4
View File
@@ -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 {
+5 -4
View File
@@ -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) {
+2 -1
View File
@@ -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({
+4 -3
View File
@@ -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<
+12 -9
View File
@@ -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 -11
View File
@@ -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']>(
+32 -14
View File
@@ -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]}
/>
-4
View File
@@ -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} />
-169
View File
@@ -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,
},
})
+38 -33
View File
@@ -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}
/>
)
}
+39 -40
View File
@@ -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}
/>
)
}
+40 -40
View File
@@ -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}
/>
)
}
+6 -3
View File
@@ -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>
+227 -215
View File
@@ -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>
</>
)
}
+16
View File
@@ -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