Compare commits
269 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc639f0cdc | |||
| c190fd58ec | |||
| b783745b2e | |||
| b26abf212e | |||
| 8b3017bca0 | |||
| 51e939ed3d | |||
| 9f657fbace | |||
| 4d28dcc48f | |||
| 3e88fdfbc4 | |||
| 19b1721ef3 | |||
| 4cc57f4bfd | |||
| 8e393b16f5 | |||
| b1bd7ab6e3 | |||
| 9d9c9095ee | |||
| 712768dd8f | |||
| 8cdd8394df | |||
| e51ccb46b8 | |||
| db3cd3e821 | |||
| af9b961411 | |||
| 7cf22e2047 | |||
| 7fb117d213 | |||
| a356b1be1a | |||
| cad0590694 | |||
| 93fbd91193 | |||
| 9d5c82d9a8 | |||
| 55e4ea84f8 | |||
| a32eb96210 | |||
| a4d4562aba | |||
| eda98679df | |||
| 8e882a4579 | |||
| 295a0949f4 | |||
| 6c728f79de | |||
| fc1e30afd6 | |||
| 73df7e53b3 | |||
| 02b2ab4f1f | |||
| b76bf9484d | |||
| 75d6535402 | |||
| 75c9da931c | |||
| 907c7c0586 | |||
| 8de27160f5 | |||
| 67fca733fa | |||
| a45da17c48 | |||
| 0ff7e71ee3 | |||
| 3f79cb3e12 | |||
| 69217f9a09 | |||
| ef24611a1f | |||
| 0baf9f9919 | |||
| d507fa9f38 | |||
| 5b44aa962f | |||
| f04932140a | |||
| b27a0b8c97 | |||
| 772d528145 | |||
| 4ec434926e | |||
| 6f2dfcb502 | |||
| a3eab31bda | |||
| df7f9856bb | |||
| 4d50aac4ee | |||
| e874dd290e | |||
| 5f39ca3187 | |||
| 4ff2bb7aba | |||
| eb0ee6ae1c | |||
| 55fb81867b | |||
| 7503d83eaa | |||
| a8ccc91335 | |||
| 396d183dfc | |||
| ad3dd9f6dc | |||
| c649ee1afa | |||
| d2d4d3a092 | |||
| 6bf97d4fd5 | |||
| 0dbc796ff6 | |||
| 19fab671a3 | |||
| 8ad813cd86 | |||
| 58588efcea | |||
| b0fcfa563d | |||
| 70f5cc6a9f | |||
| 5c08f0d95b | |||
| bcffaffc07 | |||
| aa239451af | |||
| 6e1541f203 | |||
| 94cc08929f | |||
| 1d10946f54 | |||
| 823ce63796 | |||
| 81bc3ed4c3 | |||
| fb17afc99d | |||
| cf9d884b5b | |||
| eacd1cbe2a | |||
| adcd097136 | |||
| 708d0fe3e1 | |||
| a0839723ee | |||
| 6d1307fdc7 | |||
| 4c60d4d070 | |||
| 09e9769eea | |||
| d24ffba01d | |||
| 023e12bb4e | |||
| 832d5734b4 | |||
| 20337ceef1 | |||
| b5bda17812 | |||
| 3d8d1dd173 | |||
| 6255e33b50 | |||
| 2e2fae378a | |||
| b6c9d34e45 | |||
| 54f424d047 | |||
| a90566d864 | |||
| 2fa26ceedc | |||
| 49cd7e4bcf | |||
| 1f02ed5d87 | |||
| 2428d22368 | |||
| bdf77f8548 | |||
| 8556016a69 | |||
| 43be4f7d3d | |||
| 717c53bea8 | |||
| fd448a5fab | |||
| 793710b02b | |||
| 8b5279ce15 | |||
| 89709949ab | |||
| f2fc654f34 | |||
| e433a1c968 | |||
| a2ed2c3b22 | |||
| b226f41560 | |||
| ccf53af2ba | |||
| ebf8644df9 | |||
| 0cac671e8a | |||
| 69f8ea80f8 | |||
| 5bec587717 | |||
| e9222a9d97 | |||
| 40908e9ce7 | |||
| 5dc41eb0d9 | |||
| d966b3e425 | |||
| 10943b4ff4 | |||
| 4794ab6b9a | |||
| a1c4f19731 | |||
| 5621c80425 | |||
| 0e2619f7d5 | |||
| d14be05922 | |||
| 0ae9da83df | |||
| f685f9c8c0 | |||
| 9e4f81b156 | |||
| 14982b9393 | |||
| dac73cbeea | |||
| dfe88e1656 | |||
| b9474a5d55 | |||
| addd66b37f | |||
| 07e6d001a2 | |||
| 2255d21bda | |||
| 5e0a6a12ff | |||
| 5b4b8e47d9 | |||
| c9c3bd98b7 | |||
| 9277282e6c | |||
| a02d5f9eb9 | |||
| 1cefe70ebe | |||
| b622f63918 | |||
| bd8d8fb9f1 | |||
| 877eab0fe3 | |||
| b6903419a1 | |||
| 49a392bfb3 | |||
| 08d12d9a3d | |||
| f491bd89cc | |||
| 2a5b0ab2ac | |||
| ad43d594c9 | |||
| 54dd4af312 | |||
| 0e3b2633b9 | |||
| 73dae9f7b5 | |||
| 86d036cca8 | |||
| a3786ee5cd | |||
| 12d8f0071a | |||
| b4480738e3 | |||
| 7a0bf7266a | |||
| 6d57094c6a | |||
| d2a11f3344 | |||
| 273fe3e8e9 | |||
| 0f67be3627 | |||
| e31fcb1a6d | |||
| 7d69570a44 | |||
| a1fc95f30e | |||
| f71ec52517 | |||
| 242b899f79 | |||
| d9b94f54ef | |||
| 8316f97e27 | |||
| 4813f26158 | |||
| a06f6ada4e | |||
| 9f5289a101 | |||
| f5b39f2755 | |||
| 41b10e3df5 | |||
| 4d2efd84e4 | |||
| 70c76a76a0 | |||
| 125ef1c756 | |||
| 1760043f79 | |||
| 2938a1397e | |||
| 3ead08ab26 | |||
| 1512b5cf68 | |||
| a3c09f9f86 | |||
| 2456ca828f | |||
| 4a2251f48b | |||
| 236223d02a | |||
| e74c46e9ab | |||
| f3db23a3b1 | |||
| 0c6a82062f | |||
| 4a858bacd6 | |||
| cbc65247ae | |||
| 521530eeb6 | |||
| c52635c578 | |||
| e8bc44e166 | |||
| e724ab8123 | |||
| 4022598692 | |||
| d0438b6b28 | |||
| ab2b454be8 | |||
| fb8a7e37f4 | |||
| ca20643ff8 | |||
| 963a44ab87 | |||
| 537ae578d6 | |||
| 200c4c1d37 | |||
| a1127bfcfc | |||
| b723c4ca7c | |||
| 3767e76390 | |||
| 41e69651f9 | |||
| b2605d3908 | |||
| eca1333b7c | |||
| 505787e437 | |||
| 8d394a3541 | |||
| 1f95628475 | |||
| 860b5307ad | |||
| 5809dd0624 | |||
| 7a920d2eb4 | |||
| 25d7e2449f | |||
| 89c65c856e | |||
| c2d87b8075 | |||
| 38fd4282f8 | |||
| 1269e76071 | |||
| a612aa276c | |||
| f78b55a235 | |||
| f78e4c2433 | |||
| 1cfa4aabbd | |||
| 76a5e3af71 | |||
| 003e18b94d | |||
| 93318bef05 | |||
| 6d422bb583 | |||
| dcf9fa5e11 | |||
| c97d971f09 | |||
| 79921b65f8 | |||
| 91a4e06878 | |||
| 123c73029d | |||
| 00103e8e58 | |||
| 31b7f76015 | |||
| 95bc461932 | |||
| 89f3135ebc | |||
| 74895e18c3 | |||
| 65606b3929 | |||
| 86df874dd3 | |||
| 646a685f26 | |||
| 87825f19ec | |||
| 4ab7909f8b | |||
| 2b02976923 | |||
| b3e6f0f29d | |||
| b8b5d24fb9 | |||
| 36d2b02a9e | |||
| 312f54cfc0 | |||
| 46ee6a0748 | |||
| c15b785016 | |||
| 880149c05e | |||
| cb4092c959 | |||
| c94e5f81a0 | |||
| 2823e8aa47 | |||
| b0cdaf71dd | |||
| 83cb04b691 | |||
| 3f1a85198a | |||
| a7661b1f6f | |||
| 44259948cc | |||
| 3f5bdd8be3 | |||
| 310e5ea35b |
+82
-1
@@ -1,3 +1,5 @@
|
||||
const bskyEslint = require('./eslint')
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
@@ -7,10 +9,85 @@ module.exports = {
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint', 'detox', 'react', 'lingui'],
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
'detox',
|
||||
'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',
|
||||
{
|
||||
groups: [
|
||||
// Side effect imports.
|
||||
['^\\u0000'],
|
||||
// Node.js builtins prefixed with `node:`.
|
||||
['^node:'],
|
||||
// Packages.
|
||||
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
|
||||
// React/React Native priortized, followed by expo
|
||||
// Followed by all packages excluding unprefixed relative ones
|
||||
[
|
||||
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
|
||||
'^(expo(.*)$)|^(expo$)',
|
||||
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
|
||||
],
|
||||
// Relative imports.
|
||||
// Ideally, anything that starts with a dot or #
|
||||
// due to unprefixed relative imports being used, we whitelist the relative paths we use
|
||||
// (?:$|\\/) matches end of string or /
|
||||
[
|
||||
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
|
||||
'^(?:#\\/)?view(?:$|\\/)',
|
||||
'^(?:#\\/)?screens(?:$|\\/)',
|
||||
'^(?:#\\/)?alf(?:$|\\/)',
|
||||
'^(?:#\\/)?components(?:$|\\/)',
|
||||
'^#\\/',
|
||||
'^\\.',
|
||||
],
|
||||
// anything else - hopefully we don't have any of these
|
||||
['^'],
|
||||
],
|
||||
},
|
||||
],
|
||||
'simple-import-sort/exports': 'warn',
|
||||
},
|
||||
ignorePatterns: [
|
||||
'**/__mocks__/*.ts',
|
||||
@@ -31,4 +108,8 @@ module.exports = {
|
||||
settings: {
|
||||
componentWrapperFunctions: ['observer'],
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 eas build -p android --profile production --local --output build.aab --non-interactive
|
||||
run: yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
|
||||
|
||||
- name: 🚀 Deploy
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
run: eas submit -p android --non-interactive --path build.aab
|
||||
|
||||
- name: ✍️ Rename bundle
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
run: mv build.aab build.apk
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: nanzm/get-time-action@master
|
||||
with:
|
||||
format: 'MM-DD-HH-mm-ss'
|
||||
|
||||
- name: 🚀 Upload Artifact
|
||||
id: upload-artifact
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
name: Build and Submit iOS
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 5 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
type: choice
|
||||
description: Build profile to use
|
||||
options:
|
||||
- testflight
|
||||
- production
|
||||
|
||||
jobs:
|
||||
@@ -69,7 +68,7 @@ jobs:
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: yarn use-build-number eas build -p ios --profile production --local --output build.ipa --non-interactive
|
||||
run: yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p ios --non-interactive --path build.ipa
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
---
|
||||
name: Bundle and Deploy EAS Update
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
type: choice
|
||||
description: Deployment channel to use
|
||||
options:
|
||||
- testflight
|
||||
- production
|
||||
runtimeVersion:
|
||||
type: string
|
||||
description: Runtime version (in x.x.x format) that this update is for
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
bundleDeploy:
|
||||
name: Bundle and Deploy EAS Update
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
fingerprint-is-different: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different }}
|
||||
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate the version if one is supplied. This should generally happen if the update is for a production client
|
||||
- name: 🧐 Validate version
|
||||
if: ${{ inputs.runtimeVersion }}
|
||||
run: |
|
||||
if [ -z "${{ inputs.runtimeVersion }}" ]; then
|
||||
[[ "${{ inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
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
|
||||
- name: 🕵️ Get the base commit
|
||||
id: base-commit
|
||||
run: |
|
||||
if [ -z "${{ inputs.channel == 'production' }}" ]; then
|
||||
echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo base-commit=$(git log -n 1 --skip 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: ✓ Make sure we found a base commit
|
||||
run: |
|
||||
if [ -z "${{ steps.base-commit.outputs.base-commit }}" ]; then
|
||||
echo "Could not find a base commit for this release. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
|
||||
- name: ⚙️ Install Dependencies
|
||||
run: yarn install
|
||||
|
||||
# Run the fingerprint
|
||||
- name: 📷 Check fingerprint
|
||||
id: fingerprint
|
||||
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-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⛏️ Setup Expo
|
||||
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
run: yarn global add eas-cli-local-build-plugin
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
|
||||
- name: 🔤 Compile Translations
|
||||
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
run: yarn intl:build
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
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-debug.outputs.fingerprint-is-different == 'false'}}
|
||||
run: EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
|
||||
|
||||
- name: 📦 Package Bundle and 🚀 Deploy
|
||||
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
|
||||
runs-on: macos-14
|
||||
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-is-different == 'true' }}
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⛏️ Setup EAS local builds
|
||||
run: yarn global add eas-cli-local-build-plugin
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
with:
|
||||
version: 1.14.3
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@v3
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
# We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
|
||||
# change unless the yarn version changes as well.
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: yarn use-build-number-with-bump eas build -p ios --profile testflight --local --output build.ipa --non-interactive
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p ios --non-interactive --path build.ipa
|
||||
|
||||
buildIfNecessaryAndroid:
|
||||
name: Build and Submit Android
|
||||
runs-on: ubuntu-latest
|
||||
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-is-different == 'true' }}
|
||||
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⛏️ Setup EAS local builds
|
||||
run: yarn global add eas-cli-local-build-plugin
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: yarn use-build-number-with-bump eas build -p android --profile testflight-android --local --output build.apk --non-interactive
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
uses: nanzm/get-time-action@master
|
||||
with:
|
||||
format: 'MM-DD-HH-mm-ss'
|
||||
|
||||
- name: 🚀 Upload Artifact
|
||||
id: upload-artifact
|
||||
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
|
||||
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
|
||||
@@ -24,6 +24,8 @@ jobs:
|
||||
attempt_delay: 2000
|
||||
- name: Lint check
|
||||
run: yarn lint
|
||||
- name: Prettier check
|
||||
run: yarn prettier --check .
|
||||
- name: Check & compile i18n
|
||||
run: yarn intl:build
|
||||
- name: Type check
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-1
@@ -18,7 +18,6 @@ xcuserdata
|
||||
*.moved-aside
|
||||
DerivedData
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.xcuserstate
|
||||
|
||||
# Android/IntelliJ
|
||||
@@ -78,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*
|
||||
@@ -104,6 +104,14 @@ google-services.json
|
||||
# Performance results (Flashlight)
|
||||
.perf/
|
||||
|
||||
# ESLint
|
||||
.eslintcache
|
||||
|
||||
# i18n
|
||||
src/locale/locales/_build/
|
||||
src/locale/locales/**/*.js
|
||||
|
||||
# local builds
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
|
||||
+13
-11
@@ -1,12 +1,14 @@
|
||||
ios
|
||||
android
|
||||
src/third-party
|
||||
src/app.json
|
||||
public
|
||||
/bskyweb/templates
|
||||
/dist/
|
||||
/.watchmanconfig
|
||||
/app.json
|
||||
# Ignore everything except JS-ey code.
|
||||
# Based on https://stackoverflow.com/a/70715829/458193
|
||||
*
|
||||
!**/*.js
|
||||
!**/*.jsx
|
||||
!**/*.ts
|
||||
!**/*.tsx
|
||||
!*/
|
||||
|
||||
web/index.html
|
||||
web-build/*
|
||||
# More specific ignores go below.
|
||||
.expo
|
||||
android
|
||||
ios
|
||||
src/locale/locales
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('Curate lists', () => {
|
||||
await element(by.text('Edit list details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Library')).tap()
|
||||
await element(by.text('Upload from Library')).tap()
|
||||
await sleep(3e3)
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
@@ -81,7 +81,7 @@ describe('Curate lists', () => {
|
||||
await element(by.text('Edit list details')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Remove')).tap()
|
||||
await element(by.text('Remove Avatar')).tap()
|
||||
await element(by.id('saveBtn')).tap()
|
||||
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('Home screen', () => {
|
||||
|
||||
it('Can go to feeds page using feeds button in tab bar', async () => {
|
||||
await element(by.id('homeScreenFeedTabs-Feeds ✨')).tap()
|
||||
await expect(element(by.text('Discover new feeds'))).toBeVisible()
|
||||
await expect(element(by.text('Discover New Feeds'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('Feeds button disappears after pinning a feed', async () => {
|
||||
|
||||
@@ -70,10 +70,10 @@ describe('Profile screen', () => {
|
||||
await element(by.id('profileHeaderEditProfileButton')).tap()
|
||||
await expect(element(by.id('editProfileModal'))).toBeVisible()
|
||||
await element(by.id('changeBannerBtn')).tap()
|
||||
await element(by.text('Library')).tap()
|
||||
await element(by.text('Upload from Library')).tap()
|
||||
await sleep(3e3)
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Library')).tap()
|
||||
await element(by.text('Upload from Library')).tap()
|
||||
await sleep(3e3)
|
||||
await element(by.id('editProfileSaveBtn')).tap()
|
||||
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
|
||||
@@ -87,9 +87,9 @@ describe('Profile screen', () => {
|
||||
await element(by.id('profileHeaderEditProfileButton')).tap()
|
||||
await expect(element(by.id('editProfileModal'))).toBeVisible()
|
||||
await element(by.id('changeBannerBtn')).tap()
|
||||
await element(by.text('Remove')).tap()
|
||||
await element(by.text('Remove Banner')).tap()
|
||||
await element(by.id('changeAvatarBtn')).tap()
|
||||
await element(by.text('Remove')).tap()
|
||||
await element(by.text('Remove Avatar')).tap()
|
||||
await element(by.id('editProfileSaveBtn')).tap()
|
||||
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
|
||||
await expect(element(by.id('userBannerFallback'))).toExist()
|
||||
|
||||
+10
-8
@@ -12,6 +12,7 @@ describe('Create account', () => {
|
||||
})
|
||||
|
||||
it('I can create a new account with text verification', async () => {
|
||||
console.log('SERVICE IS', service)
|
||||
await element(by.id('e2eOpenLoggedOutView')).tap()
|
||||
|
||||
await element(by.id('createAccountButton')).tap()
|
||||
@@ -28,16 +29,17 @@ describe('Create account', () => {
|
||||
await device.takeScreenshot('4- entered account details')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await element(by.id('phoneInput')).typeText('8042221111')
|
||||
await element(by.id('requestCodeBtn')).tap()
|
||||
await device.takeScreenshot('5- requested code')
|
||||
|
||||
await element(by.id('codeInput')).typeText('000000')
|
||||
await device.takeScreenshot('6- entered code')
|
||||
await element(by.id('handleInput')).typeText('text-verification-test')
|
||||
await device.takeScreenshot('5- entered handle')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await element(by.id('handleInput')).typeText('text-verification-test')
|
||||
await device.takeScreenshot('7- entered handle')
|
||||
await element(by.id('phoneInput')).typeText('8042221111')
|
||||
await element(by.id('requestCodeBtn')).tap()
|
||||
await device.takeScreenshot('6- requested code')
|
||||
|
||||
await element(by.id('codeInput')).typeText('000000')
|
||||
await device.takeScreenshot('7- entered code')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
linkRequiresWarning,
|
||||
isPossiblyAUrl,
|
||||
splitApexDomain,
|
||||
isTrustedUrl,
|
||||
} from '../../../src/lib/strings/url-helpers'
|
||||
|
||||
describe('linkRequiresWarning', () => {
|
||||
@@ -74,6 +75,10 @@ describe('linkRequiresWarning', () => {
|
||||
// bad uri inputs, default to true
|
||||
['', '', true],
|
||||
['example.com', 'example.com', true],
|
||||
['/profile', 'Username', false],
|
||||
['#', 'Show More', false],
|
||||
['https://docs.bsky.app', 'https://docs.bsky.app', false],
|
||||
['https://bsky.app/compose/intent?text=test', 'Compose a post', false],
|
||||
]
|
||||
|
||||
it.each(cases)(
|
||||
@@ -139,3 +144,36 @@ describe('splitApexDomain', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('isTrustedUrl', () => {
|
||||
const cases = [
|
||||
['#', true],
|
||||
['#profile', true],
|
||||
['/', true],
|
||||
['/profile', true],
|
||||
['/profile/', true],
|
||||
['/profile/bob.test', true],
|
||||
['https://bsky.app', true],
|
||||
['https://bsky.app/', true],
|
||||
['https://bsky.app/profile/bob.test', true],
|
||||
['https://www.bsky.app', true],
|
||||
['https://www.bsky.app/', true],
|
||||
['https://docs.bsky.app', true],
|
||||
['https://bsky.social', true],
|
||||
['https://bsky.social/blog', true],
|
||||
['https://blueskyweb.xyz', true],
|
||||
['https://blueskyweb.zendesk.com', true],
|
||||
['http://bsky.app', true],
|
||||
['http://bsky.social', true],
|
||||
['http://blueskyweb.xyz', true],
|
||||
['http://blueskyweb.zendesk.com', true],
|
||||
['https://google.com', false],
|
||||
['https://docs.google.com', false],
|
||||
['https://google.com/#', false],
|
||||
]
|
||||
|
||||
it.each(cases)('given input uri %p, returns %p', (str, expected) => {
|
||||
const output = isTrustedUrl(str)
|
||||
expect(output).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
+17
-9
@@ -41,6 +41,9 @@ module.exports = function (config) {
|
||||
: process.env.BSKY_IOS_BUILD_NUMBER
|
||||
|
||||
const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
|
||||
|
||||
return {
|
||||
expo: {
|
||||
@@ -122,10 +125,20 @@ module.exports = function (config) {
|
||||
favicon: './assets/favicon.png',
|
||||
},
|
||||
updates: {
|
||||
enabled: true,
|
||||
fallbackToCacheTimeout: 1000,
|
||||
url: 'https://u.expo.dev/55bd077a-d905-4184-9c7f-94789ba0f302',
|
||||
url: 'https://updates.bsky.app/manifest',
|
||||
// TODO Eventually we want to enable this for all environments, but for now it will only be used for
|
||||
// TestFlight builds
|
||||
enabled: IS_TESTFLIGHT,
|
||||
fallbackToCacheTimeout: 30000,
|
||||
codeSigningCertificate: './code-signing/certificate.pem',
|
||||
codeSigningMetadata: {
|
||||
keyid: 'main',
|
||||
alg: 'rsa-v1_5-sha256',
|
||||
},
|
||||
checkAutomatically: 'NEVER',
|
||||
channel: UPDATES_CHANNEL,
|
||||
},
|
||||
assetBundlePatterns: ['**/*'],
|
||||
plugins: [
|
||||
'expo-localization',
|
||||
Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo',
|
||||
@@ -145,12 +158,6 @@ module.exports = function (config) {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-updates',
|
||||
{
|
||||
username: 'blueskysocial',
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-notifications',
|
||||
{
|
||||
@@ -161,6 +168,7 @@ module.exports = function (config) {
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
|
||||
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
|
||||
'./plugins/shareExtension/withShareExtensions.js',
|
||||
].filter(Boolean),
|
||||
extra: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 296 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 871 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 327 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M15.586 2.5a2 2 0 0 1 2.828 0L21.5 5.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 7.086 22H3a1 1 0 0 1-1-1v-4.086a2 2 0 0 1 .586-1.414l13-13ZM17 3.914l-13 13V20h3.086l13-13L17 3.914ZM13 21a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" stroke="#000" stroke-linejoin="round" d="M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 768 B |
@@ -44,7 +44,7 @@
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
html, body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
/* ProseMirror */
|
||||
.ProseMirror {
|
||||
font: 18px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font: 18px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif;
|
||||
min-height: 140px;
|
||||
}
|
||||
.ProseMirror-dark {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC0TCCAbmgAwIBAgIJcMN2yt5KNDqTMA0GCSqGSIb3DQEBCwUAMBIxEDAOBgNV
|
||||
BAMTB0JsdWVza3kwHhcNMjQwMzE0MDA1OTU4WhcNMzQwMzE0MDA1OTU4WjASMRAw
|
||||
DgYDVQQDEwdCbHVlc2t5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
|
||||
izSAWEc3wRoa3eTBEh/kE9pH0d6jhEGw9GrYfei60MHT1pSq2cTdyUM1yUZchAeW
|
||||
gFFtqFxX0pfIZQyMlIZbjkaOxOqzWhB0aCsxngnhbSahFwRxkVwTAuonhqIpaLBL
|
||||
hrCCCQ2IfZUpy8QeasqlTlmvmijuCC34fXxJlxNcj8SqzIZi+civ7U5PMPfIMMnD
|
||||
tCDIBy1vxMk57m25X2ikcWUFW64qNVLkFAL36xEnmFTL4Ivqpz23gUcUIe1zbesY
|
||||
jAgDtlwnAE7mU3oagCUDcSuOveT4POhT35Xp3Y/07I68kmXtrPxwd5k0L0zbisEm
|
||||
poKZ87E2X29BitihicMpBwIDAQABoyowKDAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0l
|
||||
AQH/BAwwCgYIKwYBBQUHAwMwDQYJKoZIhvcNAQELBQADggEBAED1gdMF0yr8Gy87
|
||||
RgyaeVpPySwSsO0selmXXrcmOWgiPA05lubyhFEa4P5kdzBEByG2MT+pJkjGYpvK
|
||||
XRnqXM5VvdS2RhYYFH0cFOIUqBKwCnzViCMuGQeoGUx4oPcKFS0PQ1WjW2d4pS75
|
||||
51GBfB6LOepsCHUG0A9XEk7EAyUWc4M2ITCJsTtJh8CVn2pTks2q14ETDs86YQv4
|
||||
peDaJv8nhIe8oQkeGn2o/P/ctkwJg/uBydQUsWgjjGTQZTilVjGTW1mwDr9FucAE
|
||||
d5gKIk4rtR/3Zd/NDdqp8PrkoWeVM7Hwr789/mpUOeqa/j7YNkDYQh7x+M/odd1D
|
||||
KY0bQEQ=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -16,14 +16,20 @@
|
||||
"ios": {
|
||||
"simulator": true,
|
||||
"resourceClass": "large"
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
"channel": "preview",
|
||||
"channel": "production",
|
||||
"ios": {
|
||||
"resourceClass": "large"
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
@@ -35,9 +41,12 @@
|
||||
"android": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"channel": "production"
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"github": {
|
||||
"testflight": {
|
||||
"extends": "base",
|
||||
"ios": {
|
||||
"autoIncrement": true
|
||||
@@ -45,7 +54,24 @@
|
||||
"android": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"channel": "production"
|
||||
"channel": "testflight",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "testflight"
|
||||
}
|
||||
},
|
||||
"testflight-android": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"channel": "testflight",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "testflight"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
const {RuleTester} = require('eslint')
|
||||
const avoidUnwrappedText = require('../avoid-unwrapped-text')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 6,
|
||||
sourceType: 'module',
|
||||
},
|
||||
})
|
||||
|
||||
describe('avoid-unwrapped-text', () => {
|
||||
const tests = {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
foo
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
<Trans>
|
||||
foo
|
||||
</Trans>
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
<>
|
||||
foo
|
||||
</>
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
{foo && <Trans>foo</Trans>}
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
{foo ? <Trans>foo</Trans> : <Trans>bar</Trans>}
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Trans>
|
||||
<Text>
|
||||
foo
|
||||
</Text>
|
||||
</Trans>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Trans>
|
||||
{foo && <Text>foo</Text>}
|
||||
</Trans>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Trans>
|
||||
{foo ? <Text>foo</Text> : <Text>bar</Text>}
|
||||
</Trans>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<CustomText>
|
||||
foo
|
||||
</CustomText>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<CustomText>
|
||||
<Trans>
|
||||
foo
|
||||
</Trans>
|
||||
</CustomText>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
{bar}
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{bar}
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
foo {bar}
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text>
|
||||
foo
|
||||
</Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text>
|
||||
{bar}
|
||||
</Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text>
|
||||
foo {bar}
|
||||
</Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<CustomText>
|
||||
foo
|
||||
</CustomText>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
<Text>foo</Text>
|
||||
}>
|
||||
<Bar />
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
foo && <Text>foo</Text>
|
||||
}>
|
||||
<Bar />
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
foo ? <Text>foo</Text> : <Text>bar</Text>
|
||||
}>
|
||||
<Bar />
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
<Trans><Text>foo</Text></Trans>
|
||||
}>
|
||||
<Bar />
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
<Text><Trans>foo</Trans></Text>
|
||||
}>
|
||||
<Bar />
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Foo propText={
|
||||
<Trans>foo</Trans>
|
||||
}>
|
||||
<Bar />
|
||||
</Foo>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Foo propText={
|
||||
foo && <Trans>foo</Trans>
|
||||
}>
|
||||
<Bar />
|
||||
</Foo>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Foo propText={
|
||||
foo ? <Trans>foo</Trans> : <Trans>bar</Trans>
|
||||
}>
|
||||
<Bar />
|
||||
</Foo>
|
||||
`,
|
||||
},
|
||||
],
|
||||
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
<View> </View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
foo
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<>
|
||||
foo
|
||||
</>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>
|
||||
foo
|
||||
</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{foo && <Trans>foo</Trans>}
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{foo ? <Trans>foo</Trans> : <Trans>bar</Trans>}
|
||||
</View>
|
||||
`,
|
||||
errors: 2,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Trans>
|
||||
<View>
|
||||
foo
|
||||
</View>
|
||||
</Trans>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
foo {bar}
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<View>
|
||||
foo
|
||||
</View>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
<View>
|
||||
foo
|
||||
</View>
|
||||
</Text>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text prop={
|
||||
<View>foo</View>
|
||||
}>
|
||||
<Bar />
|
||||
</Text>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text prop={
|
||||
foo && <View>foo</View>
|
||||
}>
|
||||
<Bar />
|
||||
</Text>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text prop={
|
||||
foo ? <View>foo</View> : <View>bar</View>
|
||||
}>
|
||||
<Bar />
|
||||
</Text>
|
||||
`,
|
||||
errors: 2,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Foo prop={
|
||||
<Trans>foo</Trans>
|
||||
}>
|
||||
<Bar />
|
||||
</Foo>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// For easier local testing
|
||||
if (!process.env.CI) {
|
||||
let only = []
|
||||
let skipped = []
|
||||
;[...tests.valid, ...tests.invalid].forEach(t => {
|
||||
if (t.skip) {
|
||||
delete t.skip
|
||||
skipped.push(t)
|
||||
}
|
||||
if (t.only) {
|
||||
delete t.only
|
||||
only.push(t)
|
||||
}
|
||||
})
|
||||
const predicate = t => {
|
||||
if (only.length > 0) {
|
||||
return only.indexOf(t) !== -1
|
||||
}
|
||||
if (skipped.length > 0) {
|
||||
return skipped.indexOf(t) === -1
|
||||
}
|
||||
return true
|
||||
}
|
||||
tests.valid = tests.valid.filter(predicate)
|
||||
tests.invalid = tests.invalid.filter(predicate)
|
||||
}
|
||||
ruleTester.run('avoid-unwrapped-text', avoidUnwrappedText, tests)
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
'use strict'
|
||||
|
||||
// Partially based on eslint-plugin-react-native.
|
||||
// Portions of code by Alex Zhukov, MIT license.
|
||||
|
||||
function hasOnlyLineBreak(value) {
|
||||
return /^[\r\n\t\f\v]+$/.test(value.replace(/ /g, ''))
|
||||
}
|
||||
|
||||
function getTagName(node) {
|
||||
const reversedIdentifiers = []
|
||||
if (
|
||||
node.type === 'JSXElement' &&
|
||||
node.openingElement.type === 'JSXOpeningElement'
|
||||
) {
|
||||
let object = node.openingElement.name
|
||||
while (object.type === 'JSXMemberExpression') {
|
||||
if (object.property.type === 'JSXIdentifier') {
|
||||
reversedIdentifiers.push(object.property.name)
|
||||
}
|
||||
object = object.object
|
||||
}
|
||||
|
||||
if (object.type === 'JSXIdentifier') {
|
||||
reversedIdentifiers.push(object.name)
|
||||
}
|
||||
}
|
||||
|
||||
return reversedIdentifiers.reverse().join('.')
|
||||
}
|
||||
|
||||
exports.create = function create(context) {
|
||||
const options = context.options[0] || {}
|
||||
const impliedTextProps = options.impliedTextProps ?? []
|
||||
const impliedTextComponents = options.impliedTextComponents ?? []
|
||||
const textProps = [...impliedTextProps]
|
||||
const textComponents = ['Text', ...impliedTextComponents]
|
||||
return {
|
||||
JSXText(node) {
|
||||
if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (textComponents.includes(tagName) || tagName.endsWith('Text')) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Skip over it and check above.
|
||||
// TODO: Maybe validate that it's present.
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
let message = 'Wrap this string in <Text>.'
|
||||
if (tagName !== 'View') {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXAttribute' &&
|
||||
parent.name.type === 'JSXIdentifier' &&
|
||||
parent.parent.type === 'JSXOpeningElement' &&
|
||||
parent.parent.parent.type === 'JSXElement'
|
||||
) {
|
||||
const tagName = getTagName(parent.parent.parent)
|
||||
const propName = parent.name.name
|
||||
if (
|
||||
textProps.includes(tagName + ' ' + propName) ||
|
||||
propName === 'text' ||
|
||||
propName.endsWith('Text')
|
||||
) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
const message =
|
||||
'Wrap this string in <Text>.' +
|
||||
' If `' +
|
||||
propName +
|
||||
'` is guaranteed to be wrapped in <Text>, ' +
|
||||
'rename it to `' +
|
||||
propName +
|
||||
'Text' +
|
||||
'` or add it to impliedTextProps.'
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
||||
},
|
||||
}
|
||||
+33
-3
@@ -72,6 +72,7 @@ export async function createServer(
|
||||
const phoneParams = phoneRequired
|
||||
? {
|
||||
phoneVerificationRequired: true,
|
||||
phoneVerificationProvider: 'twilio',
|
||||
twilioAccountSid: 'ACXXXXXXX',
|
||||
twilioAuthToken: 'AUTH',
|
||||
twilioServiceSid: 'VAXXXXXXXX',
|
||||
@@ -95,6 +96,35 @@ export async function createServer(
|
||||
})
|
||||
mockTwilio(testNet.pds)
|
||||
|
||||
// add the test mod authority
|
||||
if (!phoneRequired) {
|
||||
const agent = new BskyAgent({service: pdsUrl})
|
||||
const res = await agent.api.com.atproto.server.createAccount({
|
||||
email: 'mod-authority@test.com',
|
||||
handle: 'mod-authority.test',
|
||||
password: 'hunter2',
|
||||
})
|
||||
agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
||||
await agent.api.app.bsky.actor.profile.create(
|
||||
{repo: res.data.did},
|
||||
{
|
||||
displayName: 'Dev-env Moderation',
|
||||
description: `The pretend version of mod.bsky.app`,
|
||||
},
|
||||
)
|
||||
|
||||
await agent.api.app.bsky.labeler.service.create(
|
||||
{repo: res.data.did, rkey: 'self'},
|
||||
{
|
||||
policies: {
|
||||
labelValues: ['!hide', '!warn'],
|
||||
labelValueDefinitions: [],
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const pic = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
|
||||
)
|
||||
@@ -455,13 +485,13 @@ async function getPort(start = 3000) {
|
||||
}
|
||||
|
||||
export const mockTwilio = (pds: TestPds) => {
|
||||
if (!pds.ctx.twilio) return
|
||||
if (!pds.ctx.phoneVerifier) return
|
||||
|
||||
pds.ctx.twilio.sendCode = async (_number: string) => {
|
||||
pds.ctx.phoneVerifier.sendCode = async (_number: string) => {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
pds.ctx.twilio.verifyCode = async (_number: string, code: string) => {
|
||||
pds.ctx.phoneVerifier.verifyCode = async (_number: string, code: string) => {
|
||||
return code === '000000'
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -2,18 +2,22 @@
|
||||
module.exports = {
|
||||
locales: [
|
||||
'en',
|
||||
'ca',
|
||||
'de',
|
||||
'es',
|
||||
'fi',
|
||||
'fr',
|
||||
'ga',
|
||||
'hi',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'pt-BR',
|
||||
'tr',
|
||||
'uk',
|
||||
'ca',
|
||||
'zh-CN',
|
||||
'it',
|
||||
'zh-TW',
|
||||
],
|
||||
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'
|
||||
+24
-12
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.73.0",
|
||||
"version": "1.76.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"packageManager": "yarn@1.22.19",
|
||||
"scripts": {
|
||||
"prepare": "is-ci || husky install",
|
||||
"postinstall": "patch-package && yarn intl:compile",
|
||||
@@ -13,11 +14,12 @@
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"use-build-number": "./scripts/useBuildNumberEnv.sh",
|
||||
"use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh",
|
||||
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/",
|
||||
"build-all": "yarn intl:build && yarn use-build-number eas build --platform all",
|
||||
"build-ios": "yarn use-build-number eas build -p ios",
|
||||
"build-android": "yarn use-build-number eas build -p android",
|
||||
"build": "yarn use-build-number eas build",
|
||||
"build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all",
|
||||
"build-ios": "yarn use-build-number-with-bump eas build -p ios",
|
||||
"build-android": "yarn use-build-number-with-bump eas build -p android",
|
||||
"build": "yarn use-build-number-with-bump eas build",
|
||||
"start": "expo start --dev-client",
|
||||
"start:prod": "expo start --dev-client --no-dev --minify",
|
||||
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
||||
@@ -25,7 +27,7 @@
|
||||
"test-watch": "NODE_ENV=test jest --watchAll",
|
||||
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||
"test-coverage": "NODE_ENV=test jest --coverage",
|
||||
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
|
||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
||||
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
|
||||
"e2e:metro": "NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||
@@ -37,16 +39,20 @@
|
||||
"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": "scripts/updateExtensions.sh"
|
||||
"update-extensions": "bash scripts/updateExtensions.sh",
|
||||
"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.0",
|
||||
"@atproto/api": "^0.12.2",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@expo/html-elements": "^0.4.2",
|
||||
"@expo/webpack-config": "^19.0.0",
|
||||
@@ -54,7 +60,6 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/react-native-fontawesome": "^0.3.0",
|
||||
"@gorhom/bottom-sheet": "^4.5.1",
|
||||
"@lingui/react": "^4.5.0",
|
||||
"@mattermost/react-native-paste-input": "^0.6.4",
|
||||
"@miblanchard/react-native-slider": "^2.3.1",
|
||||
@@ -91,6 +96,7 @@
|
||||
"@tiptap/pm": "^2.0.0-beta.220",
|
||||
"@tiptap/react": "^2.0.0-beta.220",
|
||||
"@tiptap/suggestion": "^2.0.0-beta.220",
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/node": "^18.16.2",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"array.prototype.findlast": "^1.2.3",
|
||||
@@ -170,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",
|
||||
@@ -230,11 +236,13 @@
|
||||
"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",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-native-a11y": "^3.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"husky": "^8.0.3",
|
||||
"is-ci": "^3.0.1",
|
||||
@@ -252,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"
|
||||
},
|
||||
@@ -309,6 +318,9 @@
|
||||
]
|
||||
},
|
||||
"lint-staged": {
|
||||
"*{.js,.jsx,.ts,.tsx}": "yarn eslint --fix"
|
||||
"*{.js,.jsx,.ts,.tsx}": [
|
||||
"eslint --cache --fix",
|
||||
"prettier --cache --write --ignore-unknown"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3594,3 +3594,19 @@ index 19b61ff..04a9951 100644
|
||||
PasteInput_compileSdkVersion=30
|
||||
PasteInput_buildToolsVersion=30.0.2
|
||||
PasteInput_targetSdkVersion=30
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
|
||||
index e916023..0564d97 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
|
||||
@@ -22,6 +22,11 @@ - (instancetype)initWithBridge:(RCTBridge *)bridge
|
||||
_backedTextInputView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
_backedTextInputView.textInputDelegate = self;
|
||||
|
||||
+ // Disable inline predictions to prevent jank in the composer
|
||||
+ if (@available(iOS 17.0, *)) {
|
||||
+ _backedTextInputView.inlinePredictionType = UITextInlinePredictionTypeNo;
|
||||
+ }
|
||||
+
|
||||
[self addSubview:_backedTextInputView];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,56 @@
|
||||
diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/ImagePickerModule.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/ImagePickerModule.kt
|
||||
index 3f50f8c..ee47fa1 100644
|
||||
--- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/ImagePickerModule.kt
|
||||
+++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/ImagePickerModule.kt
|
||||
@@ -33,7 +33,9 @@ import kotlin.coroutines.resumeWithException
|
||||
// TODO(@bbarthec): rename to ExpoImagePicker
|
||||
private const val moduleName = "ExponentImagePicker"
|
||||
|
||||
+
|
||||
class ImagePickerModule : Module() {
|
||||
+ private var isPickerOpen = false
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name(moduleName)
|
||||
@@ -129,6 +131,11 @@ class ImagePickerModule : Module() {
|
||||
options: ImagePickerOptions
|
||||
): Any {
|
||||
return try {
|
||||
+ if(isPickerOpen) {
|
||||
+ return ImagePickerResponse(canceled = true)
|
||||
+ }
|
||||
+
|
||||
+ isPickerOpen = true
|
||||
var result = launchPicker(pickerLauncher)
|
||||
if (
|
||||
!options.allowsMultipleSelection &&
|
||||
@@ -143,6 +150,8 @@ class ImagePickerModule : Module() {
|
||||
mediaHandler.readExtras(result.data, options)
|
||||
} catch (cause: OperationCanceledException) {
|
||||
return ImagePickerResponse(canceled = true)
|
||||
+ } finally {
|
||||
+ isPickerOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/contracts/ImageLibraryContract.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/contracts/ImageLibraryContract.kt
|
||||
index ff15c91..41aaf12 100644
|
||||
index ff15c91..9763012 100644
|
||||
--- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/contracts/ImageLibraryContract.kt
|
||||
+++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/contracts/ImageLibraryContract.kt
|
||||
@@ -26,51 +26,26 @@ import java.io.Serializable
|
||||
@@ -5,12 +5,7 @@ import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
-import androidx.activity.result.PickVisualMediaRequest
|
||||
-import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
|
||||
-import androidx.activity.result.contract.ActivityResultContracts.PickMultipleVisualMedia
|
||||
import expo.modules.imagepicker.ImagePickerOptions
|
||||
-import expo.modules.imagepicker.MediaTypes
|
||||
-import expo.modules.imagepicker.UNLIMITED_SELECTION
|
||||
import expo.modules.imagepicker.getAllDataUris
|
||||
import expo.modules.imagepicker.toMediaType
|
||||
import expo.modules.kotlin.activityresult.AppContextActivityResultContract
|
||||
@@ -26,51 +21,26 @@ import java.io.Serializable
|
||||
* @see [androidx.activity.result.contract.ActivityResultContracts.GetMultipleContents]
|
||||
*/
|
||||
internal class ImageLibraryContract(
|
||||
@@ -12,7 +60,7 @@ index ff15c91..41aaf12 100644
|
||||
private val contentResolver: ContentResolver
|
||||
get() = appContextProvider.appContext.reactContext?.contentResolver
|
||||
?: throw Exceptions.ReactContextLost()
|
||||
|
||||
|
||||
override fun createIntent(context: Context, input: ImageLibraryContractOptions): Intent {
|
||||
- val request = PickVisualMediaRequest.Builder()
|
||||
- .setMediaType(
|
||||
@@ -34,7 +82,7 @@ index ff15c91..41aaf12 100644
|
||||
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
+ .addCategory(Intent.CATEGORY_OPENABLE)
|
||||
+ .setType("image/*")
|
||||
|
||||
|
||||
if (input.options.allowsMultipleSelection) {
|
||||
- val selectionLimit = input.options.selectionLimit
|
||||
-
|
||||
@@ -45,7 +93,7 @@ index ff15c91..41aaf12 100644
|
||||
+ if(input.options.selectionLimit == 1) {
|
||||
+ return intent
|
||||
}
|
||||
|
||||
|
||||
- if (selectionLimit > 1) {
|
||||
- return PickMultipleVisualMedia(selectionLimit).createIntent(context, request)
|
||||
- }
|
||||
@@ -56,9 +104,9 @@ index ff15c91..41aaf12 100644
|
||||
- }
|
||||
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
}
|
||||
|
||||
|
||||
- return PickVisualMedia().createIntent(context, request)
|
||||
+ return intent
|
||||
}
|
||||
|
||||
|
||||
override fun parseResult(input: ImageLibraryContractOptions, resultCode: Int, intent: Intent?) =
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift
|
||||
index 189a5f5..8d5b8e6 100644
|
||||
--- a/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift
|
||||
+++ b/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift
|
||||
@@ -68,13 +68,20 @@ public final class NewUpdate: Update {
|
||||
processedAssets.append(asset)
|
||||
}
|
||||
|
||||
+ // Instead of relying on various hacks to get the correct format for the specific
|
||||
+ // platform on the backend, we can just add this little patch..
|
||||
+ let dateFormatter = DateFormatter()
|
||||
+ dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
+ dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
|
||||
+ let date = dateFormatter.date(from:commitTime) ?? RCTConvert.nsDate(commitTime)!
|
||||
+
|
||||
return Update(
|
||||
manifest: manifest,
|
||||
config: config,
|
||||
database: database,
|
||||
updateId: uuid,
|
||||
scopeKey: config.scopeKey,
|
||||
- commitTime: RCTConvert.nsDate(commitTime),
|
||||
+ commitTime: date,
|
||||
runtimeVersion: runtimeVersion,
|
||||
keep: true,
|
||||
status: UpdateStatus.StatusPending,
|
||||
@@ -0,0 +1,7 @@
|
||||
# Expo-Updates Patch
|
||||
|
||||
This is a small patch to convert timestamp formats that are returned from the backend. Instead of relying on the
|
||||
backend to return the correct format for a specific format (the format required on Android is not the same as on iOS)
|
||||
we can just add this conversion in.
|
||||
|
||||
Don't remove unless we make changes on the backend to support both platforms.
|
||||
@@ -1,92 +1,19 @@
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm
|
||||
index 9dca6a5..090bda5 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm
|
||||
@@ -266,11 +266,10 @@ - (void)textViewDidChange:(__unused UITextView *)textView
|
||||
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
index b09e653..d290dab 100644
|
||||
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
@@ -198,6 +198,14 @@ - (void)refreshControlValueChanged
|
||||
[self setCurrentRefreshingState:super.refreshing];
|
||||
_refreshingProgrammatically = NO;
|
||||
|
||||
- (void)textViewDidChangeSelection:(__unused UITextView *)textView
|
||||
{
|
||||
- if (_lastStringStateWasUpdatedWith && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
|
||||
+ if (![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
|
||||
[self textViewDidChange:_backedTextInputView];
|
||||
_ignoreNextTextInputCall = YES;
|
||||
}
|
||||
- _lastStringStateWasUpdatedWith = _backedTextInputView.attributedText;
|
||||
[self textViewProbablyDidChangeSelection];
|
||||
}
|
||||
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.mm
|
||||
index 1f06b79..ab458f3 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.mm
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.mm
|
||||
@@ -87,7 +87,7 @@ - (void)invalidateContentSize
|
||||
return;
|
||||
}
|
||||
|
||||
- CGSize maximumSize = self.layoutMetrics.frame.size;
|
||||
+ CGSize maximumSize = self.layoutMetrics.contentFrame.size;
|
||||
|
||||
if (_maximumNumberOfLines == 1) {
|
||||
maximumSize.width = CGFLOAT_MAX;
|
||||
@@ -158,6 +158,8 @@ - (void)uiManagerWillPerformMounting
|
||||
[attributedText insertAttributedString:propertyAttributedText atIndex:0];
|
||||
}
|
||||
|
||||
+ [self postprocessAttributedText:attributedText];
|
||||
+
|
||||
NSAttributedString *newAttributedText;
|
||||
if (![_previousAttributedText isEqualToAttributedString:attributedText]) {
|
||||
// We have to follow `set prop` pattern:
|
||||
@@ -191,6 +193,52 @@ - (void)uiManagerWillPerformMounting
|
||||
}];
|
||||
}
|
||||
|
||||
+- (void)postprocessAttributedText:(NSMutableAttributedString *)attributedText
|
||||
+{
|
||||
+ __block CGFloat maximumLineHeight = 0;
|
||||
+
|
||||
+ [attributedText enumerateAttribute:NSParagraphStyleAttributeName
|
||||
+ inRange:NSMakeRange(0, attributedText.length)
|
||||
+ options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
|
||||
+ usingBlock:^(NSParagraphStyle *paragraphStyle, __unused NSRange range, __unused BOOL *stop) {
|
||||
+ if (!paragraphStyle) {
|
||||
+ return;
|
||||
+ if (@available(iOS 17.4, *)) {
|
||||
+ if (_currentRefreshingState) {
|
||||
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
+ [feedbackGenerator prepare];
|
||||
+ [feedbackGenerator impactOccurred];
|
||||
+ }
|
||||
+
|
||||
+ maximumLineHeight = MAX(paragraphStyle.maximumLineHeight, maximumLineHeight);
|
||||
+ }];
|
||||
+
|
||||
+ if (maximumLineHeight == 0) {
|
||||
+ // `lineHeight` was not specified, nothing to do.
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ __block CGFloat maximumFontLineHeight = 0;
|
||||
+
|
||||
+ [attributedText enumerateAttribute:NSFontAttributeName
|
||||
+ inRange:NSMakeRange(0, attributedText.length)
|
||||
+ options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
|
||||
+ usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) {
|
||||
+ if (!font) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ if (maximumFontLineHeight <= font.lineHeight) {
|
||||
+ maximumFontLineHeight = font.lineHeight;
|
||||
+ }
|
||||
+ }];
|
||||
+
|
||||
+ if (maximumLineHeight < maximumFontLineHeight) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ CGFloat baseLineOffset = maximumLineHeight / 2.0 - maximumFontLineHeight / 2.0;
|
||||
+
|
||||
+ [attributedText addAttribute:NSBaselineOffsetAttributeName
|
||||
+ value:@(baseLineOffset)
|
||||
+ range:NSMakeRange(0, attributedText.length)];
|
||||
+}
|
||||
+
|
||||
#pragma mark -
|
||||
|
||||
- (NSAttributedString *)measurableAttributedText
|
||||
if (_onRefresh) {
|
||||
_onRefresh(nil);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# TextInput Patch
|
||||
# RefreshControl Patch
|
||||
|
||||
Patching `RCTBaseTextShadowInput.mm` from https://github.com/facebook/react-native/pull/38359. This fixes some text
|
||||
getting cut off inside the composer. This was merged in December, so we should be able to remove this patch when RN
|
||||
ships the next release.
|
||||
Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since
|
||||
17.4, there has been a regression somewhere causing haptics to not play on iOS on refresh. Should monitor for an update
|
||||
in the RN repo: https://github.com/facebook/react-native/issues/43388
|
||||
@@ -0,0 +1,28 @@
|
||||
const {withStringsXml, AndroidConfig} = require('@expo/config-plugins')
|
||||
|
||||
module.exports = function withAndroidSplashScreenStatusBarTranslucentPlugin(
|
||||
appConfig,
|
||||
) {
|
||||
return withStringsXml(appConfig, function (decoratedAppConfig) {
|
||||
try {
|
||||
decoratedAppConfig.modResults = AndroidConfig.Strings.setStringItem(
|
||||
[
|
||||
{
|
||||
_: 'true',
|
||||
$: {
|
||||
name: 'expo_splash_screen_status_bar_translucent',
|
||||
translatable: 'false',
|
||||
},
|
||||
},
|
||||
],
|
||||
decoratedAppConfig.modResults,
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`withAndroidSplashScreenStatusBarTranslucentPlugin failed`,
|
||||
e,
|
||||
)
|
||||
}
|
||||
return decoratedAppConfig
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const fsp = fs.promises
|
||||
const path = require('path')
|
||||
|
||||
const DIST_DIR = './dist'
|
||||
const BUNDLES_DIR = '/_expo/static/js'
|
||||
const IOS_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/ios')
|
||||
const ANDROID_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/android')
|
||||
const METADATA_PATH = path.join(DIST_DIR, '/metadata.json')
|
||||
const DEST_DIR = './bundleTempDir'
|
||||
|
||||
// Weird, don't feel like figuring out _why_ it wants this
|
||||
const METADATA = require(`../${METADATA_PATH}`)
|
||||
const IOS_METADATA_ASSETS = METADATA.fileMetadata.ios.assets
|
||||
const ANDROID_METADATA_ASSETS = METADATA.fileMetadata.android.assets
|
||||
|
||||
const getMd5 = async path => {
|
||||
return new Promise(res => {
|
||||
const hash = crypto.createHash('md5')
|
||||
const rStream = fs.createReadStream(path)
|
||||
rStream.on('data', data => {
|
||||
hash.update(data)
|
||||
})
|
||||
rStream.on('end', () => {
|
||||
res(hash.digest('hex'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const moveFiles = async () => {
|
||||
console.log('Making directory...')
|
||||
await fsp.mkdir(DEST_DIR)
|
||||
await fsp.mkdir(path.join(DEST_DIR, '/assets'))
|
||||
|
||||
console.log('Getting ios md5...')
|
||||
const iosCurrPath = path.join(
|
||||
IOS_BUNDLE_DIR,
|
||||
(await fsp.readdir(IOS_BUNDLE_DIR))[0],
|
||||
)
|
||||
const iosMd5 = await getMd5(iosCurrPath)
|
||||
const iosNewPath = `bundles/${iosMd5}.bundle`
|
||||
|
||||
console.log('Copying ios bundle...')
|
||||
await fsp.cp(iosCurrPath, path.join(DEST_DIR, iosNewPath))
|
||||
|
||||
console.log('Getting android md5...')
|
||||
const androidCurrPath = path.join(
|
||||
ANDROID_BUNDLE_DIR,
|
||||
(await fsp.readdir(ANDROID_BUNDLE_DIR))[0],
|
||||
)
|
||||
const androidMd5 = await getMd5(androidCurrPath)
|
||||
const androidNewPath = `bundles/${androidMd5}.bundle`
|
||||
|
||||
console.log('Copying android bundle...')
|
||||
await fsp.cp(androidCurrPath, path.join(DEST_DIR, androidNewPath))
|
||||
|
||||
const iosAssets = []
|
||||
const androidAssets = []
|
||||
|
||||
console.log('Getting ios asset md5s and moving them...')
|
||||
for (const asset of IOS_METADATA_ASSETS) {
|
||||
const currPath = path.join(DIST_DIR, asset.path)
|
||||
const md5 = await getMd5(currPath)
|
||||
const withExtPath = `assets/${md5}.${asset.ext}`
|
||||
iosAssets.push(withExtPath)
|
||||
await fsp.cp(currPath, path.join(DEST_DIR, withExtPath))
|
||||
}
|
||||
|
||||
console.log('Getting android asset md5s and moving them...')
|
||||
for (const asset of ANDROID_METADATA_ASSETS) {
|
||||
const currPath = path.join(DIST_DIR, asset.path)
|
||||
const md5 = await getMd5(currPath)
|
||||
const withExtPath = `assets/${md5}.${asset.ext}`
|
||||
androidAssets.push(withExtPath)
|
||||
await fsp.cp(currPath, path.join(DEST_DIR, withExtPath))
|
||||
}
|
||||
|
||||
const result = {
|
||||
version: 0,
|
||||
bundler: 'metro',
|
||||
fileMetadata: {
|
||||
ios: {
|
||||
bundle: iosNewPath,
|
||||
assets: iosAssets,
|
||||
},
|
||||
android: {
|
||||
bundle: androidNewPath,
|
||||
assets: androidAssets,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
console.log('Writing metadata...')
|
||||
await fsp.writeFile(
|
||||
path.join(DEST_DIR, 'metadata.json'),
|
||||
JSON.stringify(result),
|
||||
)
|
||||
|
||||
console.log('Finished!')
|
||||
console.log('Metadata:', result)
|
||||
}
|
||||
|
||||
moveFiles()
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
rm -rf bundleTempDir
|
||||
rm -rf bundle.tar.gz
|
||||
|
||||
echo "Creating tarball..."
|
||||
node scripts/bundleUpdate.js
|
||||
|
||||
if [ -z "$RUNTIME_VERSION" ]; then
|
||||
RUNTIME_VERSION=$(cat package.json | jq '.version' -r)
|
||||
fi
|
||||
|
||||
cd bundleTempDir || exit
|
||||
BUNDLE_VERSION=$(date +%s)
|
||||
DEPLOYMENT_URL="https://updates.bsky.app/v1/upload?runtime-version=$RUNTIME_VERSION&bundle-version=$BUNDLE_VERSION&channel=$CHANNEL_NAME&ios-build-number=$BSKY_IOS_BUILD_NUMBER&android-build-number=$BSKY_ANDROID_VERSION_CODE"
|
||||
|
||||
tar czvf bundle.tar.gz ./*
|
||||
|
||||
echo "Deploying to $DEPLOYMENT_URL..."
|
||||
|
||||
curl -o - --form "bundle=@./bundle.tar.gz" --user "bsky:$DENIS_API_KEY" --basic "$DEPLOYMENT_URL"
|
||||
|
||||
cd ..
|
||||
|
||||
rm -rf bundleTempDir
|
||||
rm -rf bundle.tar.gz
|
||||
@@ -1,11 +1,7 @@
|
||||
#!/bin/bash
|
||||
outputIos=$(eas build:version:get -p ios)
|
||||
outputAndroid=$(eas build:version:get -p android)
|
||||
currentIosVersion=${outputIos#*buildNumber - }
|
||||
currentAndroidVersion=${outputAndroid#*versionCode - }
|
||||
|
||||
BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1))
|
||||
BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1))
|
||||
BSKY_IOS_BUILD_NUMBER=${outputIos#*buildNumber - }
|
||||
BSKY_ANDROID_VERSION_CODE=${outputAndroid#*versionCode - }
|
||||
|
||||
bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*"
|
||||
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
outputIos=$(eas build:version:get -p ios)
|
||||
outputAndroid=$(eas build:version:get -p android)
|
||||
currentIosVersion=${outputIos#*buildNumber - }
|
||||
currentAndroidVersion=${outputAndroid#*versionCode - }
|
||||
|
||||
BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1))
|
||||
BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1))
|
||||
|
||||
bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*"
|
||||
|
||||
+90
-90
@@ -1,59 +1,53 @@
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import 'lib/sentry' // must be near top
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
|
||||
import {
|
||||
SafeAreaProvider,
|
||||
initialWindowMetrics,
|
||||
} from 'react-native-safe-area-context'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {s} from 'lib/styles'
|
||||
import {Shell} from 'view/shell'
|
||||
import * as notifications from 'lib/notifications/notifications'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {
|
||||
queryClient,
|
||||
asyncStoragePersister,
|
||||
dehydrateOptions,
|
||||
} from 'lib/react-query'
|
||||
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as DialogStateProvider} from 'state/dialogs'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
} from 'react-native-safe-area-context'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
|
||||
import {useNotificationsListener} from 'lib/notifications/notifications'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {s} from 'lib/styles'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {Provider as DialogStateProvider} from 'state/dialogs'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Splash} from '#/Splash'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {Shell} from 'view/shell'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {StatusBar} from 'expo-status-bar'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
import {Splash} from '#/Splash'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
@@ -62,11 +56,12 @@ function InnerApp() {
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
useIntentHandler()
|
||||
useOTAUpdates()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
notifications.init(queryClient)
|
||||
listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
@@ -77,31 +72,34 @@ function InnerApp() {
|
||||
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
{isAndroid && <StatusBar />}
|
||||
<Alf theme={theme}>
|
||||
<Splash isReady={!isInitialLoad}>
|
||||
<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>
|
||||
@@ -109,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)
|
||||
|
||||
@@ -125,31 +129,27 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</PersistQueryClientProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+66
-74
@@ -1,44 +1,38 @@
|
||||
import 'lib/sentry' // must be near top
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {
|
||||
queryClient,
|
||||
asyncStoragePersister,
|
||||
dehydrateOptions,
|
||||
} from 'lib/react-query'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as DialogStateProvider} from 'state/dialogs'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {Provider as DialogStateProvider} from 'state/dialogs'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
@@ -60,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>
|
||||
)
|
||||
@@ -100,31 +96,27 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</PersistQueryClientProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+84
-76
@@ -1,86 +1,86 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
NavigationContainer,
|
||||
createNavigationContainerRef,
|
||||
CommonActions,
|
||||
StackActions,
|
||||
DefaultTheme,
|
||||
DarkTheme,
|
||||
} from '@react-navigation/native'
|
||||
import {JSX} from 'react/jsx-runtime'
|
||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {
|
||||
BottomTabBarProps,
|
||||
createBottomTabNavigator,
|
||||
} from '@react-navigation/bottom-tabs'
|
||||
import {
|
||||
HomeTabNavigatorParams,
|
||||
SearchTabNavigatorParams,
|
||||
FeedsTabNavigatorParams,
|
||||
NotificationsTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
AllNavigatorParams,
|
||||
MyProfileTabNavigatorParams,
|
||||
BottomTabNavigatorParams,
|
||||
} from 'lib/routes/types'
|
||||
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
|
||||
import {buildStateObject} from 'lib/routes/helpers'
|
||||
import {State, RouteParams} from 'lib/routes/types'
|
||||
import {isAndroid, isNative} from 'platform/detection'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {router} from './routes'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {JSX} from 'react/jsx-runtime'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
import {useSession} from './state/session'
|
||||
import {useModalControls} from './state/modals'
|
||||
import {
|
||||
shouldRequestEmailConfirmation,
|
||||
setEmailConfirmationRequested,
|
||||
} from './state/shell/reminders'
|
||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
CommonActions,
|
||||
createNavigationContainerRef,
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
NavigationContainer,
|
||||
StackActions,
|
||||
} from '@react-navigation/native'
|
||||
|
||||
import {HomeScreen} from './view/screens/Home'
|
||||
import {SearchScreen} from './view/screens/Search'
|
||||
import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {ListsScreen} from './view/screens/Lists'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||
import {NotFoundScreen} from './view/screens/NotFound'
|
||||
import {SettingsScreen} from './view/screens/Settings'
|
||||
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
|
||||
import {ProfileScreen} from './view/screens/Profile'
|
||||
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
||||
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
|
||||
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
|
||||
import {ProfileListScreen} from './view/screens/ProfileList'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||
import {Storybook} from './view/screens/Storybook'
|
||||
import {DebugModScreen} from './view/screens/DebugMod'
|
||||
import {LogScreen} from './view/screens/Log'
|
||||
import {SupportScreen} from './view/screens/Support'
|
||||
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
|
||||
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
|
||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {buildStateObject} from 'lib/routes/helpers'
|
||||
import {
|
||||
AllNavigatorParams,
|
||||
BottomTabNavigatorParams,
|
||||
FeedsTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
HomeTabNavigatorParams,
|
||||
MyProfileTabNavigatorParams,
|
||||
NotificationsTabNavigatorParams,
|
||||
SearchTabNavigatorParams,
|
||||
} from 'lib/routes/types'
|
||||
import {RouteParams, State} from 'lib/routes/types'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {isAndroid, isNative} from 'platform/detection'
|
||||
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||
import {AppPasswords} from 'view/screens/AppPasswords'
|
||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
|
||||
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
||||
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig'
|
||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||
import {router} from './routes'
|
||||
import {useModalControls} from './state/modals'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
import {useSession} from './state/session'
|
||||
import {
|
||||
setEmailConfirmationRequested,
|
||||
shouldRequestEmailConfirmation,
|
||||
} from './state/shell/reminders'
|
||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
|
||||
import {DebugModScreen} from './view/screens/DebugMod'
|
||||
import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {HomeScreen} from './view/screens/Home'
|
||||
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
|
||||
import {ListsScreen} from './view/screens/Lists'
|
||||
import {LogScreen} from './view/screens/Log'
|
||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||
import {NotFoundScreen} from './view/screens/NotFound'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
|
||||
import {ProfileScreen} from './view/screens/Profile'
|
||||
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
|
||||
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
|
||||
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
||||
import {ProfileListScreen} from './view/screens/ProfileList'
|
||||
import {SearchScreen} from './view/screens/Search'
|
||||
import {SettingsScreen} from './view/screens/Settings'
|
||||
import {Storybook} from './view/screens/Storybook'
|
||||
import {SupportScreen} from './view/screens/Support'
|
||||
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
|
||||
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
|
||||
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
@@ -554,10 +554,14 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
ref={navigationRef}
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
logEvent('router:navigate', {})
|
||||
}}
|
||||
onReady={() => {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
logModuleInitTime()
|
||||
onReady()
|
||||
logEvent('router:navigate', {})
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
@@ -565,7 +569,11 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
|
||||
function getCurrentRouteName() {
|
||||
return navigationRef.getCurrentRoute()?.name
|
||||
if (navigationRef.isReady()) {
|
||||
return navigationRef.getCurrentRoute()?.name
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -689,11 +697,11 @@ function logModuleInitTime() {
|
||||
}
|
||||
|
||||
export {
|
||||
navigate,
|
||||
resetToTab,
|
||||
reset,
|
||||
handleLink,
|
||||
TabsNavigator,
|
||||
FlatNavigator,
|
||||
handleLink,
|
||||
navigate,
|
||||
reset,
|
||||
resetToTab,
|
||||
RoutesContainer,
|
||||
TabsNavigator,
|
||||
}
|
||||
|
||||
+78
-6
@@ -1,6 +1,7 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {web, native} from '#/alf/util/platform'
|
||||
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import {native, web} from '#/alf/util/platform'
|
||||
|
||||
export const atoms = {
|
||||
/*
|
||||
@@ -157,6 +158,12 @@ export const atoms = {
|
||||
align_end: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
align_baseline: {
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
align_stretch: {
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
self_auto: {
|
||||
alignSelf: 'auto',
|
||||
},
|
||||
@@ -247,10 +254,10 @@ export const atoms = {
|
||||
fontWeight: tokens.fontWeight.normal,
|
||||
},
|
||||
font_semibold: {
|
||||
fontWeight: '500',
|
||||
fontWeight: tokens.fontWeight.semibold,
|
||||
},
|
||||
font_bold: {
|
||||
fontWeight: tokens.fontWeight.semibold,
|
||||
fontWeight: tokens.fontWeight.bold,
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
@@ -300,6 +307,9 @@ export const atoms = {
|
||||
/*
|
||||
* Padding
|
||||
*/
|
||||
p_0: {
|
||||
padding: 0,
|
||||
},
|
||||
p_2xs: {
|
||||
padding: tokens.space._2xs,
|
||||
},
|
||||
@@ -330,6 +340,10 @@ export const atoms = {
|
||||
p_5xl: {
|
||||
padding: tokens.space._5xl,
|
||||
},
|
||||
px_0: {
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
px_2xs: {
|
||||
paddingLeft: tokens.space._2xs,
|
||||
paddingRight: tokens.space._2xs,
|
||||
@@ -370,6 +384,10 @@ export const atoms = {
|
||||
paddingLeft: tokens.space._5xl,
|
||||
paddingRight: tokens.space._5xl,
|
||||
},
|
||||
py_0: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
py_2xs: {
|
||||
paddingTop: tokens.space._2xs,
|
||||
paddingBottom: tokens.space._2xs,
|
||||
@@ -410,6 +428,9 @@ export const atoms = {
|
||||
paddingTop: tokens.space._5xl,
|
||||
paddingBottom: tokens.space._5xl,
|
||||
},
|
||||
pt_0: {
|
||||
paddingTop: 0,
|
||||
},
|
||||
pt_2xs: {
|
||||
paddingTop: tokens.space._2xs,
|
||||
},
|
||||
@@ -440,6 +461,9 @@ export const atoms = {
|
||||
pt_5xl: {
|
||||
paddingTop: tokens.space._5xl,
|
||||
},
|
||||
pb_0: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
pb_2xs: {
|
||||
paddingBottom: tokens.space._2xs,
|
||||
},
|
||||
@@ -470,6 +494,9 @@ export const atoms = {
|
||||
pb_5xl: {
|
||||
paddingBottom: tokens.space._5xl,
|
||||
},
|
||||
pl_0: {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
pl_2xs: {
|
||||
paddingLeft: tokens.space._2xs,
|
||||
},
|
||||
@@ -500,6 +527,9 @@ export const atoms = {
|
||||
pl_5xl: {
|
||||
paddingLeft: tokens.space._5xl,
|
||||
},
|
||||
pr_0: {
|
||||
paddingRight: 0,
|
||||
},
|
||||
pr_2xs: {
|
||||
paddingRight: tokens.space._2xs,
|
||||
},
|
||||
@@ -534,9 +564,8 @@ export const atoms = {
|
||||
/*
|
||||
* Margin
|
||||
*/
|
||||
mx_auto: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
m_0: {
|
||||
margin: 0,
|
||||
},
|
||||
m_2xs: {
|
||||
margin: tokens.space._2xs,
|
||||
@@ -568,6 +597,13 @@ export const atoms = {
|
||||
m_5xl: {
|
||||
margin: tokens.space._5xl,
|
||||
},
|
||||
m_auto: {
|
||||
margin: 'auto',
|
||||
},
|
||||
mx_0: {
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
},
|
||||
mx_2xs: {
|
||||
marginLeft: tokens.space._2xs,
|
||||
marginRight: tokens.space._2xs,
|
||||
@@ -608,6 +644,14 @@ export const atoms = {
|
||||
marginLeft: tokens.space._5xl,
|
||||
marginRight: tokens.space._5xl,
|
||||
},
|
||||
mx_auto: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
my_0: {
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
my_2xs: {
|
||||
marginTop: tokens.space._2xs,
|
||||
marginBottom: tokens.space._2xs,
|
||||
@@ -648,6 +692,13 @@ export const atoms = {
|
||||
marginTop: tokens.space._5xl,
|
||||
marginBottom: tokens.space._5xl,
|
||||
},
|
||||
my_auto: {
|
||||
marginTop: 'auto',
|
||||
marginBottom: 'auto',
|
||||
},
|
||||
mt_0: {
|
||||
marginTop: 0,
|
||||
},
|
||||
mt_2xs: {
|
||||
marginTop: tokens.space._2xs,
|
||||
},
|
||||
@@ -678,6 +729,12 @@ export const atoms = {
|
||||
mt_5xl: {
|
||||
marginTop: tokens.space._5xl,
|
||||
},
|
||||
mt_auto: {
|
||||
marginTop: 'auto',
|
||||
},
|
||||
mb_0: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
mb_2xs: {
|
||||
marginBottom: tokens.space._2xs,
|
||||
},
|
||||
@@ -708,6 +765,12 @@ export const atoms = {
|
||||
mb_5xl: {
|
||||
marginBottom: tokens.space._5xl,
|
||||
},
|
||||
mb_auto: {
|
||||
marginBottom: 'auto',
|
||||
},
|
||||
ml_0: {
|
||||
marginLeft: 0,
|
||||
},
|
||||
ml_2xs: {
|
||||
marginLeft: tokens.space._2xs,
|
||||
},
|
||||
@@ -738,6 +801,12 @@ export const atoms = {
|
||||
ml_5xl: {
|
||||
marginLeft: tokens.space._5xl,
|
||||
},
|
||||
ml_auto: {
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
mr_0: {
|
||||
marginRight: 0,
|
||||
},
|
||||
mr_2xs: {
|
||||
marginRight: tokens.space._2xs,
|
||||
},
|
||||
@@ -768,4 +837,7 @@ export const atoms = {
|
||||
mr_5xl: {
|
||||
marginRight: tokens.space._5xl,
|
||||
},
|
||||
mr_auto: {
|
||||
marginRight: 'auto',
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -16,6 +16,7 @@ type BreakpointName = keyof typeof breakpoints
|
||||
const breakpoints: {
|
||||
[key: string]: number
|
||||
} = {
|
||||
gtPhone: 500,
|
||||
gtMobile: 800,
|
||||
gtTablet: 1300,
|
||||
}
|
||||
@@ -26,6 +27,7 @@ function getActiveBreakpoints({width}: {width: number}) {
|
||||
|
||||
return {
|
||||
active: active[active.length - 1],
|
||||
gtPhone: active.includes('gtPhone'),
|
||||
gtMobile: active.includes('gtMobile'),
|
||||
gtTablet: active.includes('gtTablet'),
|
||||
}
|
||||
@@ -39,6 +41,7 @@ export const Context = React.createContext<{
|
||||
theme: themes.Theme
|
||||
breakpoints: {
|
||||
active: BreakpointName | undefined
|
||||
gtPhone: boolean
|
||||
gtMobile: boolean
|
||||
gtTablet: boolean
|
||||
}
|
||||
@@ -47,6 +50,7 @@ export const Context = React.createContext<{
|
||||
theme: themes.light,
|
||||
breakpoints: {
|
||||
active: undefined,
|
||||
gtPhone: false,
|
||||
gtMobile: false,
|
||||
gtTablet: false,
|
||||
},
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
BLUE_HUE,
|
||||
RED_HUE,
|
||||
GREEN_HUE,
|
||||
generateScale,
|
||||
GREEN_HUE,
|
||||
RED_HUE,
|
||||
} from '#/alf/util/colorGeneration'
|
||||
|
||||
export const scale = generateScale(6, 100)
|
||||
@@ -116,8 +116,8 @@ export const borderRadius = {
|
||||
|
||||
export const fontWeight = {
|
||||
normal: '400',
|
||||
semibold: '600',
|
||||
bold: '900',
|
||||
semibold: '500',
|
||||
bold: '600',
|
||||
} as const
|
||||
|
||||
export const gradients = {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
|
||||
import {Button} from './Button'
|
||||
import {Text} from './Typography'
|
||||
|
||||
export function AccountList({
|
||||
onSelectAccount,
|
||||
onSelectOther,
|
||||
otherLabel,
|
||||
}: {
|
||||
onSelectAccount: (account: SessionAccount) => void
|
||||
onSelectOther: () => void
|
||||
otherLabel?: string
|
||||
}) {
|
||||
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const onPressAddAccount = useCallback(() => {
|
||||
onSelectOther()
|
||||
}, [onSelectOther])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{accounts.map(account => (
|
||||
<React.Fragment key={account.did}>
|
||||
<AccountItem
|
||||
account={account}
|
||||
onSelect={onSelectAccount}
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Button
|
||||
testID="chooseAddAccountBtn"
|
||||
style={[a.flex_1]}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
|
||||
label={_(msg`Login to account that is not listed`)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed || isSwitchingAccounts) &&
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.align_baseline,
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
{paddingLeft: 48},
|
||||
]}>
|
||||
{otherLabel ?? <Trans>Other account</Trans>}
|
||||
</Text>
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountItem({
|
||||
account,
|
||||
onSelect,
|
||||
isCurrentAccount,
|
||||
}: {
|
||||
account: SessionAccount
|
||||
onSelect: (account: SessionAccount) => void
|
||||
isCurrentAccount: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {data: profile} = useProfileQuery({did: account.did})
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
onSelect(account)
|
||||
}, [account, onSelect])
|
||||
|
||||
return (
|
||||
<Button
|
||||
testID={`chooseAccountBtn-${account.handle}`}
|
||||
key={account.did}
|
||||
style={[a.flex_1]}
|
||||
onPress={onPress}
|
||||
label={
|
||||
isCurrentAccount
|
||||
? _(msg`Continue as ${account.handle} (currently signed in)`)
|
||||
: _(msg`Sign in as ${account.handle}`)
|
||||
}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={a.p_md}>
|
||||
<UserAvatar avatar={profile?.avatar} size={24} />
|
||||
</View>
|
||||
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
|
||||
<Text style={[a.font_bold]}>
|
||||
{profile?.displayName || account.handle}{' '}
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text>
|
||||
</Text>
|
||||
{isCurrentAccount ? (
|
||||
<Check
|
||||
size="sm"
|
||||
style={[{color: t.palette.positive_600}, a.mr_md]}
|
||||
/>
|
||||
) : (
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Button
|
||||
|
||||
`Button` is intended to be very flexible and can be used to create consistent
|
||||
button styles, as seen in the Storybook, or can be used to create custom
|
||||
buttons with totally independent styles.
|
||||
|
||||
## Usage
|
||||
|
||||
Typical usage looks like this, where the `variant`, `size`, `color`, and/or
|
||||
`shape` are defined. Normal usage also should make use of the `ButtonText` and
|
||||
`ButtonIcon` components so that they inherit styles from the top level props
|
||||
like `color`.
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
label='Typical button'
|
||||
onPress={() => {}}
|
||||
size='large'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
>
|
||||
<ButtonIcon icon={Plus} position='left' />
|
||||
<ButtonText>Hello world</ButtonText>
|
||||
</Button>
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
label='Typical button'
|
||||
onPress={() => {}}
|
||||
size='large'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
>
|
||||
<ButtonText>Hello world</ButtonText>
|
||||
<ButtonIcon icon={Plus} position='right' />
|
||||
</Button>
|
||||
```
|
||||
|
||||
Each of those top-level props props is optional in order to allow for "custom"
|
||||
buttons, like this:
|
||||
|
||||
```tsx
|
||||
<Button label='Custom button' onPress={() => {}}>
|
||||
{({ hovered, focused, pressed, disabled }) => (
|
||||
<View style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
hovered && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<Text>{disabled ? 'Disabled' : 'Click me!'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</Button>
|
||||
```
|
||||
|
||||
In the custom button case, you have the _option_ to use `ButtonIcon` and
|
||||
`ButtonText`, but without top-level props like `color`, they serve little
|
||||
purpose, and it may be perferrable to define your own components for these
|
||||
purposes.
|
||||
@@ -1,19 +1,20 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
PressableProps,
|
||||
TextProps,
|
||||
ViewStyle,
|
||||
AccessibilityProps,
|
||||
View,
|
||||
TextStyle,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
PressableProps,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextProps,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
|
||||
import {android, atoms as a, flatten, tokens, useTheme} from '#/alf'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {normalizeTextStyles} from '#/components/Typography'
|
||||
|
||||
@@ -403,7 +404,9 @@ export function Button({
|
||||
</View>
|
||||
)}
|
||||
<Context.Provider value={context}>
|
||||
{typeof children === 'string' ? (
|
||||
{/* @ts-ignore */}
|
||||
{typeof children === 'string' || children?.type === Trans ? (
|
||||
/* @ts-ignore */
|
||||
<ButtonText>{children}</ButtonText>
|
||||
) : typeof children === 'function' ? (
|
||||
children(context)
|
||||
@@ -1,31 +1,31 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {View, Dimensions, Keyboard, Pressable} from 'react-native'
|
||||
import {Dimensions, Pressable, View} from 'react-native'
|
||||
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import BottomSheet, {
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetScrollViewMethods,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
useBottomSheet,
|
||||
WINDOW_HEIGHT,
|
||||
} from '@gorhom/bottom-sheet'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||
} from '@discord/bottom-sheet/src'
|
||||
|
||||
import {useTheme, atoms as a, flatten} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {logger} from '#/logger'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
|
||||
import {isNative} from 'platform/detection'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {
|
||||
DialogOuterProps,
|
||||
DialogControlProps,
|
||||
DialogInnerProps,
|
||||
DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
|
||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/types'
|
||||
// @ts-ignore
|
||||
export const Input = createInput(BottomSheetTextInput)
|
||||
@@ -122,7 +122,6 @@ export function Outer({
|
||||
)
|
||||
|
||||
const onCloseInner = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
try {
|
||||
closeCallback.current?.()
|
||||
} catch (e: any) {
|
||||
@@ -206,12 +205,14 @@ export function Inner({children, style}: DialogInnerProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ScrollableInner({children, style}: DialogInnerProps) {
|
||||
export const ScrollableInner = React.forwardRef<
|
||||
BottomSheetScrollViewMethods,
|
||||
DialogInnerProps
|
||||
>(function ScrollableInner({children, style}, ref) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
style={[
|
||||
a.flex_1, // main diff is this
|
||||
a.p_xl,
|
||||
@@ -223,24 +224,19 @@ export function ScrollableInner({children, style}: DialogInnerProps) {
|
||||
},
|
||||
flatten(style),
|
||||
]}
|
||||
contentContainerStyle={isNative ? a.pb_4xl : undefined}>
|
||||
contentContainerStyle={isNative ? a.pb_4xl : undefined}
|
||||
ref={ref}>
|
||||
{children}
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||
</BottomSheetScrollView>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function Handle() {
|
||||
const t = useTheme()
|
||||
|
||||
const onTouchStart = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}
|
||||
onTouchStart={onTouchStart}>
|
||||
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from 'react'
|
||||
import type {AccessibilityProps} from 'react-native'
|
||||
import {BottomSheetProps} from '@gorhom/bottom-sheet'
|
||||
import type {
|
||||
AccessibilityProps,
|
||||
GestureResponderEvent,
|
||||
ScrollViewProps,
|
||||
} from 'react-native'
|
||||
import {BottomSheetProps} from '@discord/bottom-sheet/src'
|
||||
|
||||
import {ViewStyleProp} from '#/alf'
|
||||
|
||||
@@ -10,9 +14,15 @@ type A11yProps = Required<AccessibilityProps>
|
||||
* Mutated by useImperativeHandle to provide a public API for controlling the
|
||||
* dialog. The methods here will actually become the handlers defined within
|
||||
* the `Dialog.Outer` component.
|
||||
*
|
||||
* `Partial<GestureResponderEvent>` here allows us to add this directly to the
|
||||
* `onPress` prop of a button, for example. If this type was not added, we
|
||||
* would need to create a function to wrap `.open()` with.
|
||||
*/
|
||||
export type DialogControlRefProps = {
|
||||
open: (options?: DialogControlOpenOptions) => void
|
||||
open: (
|
||||
options?: DialogControlOpenOptions & Partial<GestureResponderEvent>,
|
||||
) => void
|
||||
close: (callback?: () => void) => void
|
||||
}
|
||||
|
||||
@@ -55,9 +65,11 @@ export type DialogInnerProps =
|
||||
label?: undefined
|
||||
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
||||
accessibilityDescribedBy: string
|
||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||
}>
|
||||
| DialogInnerPropsBase<{
|
||||
label: string
|
||||
accessibilityLabelledBy?: undefined
|
||||
accessibilityDescribedBy?: undefined
|
||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||
}>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-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'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export function Error({
|
||||
title,
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
title?: string
|
||||
message?: string
|
||||
onRetry?: () => unknown
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const canGoBack = navigation.canGoBack()
|
||||
const onGoBack = React.useCallback(() => {
|
||||
if (canGoBack) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
|
||||
// Checking the state for routes ensures that web doesn't encounter errors while going back
|
||||
if (navigation.getState()?.routes) {
|
||||
navigation.dispatch(StackActions.push(...router.matchPath('/')))
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
}
|
||||
}
|
||||
}, [navigation, canGoBack])
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>{title}</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_high,
|
||||
{lineHeight: 1.4},
|
||||
gtMobile && {width: 450},
|
||||
]}>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label={_(msg`Press to retry`)}
|
||||
onPress={onRetry}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label={_(msg`Return to previous page`)}
|
||||
onPress={onGoBack}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export function Default({
|
||||
}: LabelingServiceProps & ViewStyleProp) {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<Avatar />
|
||||
<Avatar avatar={labeler.creator.avatar} />
|
||||
<Content>
|
||||
<Title
|
||||
value={getLabelingServiceTitle({
|
||||
|
||||
@@ -1,47 +1,54 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
function renderItem({item}: {item: GetLikes.Like}) {
|
||||
return <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||
}
|
||||
|
||||
function keyExtractor(item: GetLikes.Like) {
|
||||
return item.actor.did
|
||||
}
|
||||
|
||||
export function LikedByList({uri}: {uri: string}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
|
||||
const {
|
||||
data: resolvedUri,
|
||||
error: resolveError,
|
||||
isFetching: isFetchingResolvedUri,
|
||||
isLoading: isUriLoading,
|
||||
} = useResolveUriQuery(uri)
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isRefetching,
|
||||
isLoading: isLikedByLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isError,
|
||||
error: likedByError,
|
||||
refetch,
|
||||
} = useLikedByQuery(resolvedUri?.uri)
|
||||
|
||||
const error = resolveError || likedByError
|
||||
const isError = !!resolveError || !!likedByError
|
||||
|
||||
const likes = React.useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return data.pages.flatMap(page => page.likes)
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const error = resolveError || likedByError
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setIsPTRing(true)
|
||||
@@ -54,56 +61,47 @@ export function LikedByList({uri}: {uri: string}) {
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more likes', {message: err})
|
||||
}
|
||||
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
||||
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => {
|
||||
if (likes.length < 1) {
|
||||
return (
|
||||
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (isFetchingResolvedUri || !isFetched) {
|
||||
return (
|
||||
<View style={[a.w_full, a.align_center, a.p_lg]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isUriLoading || isLikedByLoading}
|
||||
isError={isError}
|
||||
emptyType="results"
|
||||
emptyMessage={_(
|
||||
msg`Nobody has liked this yet. Maybe you should be the first!`,
|
||||
)}
|
||||
errorMessage={cleanError(resolveError || error)}
|
||||
onRetry={isError ? refetch : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return likes.length ? (
|
||||
return (
|
||||
<List
|
||||
data={likes}
|
||||
keyExtractor={item => item.actor.did}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={3}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={initialNumToRender}
|
||||
ListFooterComponent={() => (
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetching={isFetching && !isRefetching}
|
||||
isError={isError}
|
||||
error={error ? error.toString() : undefined}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
)}
|
||||
}
|
||||
onEndReachedThreshold={3}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
) : (
|
||||
<View style={[a.p_lg]}>
|
||||
<View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Nobody has liked this yet. Maybe you should be the first!
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
+43
-9
@@ -1,23 +1,24 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent} from 'react-native'
|
||||
import {useLinkProps, StackActions} from '@react-navigation/native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {StackActions, useLinkProps} from '@react-navigation/native'
|
||||
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
import {AllNavigatorParams} from '#/lib/routes/types'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {router} from '#/routes'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {useOpenLink} from 'state/preferences/in-app-browser'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {router} from '#/routes'
|
||||
|
||||
/**
|
||||
* Only available within a `Link`, since that inherits from `Button`.
|
||||
@@ -60,6 +61,11 @@ type BaseLinkProps = Pick<
|
||||
* Web-only attribute. Sets `download` attr on web.
|
||||
*/
|
||||
download?: string
|
||||
|
||||
/**
|
||||
* Native-only attribute. If true, will open the share sheet on long press.
|
||||
*/
|
||||
shareOnLongPress?: boolean
|
||||
}
|
||||
|
||||
export function useLink({
|
||||
@@ -68,6 +74,7 @@ export function useLink({
|
||||
action = 'push',
|
||||
disableMismatchWarning,
|
||||
onPress: outerOnPress,
|
||||
shareOnLongPress,
|
||||
}: BaseLinkProps & {
|
||||
displayText: string
|
||||
}) {
|
||||
@@ -157,10 +164,34 @@ export function useLink({
|
||||
],
|
||||
)
|
||||
|
||||
const handleLongPress = React.useCallback(() => {
|
||||
const requiresWarning = Boolean(
|
||||
!disableMismatchWarning &&
|
||||
displayText &&
|
||||
isExternal &&
|
||||
linkRequiresWarning(href, displayText),
|
||||
)
|
||||
|
||||
if (requiresWarning) {
|
||||
openModal({
|
||||
name: 'link-warning',
|
||||
text: displayText,
|
||||
href: href,
|
||||
share: true,
|
||||
})
|
||||
} else {
|
||||
shareUrl(href)
|
||||
}
|
||||
}, [disableMismatchWarning, displayText, href, isExternal, openModal])
|
||||
|
||||
const onLongPress =
|
||||
isNative && isExternal && shareOnLongPress ? handleLongPress : undefined
|
||||
|
||||
return {
|
||||
isExternal,
|
||||
href,
|
||||
onPress,
|
||||
onLongPress,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,16 +260,18 @@ export function InlineLink({
|
||||
download,
|
||||
selectable,
|
||||
label,
|
||||
shareOnLongPress,
|
||||
...rest
|
||||
}: InlineLinkProps) {
|
||||
const t = useTheme()
|
||||
const stringChildren = typeof children === 'string'
|
||||
const {href, isExternal, onPress} = useLink({
|
||||
const {href, isExternal, onPress, onLongPress} = useLink({
|
||||
to,
|
||||
displayText: stringChildren ? children : '',
|
||||
action,
|
||||
disableMismatchWarning,
|
||||
onPress: outerOnPress,
|
||||
shareOnLongPress,
|
||||
})
|
||||
const {
|
||||
state: hovered,
|
||||
@@ -270,6 +303,7 @@ export function InlineLink({
|
||||
]}
|
||||
role="link"
|
||||
onPress={download ? undefined : onPress}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
|
||||
+76
-130
@@ -1,26 +1,26 @@
|
||||
import React from 'react'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {View} from 'react-native'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
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 {Error} from '#/components/Error'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
import {router} from '#/routes'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
|
||||
export function ListFooter({
|
||||
isFetching,
|
||||
isError,
|
||||
isFetchingNextPage,
|
||||
error,
|
||||
onRetry,
|
||||
height,
|
||||
}: {
|
||||
isFetching: boolean
|
||||
isError: boolean
|
||||
isFetchingNextPage?: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
height?: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -29,37 +29,31 @@ export function ListFooter({
|
||||
style={[
|
||||
a.w_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.border_t,
|
||||
a.pb_lg,
|
||||
t.atoms.border_contrast_low,
|
||||
{height: 180},
|
||||
{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]}>
|
||||
@@ -83,7 +77,7 @@ function ListFooterMaybeError({
|
||||
</Text>
|
||||
<Button
|
||||
variant="gradient"
|
||||
label="Press to retry"
|
||||
label={_(msg`Press to retry`)}
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
@@ -93,7 +87,7 @@ function ListFooterMaybeError({
|
||||
a.py_sm,
|
||||
]}
|
||||
onPress={onRetry}>
|
||||
Retry
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -126,123 +120,75 @@ export function ListHeaderDesktop({
|
||||
|
||||
export function ListMaybePlaceholder({
|
||||
isLoading,
|
||||
isEmpty,
|
||||
noEmpty,
|
||||
isError,
|
||||
empty,
|
||||
error,
|
||||
notFoundType = 'page',
|
||||
emptyTitle,
|
||||
emptyMessage,
|
||||
errorTitle,
|
||||
errorMessage,
|
||||
emptyType = 'page',
|
||||
onRetry,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
isEmpty: boolean
|
||||
isError: boolean
|
||||
empty?: string
|
||||
error?: string
|
||||
notFoundType?: 'page' | 'results'
|
||||
noEmpty?: boolean
|
||||
isError?: boolean
|
||||
emptyTitle?: string
|
||||
emptyMessage?: string
|
||||
errorTitle?: string
|
||||
errorMessage?: string
|
||||
emptyType?: 'page' | 'results'
|
||||
onRetry?: () => Promise<unknown>
|
||||
}) {
|
||||
const navigation = useNavigationDeduped()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile, gtTablet} = useBreakpoints()
|
||||
|
||||
const canGoBack = navigation.canGoBack()
|
||||
const onGoBack = React.useCallback(() => {
|
||||
if (canGoBack) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
|
||||
// Checking the state for routes ensures that web doesn't encounter errors while going back
|
||||
if (navigation.getState()?.routes) {
|
||||
navigation.dispatch(StackActions.push(...router.matchPath('/')))
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
}
|
||||
}
|
||||
}, [navigation, canGoBack])
|
||||
|
||||
if (!isEmpty) return null
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders={gtMobile}
|
||||
topBorder={!gtTablet}>
|
||||
{isLoading ? (
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders={gtMobile}
|
||||
topBorder={!gtTablet}>
|
||||
<View style={[a.w_full, a.align_center, {top: 100}]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
{isError ? (
|
||||
<Trans>Oops!</Trans>
|
||||
) : isEmpty ? (
|
||||
<>
|
||||
{notFoundType === 'results' ? (
|
||||
<Trans>No results found</Trans>
|
||||
) : (
|
||||
<Trans>Page not found</Trans>
|
||||
)}
|
||||
</>
|
||||
) : undefined}
|
||||
</Text>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
{isError ? (
|
||||
<Text
|
||||
style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
|
||||
{error ? error : <Trans>Something went wrong!</Trans>}
|
||||
</Text>
|
||||
) : isEmpty ? (
|
||||
<Text
|
||||
style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
|
||||
{empty ? (
|
||||
empty
|
||||
) : (
|
||||
<Trans>
|
||||
We're sorry! We can't find the page you were looking for.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
<View
|
||||
style={[a.gap_md, !gtMobile ? [a.w_full, a.px_lg] : {width: 350}]}>
|
||||
{isError && onRetry && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label="Click here"
|
||||
onPress={onRetry}
|
||||
size="large"
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
{paddingVertical: 10},
|
||||
]}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color={isError && onRetry ? 'secondary' : 'primary'}
|
||||
label="Click here"
|
||||
onPress={onGoBack}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
Go Back
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</CenteredView>
|
||||
)
|
||||
if (isError) {
|
||||
return (
|
||||
<Error
|
||||
title={errorTitle ?? _(msg`Oops!`)}
|
||||
message={errorMessage ?? _(`Something went wrong!`)}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!noEmpty) {
|
||||
return (
|
||||
<Error
|
||||
title={
|
||||
emptyTitle ??
|
||||
(emptyType === 'results'
|
||||
? _(msg`No results found`)
|
||||
: _(msg`Page not found`))
|
||||
}
|
||||
message={
|
||||
emptyMessage ??
|
||||
_(msg`We're sorry! We can't find the page you were looking for.`)
|
||||
}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ItemIconProps,
|
||||
} from '#/components/Menu/types'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {isNative} from 'platform/detection'
|
||||
|
||||
@@ -209,7 +209,9 @@ function Cancel() {
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useTheme, atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonColor, ButtonText} from '#/components/Button'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {useDialogControl as usePromptControl} from '#/components/Dialog'
|
||||
|
||||
@@ -80,7 +79,7 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.gap_sm,
|
||||
a.gap_md,
|
||||
a.justify_end,
|
||||
gtMobile
|
||||
? [a.flex_row, a.flex_row_reverse, a.justify_start]
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, useButtonContext} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
|
||||
import * as LabelingServiceCard from '#/components/LabelingServiceCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
export function SelectLabelerView({
|
||||
@@ -22,31 +21,29 @@ export function SelectLabelerView({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
return (
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.justify_center, a.gap_sm]}>
|
||||
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Select moderation service</Trans>
|
||||
<Trans>Select moderator</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Who do you want to send this report to?</Trans>
|
||||
<Trans>To whom would you like to send this report?</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
{props.labelers.map(labeler => {
|
||||
return (
|
||||
<Button
|
||||
key={labeler.creator.did}
|
||||
label={_(msg`Send report to ${labeler.creator.displayName}`)}
|
||||
onPress={() => props.onSelectLabeler(labeler.creator.did)}>
|
||||
<LabelerButton
|
||||
title={labeler.creator.displayName || labeler.creator.handle}
|
||||
description={labeler.creator.description || ''}
|
||||
/>
|
||||
<LabelerButton labeler={labeler} />
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
@@ -56,60 +53,35 @@ export function SelectLabelerView({
|
||||
}
|
||||
|
||||
function LabelerButton({
|
||||
title,
|
||||
description,
|
||||
labeler,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {hovered, pressed} = useButtonContext()
|
||||
const interacted = hovered || pressed
|
||||
|
||||
const styles = React.useMemo(() => {
|
||||
return {
|
||||
interacted: {
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
},
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<View
|
||||
<LabelingServiceCard.Outer
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.p_md,
|
||||
a.rounded_md,
|
||||
{paddingRight: 70},
|
||||
interacted && styles.interacted,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
interacted && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[a.leading_tight, {maxWidth: 400}]} numberOfLines={3}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.justify_center,
|
||||
a.pr_md,
|
||||
{left: 'auto'},
|
||||
]}>
|
||||
<ChevronRight
|
||||
size="md"
|
||||
fill={
|
||||
hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color
|
||||
}
|
||||
<LabelingServiceCard.Avatar avatar={labeler.creator.avatar} />
|
||||
<LabelingServiceCard.Content>
|
||||
<LabelingServiceCard.Title
|
||||
value={getLabelingServiceTitle({
|
||||
displayName: labeler.creator.displayName,
|
||||
handle: labeler.creator.handle,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.text_sm, a.font_semibold]}>
|
||||
@{labeler.creator.handle}
|
||||
</Text>
|
||||
</LabelingServiceCard.Content>
|
||||
</LabelingServiceCard.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {DMCA_LINK} from '#/components/ReportDialog/const'
|
||||
import {ReportOption, useReportOptions} from '#/lib/moderation/useReportOptions'
|
||||
import {Link} from '#/components/Link'
|
||||
import {DMCA_LINK} from '#/components/ReportDialog/const'
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
@@ -19,11 +18,11 @@ import {
|
||||
} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {
|
||||
ChevronRight_Stroke2_Corner0_Rounded as ChevronRight,
|
||||
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft,
|
||||
ChevronRight_Stroke2_Corner0_Rounded as ChevronRight,
|
||||
} from '#/components/icons/Chevron'
|
||||
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
|
||||
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
export function SelectReportOptionView({
|
||||
@@ -35,6 +34,7 @@ export function SelectReportOptionView({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const allReportOptions = useReportOptions()
|
||||
const reportOptions = allReportOptions[props.params.type]
|
||||
|
||||
@@ -76,7 +76,7 @@ export function SelectReportOptionView({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<View style={[a.justify_center, a.gap_sm]}>
|
||||
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>{i18n.title}</Text>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
{i18n.description}
|
||||
@@ -85,7 +85,7 @@ export function SelectReportOptionView({
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
{reportOptions.map(reportOption => {
|
||||
return (
|
||||
<Button
|
||||
@@ -101,39 +101,37 @@ export function SelectReportOptionView({
|
||||
})}
|
||||
|
||||
{(props.params.type === 'post' || props.params.type === 'account') && (
|
||||
<View style={[a.pt_md, a.px_md]}>
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.p_md,
|
||||
a.pl_lg,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_900,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.p_md,
|
||||
a.pl_lg,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_900,
|
||||
a.flex_1,
|
||||
t.atoms.text_inverted,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.text_inverted,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
<Trans>Need to report a copyright violation?</Trans>
|
||||
</Text>
|
||||
<Link
|
||||
to={DMCA_LINK}
|
||||
label={_(msg`View details for reporting a copyright violation`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>View details</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon position="right" icon={SquareArrowTopRight} />
|
||||
</Link>
|
||||
</View>
|
||||
<Trans>Need to report a copyright violation?</Trans>
|
||||
</Text>
|
||||
<Link
|
||||
to={DMCA_LINK}
|
||||
label={_(msg`View details for reporting a copyright violation`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>View details</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon position="right" icon={SquareArrowTopRight} />
|
||||
</Link>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -152,14 +150,6 @@ function ReportOptionButton({
|
||||
const {hovered, pressed} = useButtonContext()
|
||||
const interacted = hovered || pressed
|
||||
|
||||
const styles = React.useMemo(() => {
|
||||
return {
|
||||
interacted: {
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
},
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -170,7 +160,8 @@ function ReportOptionButton({
|
||||
a.p_md,
|
||||
a.rounded_md,
|
||||
{paddingRight: 70},
|
||||
interacted && styles.interacted,
|
||||
t.atoms.bg_contrast_25,
|
||||
interacted && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
@@ -187,12 +178,7 @@ function ReportOptionButton({
|
||||
a.pr_md,
|
||||
{left: 'auto'},
|
||||
]}>
|
||||
<ChevronRight
|
||||
size="md"
|
||||
fill={
|
||||
hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color
|
||||
}
|
||||
/>
|
||||
<ChevronRight size="md" fill={t.atoms.text_contrast_low.color} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src'
|
||||
|
||||
import {ReportDialogProps} from './types'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||
import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {SelectLabelerView} from './SelectLabelerView'
|
||||
import {SelectReportOptionView} from './SelectReportOptionView'
|
||||
import {SubmitView} from './SubmitView'
|
||||
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
export function ReportDialog(props: ReportDialogProps) {
|
||||
return (
|
||||
@@ -29,6 +32,7 @@ export function ReportDialog(props: ReportDialogProps) {
|
||||
}
|
||||
|
||||
function ReportDialogInner(props: ReportDialogProps) {
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
isLoading: isLabelerLoading,
|
||||
data: labelers,
|
||||
@@ -36,8 +40,13 @@ function ReportDialogInner(props: ReportDialogProps) {
|
||||
} = useMyLabelersQuery()
|
||||
const isLoading = useDelayedLoading(500, isLabelerLoading)
|
||||
|
||||
const ref = React.useRef<BottomSheetScrollViewMethods>(null)
|
||||
useOnKeyboardDidShow(() => {
|
||||
ref.current?.scrollToEnd({animated: true})
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label="Report Dialog">
|
||||
<Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
|
||||
{isLoading ? (
|
||||
<View style={[a.align_center, {height: 100}]}>
|
||||
<Loader size="xl" />
|
||||
@@ -53,8 +62,6 @@ function ReportDialogInner(props: ReportDialogProps) {
|
||||
) : (
|
||||
<ReportDialogLoaded labelers={labelers} {...props} />
|
||||
)}
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -264,7 +264,9 @@ export function TagMenu({
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function TagMenu({
|
||||
author: authorHandle,
|
||||
})
|
||||
},
|
||||
testID: 'tagMenuSeachByUser',
|
||||
testID: 'tagMenuSearchByUser',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'magnifyingglass',
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Text as RNText,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
TextProps as RNTextProps,
|
||||
} from 'react-native'
|
||||
import {UITextView} from 'react-native-ui-text-view'
|
||||
import {StyleProp, TextProps as RNTextProps, TextStyle} from 'react-native'
|
||||
import {UITextView} from 'react-native-uitextview'
|
||||
|
||||
import {useTheme, atoms, web, flatten} from '#/alf'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms, flatten, useTheme, web} from '#/alf'
|
||||
|
||||
export type TextProps = RNTextProps & {
|
||||
/**
|
||||
@@ -61,11 +56,8 @@ export function normalizeTextStyles(styles: StyleProp<TextStyle>) {
|
||||
export function Text({style, selectable, ...rest}: TextProps) {
|
||||
const t = useTheme()
|
||||
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
|
||||
return selectable && isIOS ? (
|
||||
<UITextView style={s} {...rest} />
|
||||
) : (
|
||||
<RNText selectable={selectable} style={s} {...rest} />
|
||||
)
|
||||
|
||||
return <UITextView selectable={selectable} uiTextView style={s} {...rest} />
|
||||
}
|
||||
|
||||
export function createHeadingElement({level}: {level: number}) {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import React from 'react'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '../Typography'
|
||||
import {DateInput} from '#/view/com/util/forms/DateInput'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetBirthDateMutation,
|
||||
UsePreferencesQueryResponse,
|
||||
usePreferencesSetBirthDateMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {DateInput} from '#/view/com/util/forms/DateInput'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function BirthDateSettingsDialog({
|
||||
control,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {AccountList} from '../AccountList'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function SwitchAccountDialog({
|
||||
control,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
const onSelectAccount = useCallback(
|
||||
(account: SessionAccount) => {
|
||||
if (account.did === currentAccount?.did) {
|
||||
control.close()
|
||||
} else {
|
||||
onPressSwitchAccount(account, 'SwitchAccount')
|
||||
}
|
||||
},
|
||||
[currentAccount, control, onPressSwitchAccount],
|
||||
)
|
||||
|
||||
const onPressAddAccount = useCallback(() => {
|
||||
setShowLoggedOut(true)
|
||||
closeAllActiveElements()
|
||||
}, [setShowLoggedOut, closeAllActiveElements])
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Switch Account</Trans>
|
||||
</Text>
|
||||
|
||||
<AccountList
|
||||
onSelectAccount={onSelectAccount}
|
||||
onSelectOther={onPressAddAccount}
|
||||
otherLabel={_(msg`Add account`)}
|
||||
/>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
|
||||
import {useTheme, atoms} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import {
|
||||
localizeDate,
|
||||
toSimpleDateString,
|
||||
} from '#/components/forms/DateField/utils'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
|
||||
import {useTheme} from '#/alf'
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {DateFieldButton} from './index.shared'
|
||||
|
||||
export * as utils from '#/components/forms/DateField/utils'
|
||||
export const Label = TextField.Label
|
||||
@@ -24,18 +16,10 @@ export function DateField({
|
||||
label,
|
||||
isInvalid,
|
||||
testID,
|
||||
accessibilityHint,
|
||||
}: DateFieldProps) {
|
||||
const t = useTheme()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
|
||||
const {chromeFocus, chromeError, chromeErrorHover} =
|
||||
TextField.useSharedInputStyles()
|
||||
|
||||
const onChangeInternal = React.useCallback(
|
||||
(date: Date) => {
|
||||
@@ -47,50 +31,29 @@ export function DateField({
|
||||
[onChangeDate, setOpen],
|
||||
)
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const onCancel = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[atoms.relative, atoms.w_full]}>
|
||||
<Pressable
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
onPress={() => setOpen(true)}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
{
|
||||
paddingTop: 16,
|
||||
paddingBottom: 16,
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
},
|
||||
atoms.flex_row,
|
||||
atoms.flex_1,
|
||||
atoms.w_full,
|
||||
atoms.px_lg,
|
||||
atoms.rounded_sm,
|
||||
t.atoms.bg_contrast_50,
|
||||
focused || pressed ? chromeFocus : {},
|
||||
isInvalid ? chromeError : {},
|
||||
isInvalid && (focused || pressed) ? chromeErrorHover : {},
|
||||
]}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
|
||||
<Text
|
||||
style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}>
|
||||
{localizeDate(value)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<>
|
||||
<DateFieldButton
|
||||
label={label}
|
||||
value={value}
|
||||
onPress={onPress}
|
||||
isInvalid={isInvalid}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<DatePicker
|
||||
modal={isAndroid}
|
||||
open={isAndroid}
|
||||
modal
|
||||
open
|
||||
timeZoneOffsetInMinutes={0}
|
||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||
date={new Date(value)}
|
||||
onConfirm={onChangeInternal}
|
||||
@@ -99,9 +62,9 @@ export function DateField({
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
|
||||
import {android, atoms as a, useTheme, web} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {localizeDate} from './utils'
|
||||
|
||||
// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
|
||||
// iOS: open a dialog with an inline date picker
|
||||
// Android: open the date picker modal
|
||||
|
||||
export function DateFieldButton({
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
isInvalid,
|
||||
accessibilityHint,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onPress: () => void
|
||||
isInvalid?: boolean
|
||||
accessibilityHint?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
|
||||
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
|
||||
TextField.useSharedInputStyles()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.relative, a.w_full]}
|
||||
{...web({
|
||||
onMouseOver: onHoverIn,
|
||||
onMouseOut: onHoverOut,
|
||||
})}>
|
||||
<Pressable
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
{
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
},
|
||||
android({
|
||||
minHeight: 57.5,
|
||||
}),
|
||||
a.flex_row,
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.align_center,
|
||||
hovered ? chromeHover : {},
|
||||
focused || pressed ? chromeFocus : {},
|
||||
isInvalid || isInvalid ? chromeError : {},
|
||||
(isInvalid || isInvalid) && (hovered || focused)
|
||||
? chromeErrorHover
|
||||
: {},
|
||||
]}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.pl_xs,
|
||||
t.atoms.text,
|
||||
{lineHeight: a.text_md.fontSize * 1.1875},
|
||||
]}>
|
||||
{localizeDate(value)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {useTheme, atoms} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {DateFieldButton} from './index.shared'
|
||||
|
||||
export * as utils from '#/components/forms/DateField/utils'
|
||||
export const Label = TextField.Label
|
||||
@@ -22,8 +27,12 @@ export function DateField({
|
||||
onChangeDate,
|
||||
testID,
|
||||
label,
|
||||
isInvalid,
|
||||
accessibilityHint,
|
||||
}: DateFieldProps) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
const onChangeInternal = React.useCallback(
|
||||
(date: Date | undefined) => {
|
||||
@@ -36,17 +45,44 @@ export function DateField({
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[atoms.relative, atoms.w_full]}>
|
||||
<DatePicker
|
||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||
date={new Date(value)}
|
||||
onDateChange={onChangeInternal}
|
||||
mode="date"
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
<>
|
||||
<DateFieldButton
|
||||
label={label}
|
||||
value={value}
|
||||
onPress={control.open}
|
||||
isInvalid={isInvalid}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
</View>
|
||||
<Dialog.Outer control={control} testID={testID}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.Inner label={label}>
|
||||
<View style={a.gap_lg}>
|
||||
<View style={[a.relative, a.w_full, a.align_center]}>
|
||||
<DatePicker
|
||||
timeZoneOffsetInMinutes={0}
|
||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||
date={new Date(value)}
|
||||
onDateChange={onChangeInternal}
|
||||
mode="date"
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Done`)}
|
||||
onPress={() => control.close()}
|
||||
size="medium"
|
||||
color="primary"
|
||||
variant="solid">
|
||||
<ButtonText>
|
||||
<Trans>Done</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Dialog.Inner>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react'
|
||||
import {TextInput, TextInputProps, StyleSheet} from 'react-native'
|
||||
import {StyleSheet, TextInput, TextInputProps} from 'react-native'
|
||||
// @ts-ignore
|
||||
import {unstable_createElement} from 'react-native-web'
|
||||
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||
|
||||
export * as utils from '#/components/forms/DateField/utils'
|
||||
export const Label = TextField.Label
|
||||
@@ -37,6 +38,7 @@ export function DateField({
|
||||
label,
|
||||
isInvalid,
|
||||
testID,
|
||||
accessibilityHint,
|
||||
}: DateFieldProps) {
|
||||
const handleOnChange = React.useCallback(
|
||||
(e: any) => {
|
||||
@@ -52,12 +54,14 @@ export function DateField({
|
||||
|
||||
return (
|
||||
<TextField.Root isInvalid={isInvalid}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
<Input
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={handleOnChange}
|
||||
onChangeText={() => {}}
|
||||
testID={testID}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
</TextField.Root>
|
||||
)
|
||||
|
||||
@@ -4,4 +4,5 @@ export type DateFieldProps = {
|
||||
label: string
|
||||
isInvalid?: boolean
|
||||
testID?: string
|
||||
accessibilityHint?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function FormError({error}: {error?: string}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!error) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: t.palette.negative_400},
|
||||
a.flex_row,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Warning fill={t.palette.white} size="md" />
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[{color: t.palette.white}, a.font_bold, a.leading_snug]}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {ServerInputDialog} from '#/view/com/auth/server-input'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||
import {PencilLine_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
|
||||
import {Button} from '../Button'
|
||||
import {useDialogControl} from '../Dialog'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function HostingProvider({
|
||||
serviceUrl,
|
||||
onSelectServiceUrl,
|
||||
onOpenDialog,
|
||||
}: {
|
||||
serviceUrl: string
|
||||
onSelectServiceUrl: (provider: string) => void
|
||||
onOpenDialog?: () => void
|
||||
}) {
|
||||
const serverInputControl = useDialogControl()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const onPressSelectService = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
serverInputControl.open()
|
||||
if (onOpenDialog) {
|
||||
onOpenDialog()
|
||||
}
|
||||
}, [onOpenDialog, serverInputControl])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={onSelectServiceUrl}
|
||||
/>
|
||||
<Button
|
||||
label={toNiceDomain(serviceUrl)}
|
||||
accessibilityHint={_(msg`Press to change hosting provider`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_sm,
|
||||
a.px_md,
|
||||
a.pr_sm,
|
||||
a.gap_xs,
|
||||
{paddingVertical: isAndroid ? 14 : 9},
|
||||
]}
|
||||
onPress={onPressSelectService}>
|
||||
{({hovered, pressed}) => {
|
||||
const interacted = hovered || pressed
|
||||
return (
|
||||
<>
|
||||
<View style={a.pr_xs}>
|
||||
<Globe
|
||||
size="md"
|
||||
fill={
|
||||
interacted ? t.palette.contrast_800 : t.palette.contrast_500
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
interacted
|
||||
? t.atoms.bg_contrast_300
|
||||
: t.atoms.bg_contrast_100,
|
||||
{marginLeft: 'auto', padding: 6},
|
||||
]}>
|
||||
<Pencil
|
||||
size="sm"
|
||||
style={{
|
||||
color: interacted
|
||||
? t.palette.contrast_800
|
||||
: t.palette.contrast_500,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
View,
|
||||
AccessibilityProps,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TextInputProps,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
StyleSheet,
|
||||
AccessibilityProps,
|
||||
} from 'react-native'
|
||||
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {HITSLOP_20} from 'lib/constants'
|
||||
import {useTheme, atoms as a, web, android} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {android, atoms as a, useTheme, web} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const Context = React.createContext<{
|
||||
inputRef: React.RefObject<TextInput> | null
|
||||
@@ -125,9 +126,10 @@ export function useSharedInputStyles() {
|
||||
|
||||
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
||||
label: string
|
||||
value: string
|
||||
onChangeText: (value: string) => void
|
||||
value?: string
|
||||
onChangeText?: (value: string) => void
|
||||
isInvalid?: boolean
|
||||
inputRef?: React.RefObject<TextInput>
|
||||
}
|
||||
|
||||
export function createInput(Component: typeof TextInput) {
|
||||
@@ -137,6 +139,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
value,
|
||||
onChangeText,
|
||||
isInvalid,
|
||||
inputRef,
|
||||
...rest
|
||||
}: InputProps) {
|
||||
const t = useTheme()
|
||||
@@ -161,19 +164,22 @@ export function createInput(Component: typeof TextInput) {
|
||||
)
|
||||
}
|
||||
|
||||
const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Component
|
||||
accessibilityHint={undefined}
|
||||
{...rest}
|
||||
accessibilityLabel={label}
|
||||
ref={ctx.inputRef}
|
||||
ref={refs}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={ctx.onFocus}
|
||||
onBlur={ctx.onBlur}
|
||||
placeholder={placeholder || label}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
hitSlop={HITSLOP_20}
|
||||
style={[
|
||||
a.relative,
|
||||
@@ -271,7 +277,7 @@ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
|
||||
<Comp
|
||||
size="md"
|
||||
style={[
|
||||
{color: t.palette.contrast_500, pointerEvents: 'none'},
|
||||
{color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0},
|
||||
ctx.hovered ? hover : {},
|
||||
ctx.focused ? focus : {},
|
||||
ctx.isInvalid && ctx.hovered ? errorHover : {},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import {Keyboard} from 'react-native'
|
||||
|
||||
export function useOnKeyboardDidShow(cb: () => unknown) {
|
||||
React.useEffect(() => {
|
||||
const subscription = Keyboard.addListener('keyboardDidShow', cb)
|
||||
|
||||
return () => {
|
||||
subscription.remove()
|
||||
}
|
||||
}, [cb])
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Calendar_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const PencilLine_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M15.586 2.5a2 2 0 0 1 2.828 0L21.5 5.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 7.086 22H3a1 1 0 0 1-1-1v-4.086a2 2 0 0 1 .586-1.414l13-13ZM17 3.914l-13 13V20h3.086l13-13L17 3.914ZM13 21a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Ticket_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z',
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetContentLabelMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
|
||||
export function GlobalModerationLabelPref({
|
||||
labelValueDefinition,
|
||||
disabled,
|
||||
}: {
|
||||
labelValueDefinition: InterpretedLabelValueDefinition
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const {identifier} = labelValueDefinition
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const savedPref = preferences?.moderationPrefs.labels[identifier]
|
||||
const pref = variables?.visibility ?? savedPref ?? 'warn'
|
||||
|
||||
const allLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings =
|
||||
labelValueDefinition.identifier in allLabelStrings
|
||||
? allLabelStrings[labelValueDefinition.identifier]
|
||||
: {
|
||||
name: labelValueDefinition.identifier,
|
||||
description: `Labeled "${labelValueDefinition.identifier}"`,
|
||||
}
|
||||
|
||||
const labelOptions = {
|
||||
hide: _(msg`Hide`),
|
||||
warn: _(msg`Warn`),
|
||||
ignore: _(msg`Show`),
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
a.py_md,
|
||||
a.pl_lg,
|
||||
a.pr_md,
|
||||
a.align_center,
|
||||
]}>
|
||||
<View style={[a.gap_xs, a.flex_1]}>
|
||||
<Text style={[a.font_bold]}>{labelStrings.name}</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{labelStrings.description}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.justify_center, {minHeight: 35}]}>
|
||||
{!disabled && (
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
|
||||
)}
|
||||
values={[pref]}
|
||||
onChange={newPref =>
|
||||
mutate({
|
||||
label: identifier,
|
||||
visibility: newPref[0] as LabelPreference,
|
||||
labelerDid: undefined,
|
||||
})
|
||||
}>
|
||||
<ToggleButton.Button name="ignore" label={labelOptions.ignore}>
|
||||
{labelOptions.ignore}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="warn" label={labelOptions.warn}>
|
||||
{labelOptions.warn}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="hide" label={labelOptions.hide}>
|
||||
{labelOptions.hide}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
|
||||
import {getLabelStrings} from '#/lib/moderation/useLabelInfo'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetContentLabelMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo'
|
||||
|
||||
export function Outer({children}: React.PropsWithChildren<{}>) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
a.justify_between,
|
||||
a.flex_wrap,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Content({
|
||||
children,
|
||||
name,
|
||||
description,
|
||||
}: React.PropsWithChildren<{
|
||||
name: string
|
||||
description: string
|
||||
}>) {
|
||||
const t = useTheme()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
return (
|
||||
<View style={[a.gap_xs, a.flex_1]}>
|
||||
<Text style={[a.font_bold, gtPhone ? a.text_sm : a.text_md]}>{name}</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Buttons({
|
||||
name,
|
||||
values,
|
||||
onChange,
|
||||
ignoreLabel,
|
||||
warnLabel,
|
||||
hideLabel,
|
||||
}: {
|
||||
name: string
|
||||
values: ToggleButton.GroupProps['values']
|
||||
onChange: ToggleButton.GroupProps['onChange']
|
||||
ignoreLabel?: string
|
||||
warnLabel?: string
|
||||
hideLabel?: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
return (
|
||||
<View style={[{minHeight: 35}, gtPhone ? undefined : a.w_full]}>
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${name}`,
|
||||
)}
|
||||
values={values}
|
||||
onChange={onChange}>
|
||||
{ignoreLabel && (
|
||||
<ToggleButton.Button name="ignore" label={ignoreLabel}>
|
||||
{ignoreLabel}
|
||||
</ToggleButton.Button>
|
||||
)}
|
||||
{warnLabel && (
|
||||
<ToggleButton.Button name="warn" label={warnLabel}>
|
||||
{warnLabel}
|
||||
</ToggleButton.Button>
|
||||
)}
|
||||
{hideLabel && (
|
||||
<ToggleButton.Button name="hide" label={hideLabel}>
|
||||
{hideLabel}
|
||||
</ToggleButton.Button>
|
||||
)}
|
||||
</ToggleButton.Group>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* For use on the global Moderation screen to set prefs for a "global" label,
|
||||
* not scoped to a single labeler.
|
||||
*/
|
||||
export function GlobalLabelPreference({
|
||||
labelDefinition,
|
||||
disabled,
|
||||
}: {
|
||||
labelDefinition: InterpretedLabelValueDefinition
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const {identifier} = labelDefinition
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const savedPref = preferences?.moderationPrefs.labels[identifier]
|
||||
const pref = variables?.visibility ?? savedPref ?? 'warn'
|
||||
|
||||
const allLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings =
|
||||
labelDefinition.identifier in allLabelStrings
|
||||
? allLabelStrings[labelDefinition.identifier]
|
||||
: {
|
||||
name: labelDefinition.identifier,
|
||||
description: `Labeled "${labelDefinition.identifier}"`,
|
||||
}
|
||||
|
||||
const labelOptions = {
|
||||
hide: _(msg`Hide`),
|
||||
warn: _(msg`Warn`),
|
||||
ignore: _(msg`Show`),
|
||||
}
|
||||
|
||||
return (
|
||||
<Outer>
|
||||
<Content
|
||||
name={labelStrings.name}
|
||||
description={labelStrings.description}
|
||||
/>
|
||||
{!disabled && (
|
||||
<Buttons
|
||||
name={labelStrings.name.toLowerCase()}
|
||||
values={[pref]}
|
||||
onChange={values => {
|
||||
mutate({
|
||||
label: identifier,
|
||||
visibility: values[0] as LabelPreference,
|
||||
labelerDid: undefined,
|
||||
})
|
||||
}}
|
||||
ignoreLabel={labelOptions.ignore}
|
||||
warnLabel={labelOptions.warn}
|
||||
hideLabel={labelOptions.hide}
|
||||
/>
|
||||
)}
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* For use on individual labeler pages
|
||||
*/
|
||||
export function LabelerLabelPreference({
|
||||
labelDefinition,
|
||||
disabled,
|
||||
labelerDid,
|
||||
}: {
|
||||
labelDefinition: InterpretedLabelValueDefinition
|
||||
disabled?: boolean
|
||||
labelerDid?: string
|
||||
}) {
|
||||
const {i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
const isGlobalLabel = !labelDefinition.definedBy
|
||||
const {identifier} = labelDefinition
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const savedPref =
|
||||
labelerDid && !isGlobalLabel
|
||||
? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid)
|
||||
?.labels[identifier]
|
||||
: preferences?.moderationPrefs.labels[identifier]
|
||||
const pref =
|
||||
variables?.visibility ??
|
||||
savedPref ??
|
||||
labelDefinition.defaultSetting ??
|
||||
'warn'
|
||||
|
||||
// does the 'warn' setting make sense for this label?
|
||||
const canWarn = !(
|
||||
labelDefinition.blurs === 'none' && labelDefinition.severity === 'none'
|
||||
)
|
||||
// is this label adult only?
|
||||
const adultOnly = labelDefinition.flags.includes('adult')
|
||||
// is this label disabled because it's adult only?
|
||||
const adultDisabled =
|
||||
adultOnly && !preferences?.moderationPrefs.adultContentEnabled
|
||||
// are there any reasons we cant configure this label here?
|
||||
const cantConfigure = isGlobalLabel || adultDisabled
|
||||
const showConfig = !disabled && (gtPhone || !cantConfigure)
|
||||
|
||||
// adjust the pref based on whether warn is available
|
||||
let prefAdjusted = pref
|
||||
if (adultDisabled) {
|
||||
prefAdjusted = 'hide'
|
||||
} else if (!canWarn && pref === 'warn') {
|
||||
prefAdjusted = 'ignore'
|
||||
}
|
||||
|
||||
// grab localized descriptions of the label and its settings
|
||||
const currentPrefLabel = useLabelBehaviorDescription(
|
||||
labelDefinition,
|
||||
prefAdjusted,
|
||||
)
|
||||
const hideLabel = useLabelBehaviorDescription(labelDefinition, 'hide')
|
||||
const warnLabel = useLabelBehaviorDescription(labelDefinition, 'warn')
|
||||
const ignoreLabel = useLabelBehaviorDescription(labelDefinition, 'ignore')
|
||||
const globalLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings = getLabelStrings(
|
||||
i18n.locale,
|
||||
globalLabelStrings,
|
||||
labelDefinition,
|
||||
)
|
||||
|
||||
return (
|
||||
<Outer>
|
||||
<Content name={labelStrings.name} description={labelStrings.description}>
|
||||
{cantConfigure && (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}>
|
||||
<CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} />
|
||||
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}>
|
||||
{adultDisabled ? (
|
||||
<Trans>Adult content is disabled.</Trans>
|
||||
) : isGlobalLabel ? (
|
||||
<Trans>
|
||||
Configured in{' '}
|
||||
<InlineLink to="/moderation" style={a.text_sm}>
|
||||
moderation settings
|
||||
</InlineLink>
|
||||
.
|
||||
</Trans>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Content>
|
||||
|
||||
{showConfig && (
|
||||
<View style={[gtPhone ? undefined : a.w_full]}>
|
||||
{cantConfigure ? (
|
||||
<View
|
||||
style={[
|
||||
{minHeight: 35},
|
||||
a.px_md,
|
||||
a.py_md,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_low]}>
|
||||
{currentPrefLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Buttons
|
||||
name={labelStrings.name.toLowerCase()}
|
||||
values={[pref]}
|
||||
onChange={values => {
|
||||
mutate({
|
||||
label: identifier,
|
||||
visibility: values[0] as LabelPreference,
|
||||
labelerDid,
|
||||
})
|
||||
}}
|
||||
ignoreLabel={ignoreLabel}
|
||||
warnLabel={canWarn ? warnLabel : undefined}
|
||||
hideLabel={hideLabel}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetContentLabelMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {getLabelStrings} from '#/lib/moderation/useLabelInfo'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
|
||||
export function ModerationLabelPref({
|
||||
labelValueDefinition,
|
||||
labelerDid,
|
||||
disabled,
|
||||
}: {
|
||||
labelValueDefinition: InterpretedLabelValueDefinition
|
||||
labelerDid: string | undefined
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_, i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const isGlobalLabel = !labelValueDefinition.definedBy
|
||||
const {identifier} = labelValueDefinition
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const savedPref =
|
||||
labelerDid && !isGlobalLabel
|
||||
? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid)
|
||||
?.labels[identifier]
|
||||
: preferences?.moderationPrefs.labels[identifier]
|
||||
const pref =
|
||||
variables?.visibility ??
|
||||
savedPref ??
|
||||
labelValueDefinition.defaultSetting ??
|
||||
'warn'
|
||||
|
||||
// does the 'warn' setting make sense for this label?
|
||||
const canWarn = !(
|
||||
labelValueDefinition.blurs === 'none' &&
|
||||
labelValueDefinition.severity === 'none'
|
||||
)
|
||||
// is this label adult only?
|
||||
const adultOnly = labelValueDefinition.flags.includes('adult')
|
||||
// is this label disabled because it's adult only?
|
||||
const adultDisabled =
|
||||
adultOnly && !preferences?.moderationPrefs.adultContentEnabled
|
||||
// are there any reasons we cant configure this label here?
|
||||
const cantConfigure = isGlobalLabel || adultDisabled
|
||||
|
||||
// adjust the pref based on whether warn is available
|
||||
let prefAdjusted = pref
|
||||
if (adultDisabled) {
|
||||
prefAdjusted = 'hide'
|
||||
} else if (!canWarn && pref === 'warn') {
|
||||
prefAdjusted = 'ignore'
|
||||
}
|
||||
|
||||
// grab localized descriptions of the label and its settings
|
||||
const currentPrefLabel = useLabelBehaviorDescription(
|
||||
labelValueDefinition,
|
||||
prefAdjusted,
|
||||
)
|
||||
const hideLabel = useLabelBehaviorDescription(labelValueDefinition, 'hide')
|
||||
const warnLabel = useLabelBehaviorDescription(labelValueDefinition, 'warn')
|
||||
const ignoreLabel = useLabelBehaviorDescription(
|
||||
labelValueDefinition,
|
||||
'ignore',
|
||||
)
|
||||
const globalLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings = getLabelStrings(
|
||||
i18n.locale,
|
||||
globalLabelStrings,
|
||||
labelValueDefinition,
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_sm, a.px_lg, a.py_lg, a.justify_between]}>
|
||||
<View style={[a.gap_xs, a.flex_1]}>
|
||||
<Text style={[a.font_bold]}>{labelStrings.name}</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{labelStrings.description}
|
||||
</Text>
|
||||
|
||||
{cantConfigure && (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}>
|
||||
<CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} />
|
||||
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}>
|
||||
{adultDisabled ? (
|
||||
<Trans>Adult content is disabled.</Trans>
|
||||
) : isGlobalLabel ? (
|
||||
<Trans>
|
||||
Configured in{' '}
|
||||
<InlineLink to="/moderation" style={a.text_sm}>
|
||||
moderation settings
|
||||
</InlineLink>
|
||||
.
|
||||
</Trans>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{disabled ? (
|
||||
<></>
|
||||
) : cantConfigure ? (
|
||||
<View style={[{minHeight: 35}, a.px_sm, a.py_md]}>
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
{currentPrefLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[{minHeight: 35}]}>
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
|
||||
)}
|
||||
values={[prefAdjusted]}
|
||||
onChange={newPref =>
|
||||
mutate({
|
||||
label: identifier,
|
||||
visibility: newPref[0] as LabelPreference,
|
||||
labelerDid,
|
||||
})
|
||||
}>
|
||||
<ToggleButton.Button name="ignore" label={ignoreLabel}>
|
||||
{ignoreLabel}
|
||||
</ToggleButton.Button>
|
||||
{canWarn && (
|
||||
<ToggleButton.Button name="warn" label={warnLabel}>
|
||||
{warnLabel}
|
||||
</ToggleButton.Button>
|
||||
)}
|
||||
<ToggleButton.Button name="hide" label={hideLabel}>
|
||||
{hideLabel}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
@@ -56,7 +55,8 @@ export function ScreenHider({
|
||||
|
||||
const isNoPwi = !!modui.blurs.find(
|
||||
cause =>
|
||||
cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated',
|
||||
cause.type === 'label' &&
|
||||
cause.labelDef.identifier === '!no-unauthenticated',
|
||||
)
|
||||
return (
|
||||
<CenteredView
|
||||
@@ -124,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>
|
||||
|
||||
+7
-3
@@ -1,5 +1,9 @@
|
||||
import VersionNumber from 'react-native-version-number'
|
||||
import * as Updates from 'expo-updates'
|
||||
export const updateChannel = Updates.channel
|
||||
|
||||
export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})`
|
||||
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
|
||||
export const appVersion = `${VersionNumber.appVersion} (${
|
||||
VersionNumber.buildVersion
|
||||
}, ${IS_DEV ? 'development' : UPDATES_CHANNEL})`
|
||||
|
||||
@@ -6,6 +6,7 @@ import {useSessionApi, SessionAccount} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {LogEvents} from '../statsig/statsig'
|
||||
|
||||
export function useAccountSwitcher() {
|
||||
const {track} = useAnalytics()
|
||||
@@ -14,7 +15,10 @@ export function useAccountSwitcher() {
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
|
||||
const onPressSwitchAccount = useCallback(
|
||||
async (account: SessionAccount) => {
|
||||
async (
|
||||
account: SessionAccount,
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => {
|
||||
track('Settings:SwitchAccountButtonClicked')
|
||||
|
||||
try {
|
||||
@@ -28,7 +32,7 @@ export function useAccountSwitcher() {
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
await selectAccount(account)
|
||||
await selectAccount(account, logContext)
|
||||
setTimeout(() => {
|
||||
Toast.show(`Signed in as @${account.handle}`)
|
||||
}, 100)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import React from 'react'
|
||||
import {Alert, AppState, AppStateStatus} from 'react-native'
|
||||
import app from 'react-native-version-number'
|
||||
import {
|
||||
checkForUpdateAsync,
|
||||
fetchUpdateAsync,
|
||||
isEnabled,
|
||||
reloadAsync,
|
||||
setExtraParamAsync,
|
||||
useUpdates,
|
||||
} from 'expo-updates'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {IS_TESTFLIGHT} from 'lib/app-info'
|
||||
import {isIOS} from 'platform/detection'
|
||||
|
||||
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
|
||||
|
||||
async function setExtraParams() {
|
||||
await setExtraParamAsync(
|
||||
isIOS ? 'ios-build-number' : 'android-build-number',
|
||||
// Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
|
||||
// This just ensures it gets passed as a string
|
||||
`${app.buildVersion}`,
|
||||
)
|
||||
await setExtraParamAsync(
|
||||
'channel',
|
||||
IS_TESTFLIGHT ? 'testflight' : 'production',
|
||||
)
|
||||
}
|
||||
|
||||
export function useOTAUpdates() {
|
||||
const appState = React.useRef<AppStateStatus>('active')
|
||||
const lastMinimize = React.useRef(0)
|
||||
const ranInitialCheck = React.useRef(false)
|
||||
const timeout = React.useRef<NodeJS.Timeout>()
|
||||
const {isUpdatePending} = useUpdates()
|
||||
|
||||
const setCheckTimeout = React.useCallback(() => {
|
||||
timeout.current = setTimeout(async () => {
|
||||
try {
|
||||
await setExtraParams()
|
||||
|
||||
logger.debug('Checking for update...')
|
||||
const res = await checkForUpdateAsync()
|
||||
|
||||
if (res.isAvailable) {
|
||||
logger.debug('Attempting to fetch update...')
|
||||
await fetchUpdateAsync()
|
||||
} else {
|
||||
logger.debug('No update available.')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('OTA Update Error', {error: `${e}`})
|
||||
}
|
||||
}, 10e3)
|
||||
}, [])
|
||||
|
||||
const onIsTestFlight = React.useCallback(() => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await setExtraParams()
|
||||
|
||||
const res = await checkForUpdateAsync()
|
||||
if (res.isAvailable) {
|
||||
await fetchUpdateAsync()
|
||||
|
||||
Alert.alert(
|
||||
'Update Available',
|
||||
'A new version of the app is available. Relaunch now?',
|
||||
[
|
||||
{
|
||||
text: 'No',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Relaunch',
|
||||
style: 'default',
|
||||
onPress: async () => {
|
||||
await reloadAsync()
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// No need to handle
|
||||
}
|
||||
}, 3e3)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
// For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
|
||||
// is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
|
||||
// immediately.
|
||||
if (IS_TESTFLIGHT) {
|
||||
onIsTestFlight()
|
||||
return
|
||||
} else if (!isEnabled || __DEV__ || ranInitialCheck.current) {
|
||||
// Development client shouldn't check for updates at all, so we skip that here.
|
||||
return
|
||||
}
|
||||
|
||||
setCheckTimeout()
|
||||
ranInitialCheck.current = true
|
||||
}, [onIsTestFlight, setCheckTimeout])
|
||||
|
||||
// After the app has been minimized for 30 minutes, we want to either A. install an update if one has become available
|
||||
// or B check for an update again.
|
||||
React.useEffect(() => {
|
||||
if (!isEnabled) return
|
||||
|
||||
const subscription = AppState.addEventListener(
|
||||
'change',
|
||||
async nextAppState => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === 'active'
|
||||
) {
|
||||
// If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since
|
||||
// chances are that there isn't anything important going on in the current session.
|
||||
if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) {
|
||||
if (isUpdatePending) {
|
||||
await reloadAsync()
|
||||
} else {
|
||||
setCheckTimeout()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastMinimize.current = Date.now()
|
||||
}
|
||||
|
||||
appState.current = nextAppState
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout.current)
|
||||
subscription.remove()
|
||||
}
|
||||
}, [isUpdatePending, setCheckTimeout])
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user