Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38000bbef6 | |||
| 65bd978367 |
@@ -13,25 +13,10 @@ module.exports = {
|
||||
'react',
|
||||
'lingui',
|
||||
'simple-import-sort',
|
||||
'bsky-internal',
|
||||
],
|
||||
rules: {
|
||||
// Temporary until https://github.com/facebook/react-native/pull/43756 gets into a release.
|
||||
'prettier/prettier': 0,
|
||||
'react/no-unescaped-entities': 0,
|
||||
'react-native/no-inline-styles': 0,
|
||||
'bsky-internal/avoid-unwrapped-text': [
|
||||
'error',
|
||||
{
|
||||
impliedTextComponents: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P'],
|
||||
impliedTextProps: [],
|
||||
suggestedTextWrappers: {
|
||||
Button: 'ButtonText',
|
||||
'ToggleButton.Button': 'ToggleButton.ButtonText',
|
||||
},
|
||||
},
|
||||
],
|
||||
'bsky-internal/use-typed-gates': 'error',
|
||||
'simple-import-sort/imports': [
|
||||
'warn',
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ on:
|
||||
type: choice
|
||||
description: Build profile to use
|
||||
options:
|
||||
- testflight-android
|
||||
- production
|
||||
|
||||
jobs:
|
||||
@@ -60,58 +59,7 @@ jobs:
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
|
||||
run: yarn use-build-number eas build -p android --profile production --local --output build.aab --non-interactive
|
||||
|
||||
- name: ✍️ Rename Testflight bundle
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
run: mv build.aab build.apk
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
uses: nanzm/get-time-action@master
|
||||
with:
|
||||
format: 'MM-DD-HH-mm-ss'
|
||||
|
||||
- name: 🚀 Upload Production Artifact
|
||||
id: upload-artifact-production
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
name: build-${{ steps.timestamp.outputs.time }}.aab
|
||||
path: build.aab
|
||||
|
||||
- name: 🚀 Upload Testflight Artifact
|
||||
id: upload-artifact-testflight
|
||||
if: ${{ inputs.profile != 'production' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Android build is ready for submission. This is a production build! Download the artifact here: ${{ steps.upload-artifact-production.outputs.artifact-url }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
|
||||
- name: 🔔 Notify Slack of Testflight Build
|
||||
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-testflight.outputs.artifact-url }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p android --non-interactive --path build.aab
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
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:
|
||||
@@ -68,7 +69,7 @@ jobs:
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive
|
||||
run: yarn use-build-number eas build -p ios --profile production --local --output build.ipa --non-interactive
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p ios --non-interactive --path build.ipa
|
||||
|
||||
@@ -2,17 +2,8 @@
|
||||
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
|
||||
@@ -22,64 +13,13 @@ jobs:
|
||||
bundleDeploy:
|
||||
name: Bundle and Deploy EAS Update
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy
|
||||
cancel-in-progress: true
|
||||
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
|
||||
[[ "${{ github.event.inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⬇️ Get last successful deployment commit from the cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: last-successful-commit-hash.txt
|
||||
key: last-successful-deployment-commit-${{ github.ref_name }}
|
||||
|
||||
- name: Add the last successful deployment commit to the output
|
||||
id: last-successful-commit
|
||||
run: echo base-commit=$(cat last-successful-commit-hash.txt) >> "$GITHUB_OUTPUT"
|
||||
|
||||
- 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=${{ steps.last-successful-commit.base-commit }} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: ✓ Make sure we found a base commit
|
||||
run: |
|
||||
if [ -z "${{ steps.base-commit.outputs.base-commit }}" && ${{ inputs.channel == 'production' }} ]; then
|
||||
echo "Could not find a base commit for this release. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -90,226 +30,26 @@ jobs:
|
||||
- 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 }}
|
||||
|
||||
- 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='$(echo "${{ 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: Lint check
|
||||
run: yarn lint
|
||||
|
||||
- name: Prettier check
|
||||
run: yarn prettier --check .
|
||||
|
||||
- name: Check & compile i18n
|
||||
run: yarn intl:build
|
||||
|
||||
- name: Type check
|
||||
run: yarn typecheck
|
||||
|
||||
- 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'}}
|
||||
- name: 🪛 Install jq
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
|
||||
- name: ⛏️ Setup Expo
|
||||
run: yarn global add eas-cli-local-build-plugin
|
||||
|
||||
- name: 🔤 Compile Translations
|
||||
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
|
||||
run: 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
|
||||
run: yarn make-deploy-bundle
|
||||
env:
|
||||
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
|
||||
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
||||
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
|
||||
|
||||
- name: Save successful deployment commit hash
|
||||
run: echo ${{ steps.fingerprint.outputs.current-git-commit }} > last-successful-commit-hash.txt
|
||||
|
||||
# GitHub actions are horrible so let's just copy paste this in
|
||||
buildIfNecessaryIOS:
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-14
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
|
||||
cancel-in-progress: false
|
||||
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
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
|
||||
cancel-in-progress: false
|
||||
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
|
||||
RUNTIME_VERSION: ${{ github.event.inputs.runtimeVersion }}
|
||||
|
||||
@@ -24,8 +24,6 @@ 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 for fingerprint action https://github.com/expo/expo
|
||||
# Credit https://github.com/expo/expo
|
||||
# https://github.com/expo/expo/blob/main/.github/workflows/pr-labeler.yml
|
||||
---
|
||||
name: PR labeler
|
||||
@@ -13,91 +13,17 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
webpack-analyzer:
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
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: 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 }}
|
||||
yarn install
|
||||
|
||||
- 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 install
|
||||
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: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: bundle-diff
|
||||
message: |
|
||||
| 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 }}%) |
|
||||
---
|
||||
|
||||
test-suite-fingerprint:
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository || 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:
|
||||
# REQUIRED: Allow comments of PRs
|
||||
pull-requests: write
|
||||
# REQUIRED: Allow updating fingerprint in acton caches
|
||||
actions: write
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -119,7 +45,9 @@ jobs:
|
||||
|
||||
- name: Get the base commit
|
||||
id: base-commit
|
||||
run: echo base-commit=$(git log -n 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
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"
|
||||
|
||||
- name: 📷 Check fingerprint
|
||||
id: fingerprint
|
||||
@@ -132,28 +60,126 @@ jobs:
|
||||
echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}"
|
||||
echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}"
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
- name: 🏷️ Labeling PR
|
||||
uses: actions/github-script@v6
|
||||
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' }}
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: ['bot: fingerprint changed']
|
||||
})
|
||||
} catch (e) {
|
||||
if (e.status != 404) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ['bot: fingerprint compatible']
|
||||
})
|
||||
|
||||
- name: 🏷️ Labeling PR
|
||||
uses: actions/github-script@v6
|
||||
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
message: |
|
||||
script: |
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: ['bot: fingerprint compatible']
|
||||
})
|
||||
} catch (e) {
|
||||
if (e.status != 404) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ['bot: fingerprint changed']
|
||||
})
|
||||
|
||||
- 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: <!-- pr-labeler comment -->
|
||||
|
||||
- name: 💬 Add comment with fingerprint
|
||||
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' && steps.old_comment.outputs.comment-id == '' }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const diff = JSON.stringify(${{ steps.fingerprint.outputs.fingerprint-diff}}, null, 2);
|
||||
const body = `<!-- pr-labeler comment -->
|
||||
The Pull Request introduced fingerprint changes against the base commit: ${{ steps.fingerprint.outputs.previous-git-commit }}
|
||||
<details><summary>Fingerprint diff</summary>
|
||||
|
||||
```json
|
||||
${{ steps.fingerprint.outputs.fingerprint-diff }}
|
||||
```
|
||||
\`\`\`json
|
||||
${diff}
|
||||
\`\`\`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
`;
|
||||
|
||||
- name: 💬 Delete comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' }}
|
||||
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: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' && steps.old_comment.outputs.comment-id != '' }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
delete: true
|
||||
script: |
|
||||
const diff = JSON.stringify(${{ steps.fingerprint.outputs.fingerprint-diff}}, null, 2);
|
||||
const body = `<!-- pr-labeler comment -->
|
||||
The Pull Request introduced fingerprint changes against the base commit: ${{ steps.fingerprint.outputs.previous-git-commit }}
|
||||
<details><summary>Fingerprint diff</summary>
|
||||
|
||||
\`\`\`json
|
||||
${diff}
|
||||
\`\`\`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
`;
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
- name: 💬 Delete comment with fingerprint
|
||||
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' && steps.old_comment.outputs.comment-id != '' }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.deleteComment({
|
||||
issue_number: context.issue.number,
|
||||
comment_id: '${{ steps.old_comment.outputs.comment-id }}',
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ xcuserdata
|
||||
*.moved-aside
|
||||
DerivedData
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.xcuserstate
|
||||
|
||||
# Android/IntelliJ
|
||||
@@ -77,7 +78,6 @@ dist/
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
stats.json
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
@@ -104,14 +104,6 @@ google-services.json
|
||||
# Performance results (Flashlight)
|
||||
.perf/
|
||||
|
||||
# ESLint
|
||||
.eslintcache
|
||||
|
||||
# i18n
|
||||
src/locale/locales/_build/
|
||||
src/locale/locales/**/*.js
|
||||
|
||||
# local builds
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
# Ignore everything except JS-ey code.
|
||||
# Based on https://stackoverflow.com/a/70715829/458193
|
||||
*
|
||||
!**/*.js
|
||||
!**/*.jsx
|
||||
!**/*.ts
|
||||
!**/*.tsx
|
||||
!*/
|
||||
|
||||
# More specific ignores go below.
|
||||
.expo
|
||||
android
|
||||
ios
|
||||
src/locale/locales
|
||||
android
|
||||
src/third-party
|
||||
src/app.json
|
||||
public
|
||||
/bskyweb/templates
|
||||
/dist/
|
||||
/.watchmanconfig
|
||||
/app.json
|
||||
|
||||
web/index.html
|
||||
web-build/*
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {createServer as createHTTPServer} from 'node:http'
|
||||
import {parse} from 'node:url'
|
||||
|
||||
import {createServer, TestPDS} from '../jest/test-pds'
|
||||
|
||||
async function main() {
|
||||
@@ -15,7 +14,8 @@ async function main() {
|
||||
await server?.close()
|
||||
console.log('Starting new server')
|
||||
const inviteRequired = url?.query && 'invite' in url.query
|
||||
server = await createServer({inviteRequired})
|
||||
const phoneRequired = url?.query && 'phone' in url.query
|
||||
server = await createServer({inviteRequired, phoneRequired})
|
||||
console.log('Listening at', server.pdsUrl)
|
||||
if (url?.query) {
|
||||
if ('users' in url.query) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {beforeAll, describe, it} from '@jest/globals'
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
|
||||
import {createServer, loginAsAlice, openApp} from '../util'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
describe('Home screen', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -69,16 +68,19 @@ describe('Home screen', () => {
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
|
||||
// it('Can report posts', async () => {
|
||||
// const carlaPosts = by.id('feedItem-by-carla.test')
|
||||
// await element(by.id('postDropdownBtn').withAncestor(carlaPosts))
|
||||
// .atIndex(0)
|
||||
// .tap()
|
||||
// await element(by.text('Report post')).tap()
|
||||
// await element(by.id('com.atproto.moderation.defs#reasonSpam')).tap()
|
||||
// await element(by.id('sendReportBtn')).tap()
|
||||
// })
|
||||
it('Can report posts', async () => {
|
||||
const carlaPosts = by.id('feedItem-by-carla.test')
|
||||
await element(by.id('postDropdownBtn').withAncestor(carlaPosts))
|
||||
.atIndex(0)
|
||||
.tap()
|
||||
await element(by.text('Report post')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('Can swipe between feeds', async () => {
|
||||
await element(by.id('homeScreen')).swipe('left', 'fast', 0.75)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {beforeAll, describe, it} from '@jest/globals'
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
|
||||
import {createServer, loginAsAlice, openApp} from '../util'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
describe('invite-codes', () => {
|
||||
let service: string
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
describe('invite-codes', () => {
|
||||
let service: string
|
||||
let inviteCode = ''
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users&invite&phone')
|
||||
await openApp({permissions: {notifications: 'YES'}})
|
||||
})
|
||||
|
||||
it('I can fetch invite codes', async () => {
|
||||
await loginAsAlice()
|
||||
await element(by.id('e2eOpenInviteCodesModal')).tap()
|
||||
await expect(element(by.id('inviteCodesModal'))).toBeVisible()
|
||||
const attrs = await element(by.id('inviteCode-0-code')).getAttributes()
|
||||
inviteCode = attrs.text
|
||||
await element(by.id('closeBtn')).tap()
|
||||
await element(by.id('e2eSignOut')).tap()
|
||||
})
|
||||
|
||||
it('I can create a new account with the invite code', async () => {
|
||||
await element(by.id('e2eOpenLoggedOutView')).tap()
|
||||
await element(by.id('createAccountButton')).tap()
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
await device.takeScreenshot('2- selected other server')
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('inviteCodeInput')).typeText(inviteCode)
|
||||
await element(by.id('emailInput')).typeText('example@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
await device.takeScreenshot('4- entered account details')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
await element(by.id('phoneInput')).typeText('2345551234')
|
||||
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('nextBtn')).tap()
|
||||
await element(by.id('handleInput')).typeText('e2e-test')
|
||||
await device.takeScreenshot('7- entered handle')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,8 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {beforeAll, describe, it} from '@jest/globals'
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
|
||||
import {createServer, loginAsAlice, openApp, sleep} from '../util'
|
||||
import {openApp, loginAsAlice, createServer, sleep} from '../util'
|
||||
|
||||
describe('Profile screen', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -125,17 +124,16 @@ describe('Profile screen', () => {
|
||||
await expect(element(by.id('profileHeaderAlert'))).not.toExist()
|
||||
})
|
||||
|
||||
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
|
||||
// it('Can report another user', async () => {
|
||||
// await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
// await element(by.text('Report Account')).tap()
|
||||
// await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
// await element(
|
||||
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
// ).tap()
|
||||
// await element(by.id('sendReportBtn')).tap()
|
||||
// await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
// })
|
||||
it('Can report another user', async () => {
|
||||
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
await element(by.text('Report Account')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('Can like posts', async () => {
|
||||
await element(by.id('postsFeed-flatlist')).swipe(
|
||||
@@ -181,16 +179,15 @@ describe('Profile screen', () => {
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
|
||||
// it('Can report posts', async () => {
|
||||
// const posts = by.id('feedItem-by-bob.test')
|
||||
// await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap()
|
||||
// await element(by.text('Report post')).tap()
|
||||
// await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
// await element(
|
||||
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
// ).tap()
|
||||
// await element(by.id('sendReportBtn')).tap()
|
||||
// await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
// })
|
||||
it('Can report posts', async () => {
|
||||
const posts = by.id('feedItem-by-bob.test')
|
||||
await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap()
|
||||
await element(by.text('Report post')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
import {openApp, createServer} from '../util'
|
||||
|
||||
describe('Create account', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?phone')
|
||||
await openApp({permissions: {notifications: 'YES'}})
|
||||
})
|
||||
|
||||
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()
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
await device.takeScreenshot('2- selected other server')
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('emailInput')).typeText('text-verification@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
await device.takeScreenshot('4- entered account details')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
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('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()
|
||||
|
||||
await expect(element(by.id('onboardingInterests'))).toBeVisible()
|
||||
})
|
||||
|
||||
it('failed text verification correctly goes back to the code input screen', async () => {
|
||||
await element(by.id('e2eSignOut')).tap()
|
||||
await element(by.id('e2eOpenLoggedOutView')).tap()
|
||||
|
||||
await element(by.id('createAccountButton')).tap()
|
||||
await device.takeScreenshot('1- opened create account screen')
|
||||
await element(by.id('selectServiceButton')).tap()
|
||||
await device.takeScreenshot('2- selected other server')
|
||||
await element(by.id('customSelectBtn')).tap()
|
||||
await element(by.id('customServerTextInput')).typeText(service)
|
||||
await element(by.id('customServerTextInput')).tapReturnKey()
|
||||
await element(by.id('doneBtn')).tap()
|
||||
await device.takeScreenshot('3- input test server URL')
|
||||
await element(by.id('emailInput')).typeText('text-verification2@test.com')
|
||||
await element(by.id('passwordInput')).typeText('hunter2')
|
||||
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('111111')
|
||||
await device.takeScreenshot('6- entered code')
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await element(by.id('handleInput')).typeText('text-verification-test2')
|
||||
await device.takeScreenshot('7- entered handle')
|
||||
|
||||
await element(by.id('nextBtn')).tap()
|
||||
|
||||
await expect(element(by.id('codeInput'))).toBeVisible()
|
||||
await device.takeScreenshot('8- got error')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,8 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {beforeAll, describe, it} from '@jest/globals'
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
|
||||
import {createServer, loginAsAlice, openApp} from '../util'
|
||||
import {openApp, loginAsAlice, createServer} from '../util'
|
||||
|
||||
describe('Thread screen', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -103,29 +102,27 @@ describe('Thread screen', () => {
|
||||
).not.toExist()
|
||||
})
|
||||
|
||||
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
|
||||
// it('Can report the root post', async () => {
|
||||
// const post = by.id('postThreadItem-by-bob.test')
|
||||
// await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
|
||||
// await element(by.text('Report post')).tap()
|
||||
// await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
// await element(
|
||||
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
// ).tap()
|
||||
// await element(by.id('sendReportBtn')).tap()
|
||||
// await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
// })
|
||||
it('Can report the root post', async () => {
|
||||
const post = by.id('postThreadItem-by-bob.test')
|
||||
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
|
||||
await element(by.text('Report post')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
|
||||
// it('Can report a reply post', async () => {
|
||||
// const post = by.id('postThreadItem-by-carla.test')
|
||||
// await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
|
||||
// await element(by.text('Report post')).tap()
|
||||
// await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
// await element(
|
||||
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
// ).tap()
|
||||
// await element(by.id('sendReportBtn')).tap()
|
||||
// await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
// })
|
||||
it('Can report a reply post', async () => {
|
||||
const post = by.id('postThreadItem-by-carla.test')
|
||||
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
|
||||
await element(by.text('Report post')).tap()
|
||||
await expect(element(by.id('reportModal'))).toBeVisible()
|
||||
await element(
|
||||
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportModal'))).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,15 +41,6 @@ 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 IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
||||
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT
|
||||
? 'testflight'
|
||||
: IS_PRODUCTION
|
||||
? 'production'
|
||||
: undefined
|
||||
const UPDATES_ENABLED = !!UPDATES_CHANNEL
|
||||
|
||||
return {
|
||||
expo: {
|
||||
@@ -95,11 +86,6 @@ module.exports = function (config) {
|
||||
barStyle: 'light-content',
|
||||
backgroundColor: '#00000000',
|
||||
},
|
||||
// Dark nav bar in light mode is better than light nav bar in dark mode
|
||||
androidNavigationBar: {
|
||||
barStyle: 'light-content',
|
||||
backgroundColor: DARK_SPLASH_CONFIG_ANDROID.backgroundColor,
|
||||
},
|
||||
android: {
|
||||
icon: './assets/icon.png',
|
||||
adaptiveIcon: {
|
||||
@@ -136,22 +122,10 @@ module.exports = function (config) {
|
||||
favicon: './assets/favicon.png',
|
||||
},
|
||||
updates: {
|
||||
url: 'https://updates.bsky.app/manifest',
|
||||
enabled: UPDATES_ENABLED,
|
||||
fallbackToCacheTimeout: 30000,
|
||||
codeSigningCertificate: UPDATES_ENABLED
|
||||
? './code-signing/certificate.pem'
|
||||
: undefined,
|
||||
codeSigningMetadata: UPDATES_ENABLED
|
||||
? {
|
||||
keyid: 'main',
|
||||
alg: 'rsa-v1_5-sha256',
|
||||
}
|
||||
: undefined,
|
||||
checkAutomatically: 'NEVER',
|
||||
channel: UPDATES_CHANNEL,
|
||||
enabled: true,
|
||||
fallbackToCacheTimeout: 1000,
|
||||
url: 'https://u.expo.dev/55bd077a-d905-4184-9c7f-94789ba0f302',
|
||||
},
|
||||
assetBundlePatterns: ['**/*'],
|
||||
plugins: [
|
||||
'expo-localization',
|
||||
Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo',
|
||||
@@ -171,6 +145,12 @@ module.exports = function (config) {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-updates',
|
||||
{
|
||||
username: 'blueskysocial',
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-notifications',
|
||||
{
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 221 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.002 6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6Zm3-1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h7a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-14Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 386 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M14.242 3.03a1 1 0 0 1 .728 1.213l-4 16a1 1 0 1 1-1.94-.485l4-16a1 1 0 0 1 1.213-.728ZM6.707 7.293a1 1 0 0 1 0 1.414L3.414 12l3.293 3.293a1 1 0 1 1-1.414 1.414l-4-4a1 1 0 0 1 0-1.414l4-4a1 1 0 0 1 1.414 0Zm10.586 0a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1 0 1.414l-4 4a1 1 0 1 1-1.414-1.414L20.586 12l-3.293-3.293a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 466 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 466 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 470 B |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "simple-import-sort"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"preact",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking"
|
||||
],
|
||||
"rules": {
|
||||
"simple-import-sort/imports": "warn",
|
||||
"simple-import-sort/exports": "warn"
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": "latest",
|
||||
"project": "./tsconfig.json"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 287 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="rgb(10,122,255)" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 232 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="rgb(66,87,108)" fill-rule="evenodd" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 361 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#ec4899" d="M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 339 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 320 286"><path fill="rgb(10,122,255)" d="M69.364 19.146c36.687 27.806 76.147 84.186 90.636 114.439 14.489-30.253 53.948-86.633 90.636-114.439C277.107-.917 320-16.44 320 32.957c0 9.865-5.603 82.875-8.889 94.729-11.423 41.208-53.045 51.719-90.071 45.357 64.719 11.12 81.182 47.953 45.627 84.785-80 82.874-106.667-44.333-106.667-44.333s-26.667 127.207-106.667 44.333c-35.555-36.832-19.092-73.665 45.627-84.785-37.026 6.362-78.648-4.149-90.071-45.357C5.603 115.832 0 42.822 0 32.957 0-16.44 42.893-.917 69.364 19.147Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 588 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#20bc07" d="M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 473 B |
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bluesky Embed</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
<link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
|
||||
<meta name="theme-color">
|
||||
<meta name="application-name" content="Bluesky">
|
||||
<meta name="generator" content="bskyweb">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/screens/landing.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "bskyembed",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build-snippet": "tsc --project tsconfig.snippet.json",
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.2",
|
||||
"@preact/preset-vite": "^2.8.2",
|
||||
"@vitejs/plugin-legacy": "^5.3.2",
|
||||
"preact": "^10.4.8",
|
||||
"terser": "^5.30.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^4.0.5",
|
||||
"vite": "^5.2.8",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bluesky Embed</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
<link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
|
||||
<meta name="theme-color">
|
||||
<meta name="application-name" content="Bluesky">
|
||||
<meta name="generator" content="bskyweb">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/screens/post.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface Window {
|
||||
bluesky: {
|
||||
scan: (element?: Pick<Element, 'querySelectorAll'>) => void
|
||||
}
|
||||
}
|
||||
|
||||
const EMBED_URL = 'https://embed.bsky.app'
|
||||
|
||||
window.bluesky = window.bluesky || {
|
||||
scan,
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for messages from the Bluesky embed iframe and adjust the height of
|
||||
* the iframe accordingly.
|
||||
*/
|
||||
window.addEventListener('message', event => {
|
||||
if (event.origin !== EMBED_URL) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = (event.data as {id: string}).id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
const embed = document.querySelector<HTMLIFrameElement>(
|
||||
`[data-bluesky-id="${id}"]`,
|
||||
)
|
||||
|
||||
if (!embed) {
|
||||
return
|
||||
}
|
||||
|
||||
const height = (event.data as {height: number}).height
|
||||
if (height) {
|
||||
embed.style.height = `${height}px`
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Scan the document for all elements with the data-bluesky-aturi attribute,
|
||||
* and initialize them as Bluesky embeds.
|
||||
*
|
||||
* @param element Only scan this specific element @default document @optional
|
||||
* @returns
|
||||
*/
|
||||
function scan(node = document) {
|
||||
const embeds = node.querySelectorAll('[data-bluesky-uri]')
|
||||
|
||||
for (let i = 0; i < embeds.length; i++) {
|
||||
const id = String(Math.random()).slice(2)
|
||||
|
||||
const embed = embeds[i]
|
||||
const aturi = embed.getAttribute('data-bluesky-uri')
|
||||
|
||||
if (!aturi) {
|
||||
continue
|
||||
}
|
||||
|
||||
const ref_url = location.origin + location.pathname
|
||||
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('id', id)
|
||||
if (ref_url.startsWith('http')) {
|
||||
searchParams.set('ref_url', encodeURIComponent(ref_url))
|
||||
}
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.setAttribute('data-bluesky-id', id)
|
||||
iframe.src = `${EMBED_URL}/embed/${aturi.slice(
|
||||
'at://'.length,
|
||||
)}?${searchParams.toString()}`
|
||||
iframe.width = '100%'
|
||||
iframe.style.border = 'none'
|
||||
iframe.style.display = 'block'
|
||||
iframe.style.flexGrow = '1'
|
||||
iframe.frameBorder = '0'
|
||||
iframe.scrolling = 'no'
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.style.maxWidth = '600px'
|
||||
container.style.width = '100%'
|
||||
container.style.marginTop = '10px'
|
||||
container.style.marginBottom = '10px'
|
||||
container.style.display = 'flex'
|
||||
container.className = 'bluesky-embed'
|
||||
|
||||
container.appendChild(iframe)
|
||||
|
||||
embed.replaceWith(container)
|
||||
}
|
||||
}
|
||||
|
||||
if (['interactive', 'complete'].indexOf(document.readyState) !== -1) {
|
||||
scan()
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => scan())
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import {ComponentChildren, h} from 'preact'
|
||||
import {useEffect, useRef} from 'preact/hooks'
|
||||
|
||||
import {Link} from './link'
|
||||
|
||||
export function Container({
|
||||
children,
|
||||
href,
|
||||
}: {
|
||||
children: ComponentChildren
|
||||
href?: string
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const prevHeight = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
|
||||
let {height} = entry.contentRect
|
||||
height += 2 // border top and bottom
|
||||
if (height !== prevHeight.current) {
|
||||
prevHeight.current = height
|
||||
window.parent.postMessage(
|
||||
{height, id: new URLSearchParams(window.location.search).get('id')},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
})
|
||||
observer.observe(ref.current)
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[600px] min-w-[300px] flex border rounded-xl"
|
||||
onClick={() => {
|
||||
if (ref.current && href) {
|
||||
// forwardRef requires preact/compat - let's keep it simple
|
||||
// to keep the bundle size down
|
||||
const anchor = ref.current.querySelector('a')
|
||||
if (anchor) {
|
||||
anchor.click()
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{href && <Link href={href} />}
|
||||
<div className="flex-1 px-4 pt-3 pb-2.5">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
import {
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphDefs,
|
||||
AppBskyLabelerDefs,
|
||||
} from '@atproto/api'
|
||||
import {ComponentChildren, h} from 'preact'
|
||||
import {useMemo} from 'preact/hooks'
|
||||
|
||||
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
||||
import {getRkey} from '../utils'
|
||||
import {Link} from './link'
|
||||
|
||||
export function Embed({
|
||||
content,
|
||||
labels,
|
||||
hideRecord,
|
||||
}: {
|
||||
content: AppBskyFeedDefs.PostView['embed']
|
||||
labels: AppBskyFeedDefs.PostView['labels']
|
||||
hideRecord?: boolean
|
||||
}) {
|
||||
const labelInfo = useMemo(() => labelsToInfo(labels), [labels])
|
||||
|
||||
if (!content) return null
|
||||
|
||||
try {
|
||||
// Case 1: Image
|
||||
if (AppBskyEmbedImages.isView(content)) {
|
||||
return <ImageEmbed content={content} labelInfo={labelInfo} />
|
||||
}
|
||||
|
||||
// Case 2: External link
|
||||
if (AppBskyEmbedExternal.isView(content)) {
|
||||
return <ExternalEmbed content={content} labelInfo={labelInfo} />
|
||||
}
|
||||
|
||||
// Case 3: Record (quote or linked post)
|
||||
if (AppBskyEmbedRecord.isView(content)) {
|
||||
if (hideRecord) {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = content.record
|
||||
|
||||
// Case 3.1: Post
|
||||
if (AppBskyEmbedRecord.isViewRecord(record)) {
|
||||
const pwiOptOut = !!record.author.labels?.find(
|
||||
label => label.val === '!no-unauthenticated',
|
||||
)
|
||||
if (pwiOptOut) {
|
||||
return (
|
||||
<Info>
|
||||
The author of the quoted post has requested their posts not be
|
||||
displayed on external sites.
|
||||
</Info>
|
||||
)
|
||||
}
|
||||
|
||||
let text
|
||||
if (AppBskyFeedPost.isRecord(record.value)) {
|
||||
text = record.value.text
|
||||
}
|
||||
|
||||
const isAuthorLabeled = record.author.labels?.some(label =>
|
||||
CONTENT_LABELS.includes(label.val),
|
||||
)
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
|
||||
className="transition-colors hover:bg-neutral-100 border rounded-lg p-2 gap-1.5 w-full flex flex-col">
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 shrink-0">
|
||||
<img
|
||||
src={record.author.avatar}
|
||||
style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined}
|
||||
/>
|
||||
</div>
|
||||
<p className="line-clamp-1 text-sm">
|
||||
<span className="font-bold">{record.author.displayName}</span>
|
||||
<span className="text-textLight ml-1">
|
||||
@{record.author.handle}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{text && <p className="text-sm">{text}</p>}
|
||||
{record.embeds?.map(embed => (
|
||||
<Embed
|
||||
key={embed.$type}
|
||||
content={embed}
|
||||
labels={record.labels}
|
||||
hideRecord
|
||||
/>
|
||||
))}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Case 3.2: List
|
||||
if (AppBskyGraphDefs.isListView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
image={record.avatar}
|
||||
title={record.name}
|
||||
href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
|
||||
subtitle={
|
||||
record.purpose === AppBskyGraphDefs.MODLIST
|
||||
? `Moderation list by @${record.creator.handle}`
|
||||
: `User list by @${record.creator.handle}`
|
||||
}
|
||||
description={record.description}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Case 3.3: Feed
|
||||
if (AppBskyFeedDefs.isGeneratorView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
image={record.avatar}
|
||||
title={record.displayName}
|
||||
href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
|
||||
subtitle={`Feed by @${record.creator.handle}`}
|
||||
description={`Liked by ${record.likeCount ?? 0} users`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Case 3.4: Labeler
|
||||
if (AppBskyLabelerDefs.isLabelerView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
image={record.creator.avatar}
|
||||
title={record.creator.displayName || record.creator.handle}
|
||||
href={`/profile/${record.creator.did}`}
|
||||
subtitle="Labeler"
|
||||
description={`Liked by ${record.likeCount ?? 0} users`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Case 3.5: Post not found
|
||||
if (AppBskyEmbedRecord.isViewNotFound(record)) {
|
||||
return <Info>Quoted post not found, it may have been deleted.</Info>
|
||||
}
|
||||
|
||||
// Case 3.6: Post blocked
|
||||
if (AppBskyEmbedRecord.isViewBlocked(record)) {
|
||||
return <Info>The quoted post is blocked.</Info>
|
||||
}
|
||||
|
||||
throw new Error('Unknown embed type')
|
||||
}
|
||||
|
||||
// Case 4: Record with media
|
||||
if (
|
||||
AppBskyEmbedRecordWithMedia.isView(content) &&
|
||||
AppBskyEmbedRecord.isViewRecord(content.record.record)
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Embed
|
||||
content={content.media}
|
||||
labels={labels}
|
||||
hideRecord={hideRecord}
|
||||
/>
|
||||
<Embed
|
||||
content={{
|
||||
$type: 'app.bsky.embed.record#view',
|
||||
record: content.record.record,
|
||||
}}
|
||||
labels={content.record.record.labels}
|
||||
hideRecord={hideRecord}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('Unsupported embed type')
|
||||
} catch (err) {
|
||||
return (
|
||||
<Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function Info({children}: {children: ComponentChildren}) {
|
||||
return (
|
||||
<div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
|
||||
<img src={infoIcon as string} className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-textLight">{children}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageEmbed({
|
||||
content,
|
||||
labelInfo,
|
||||
}: {
|
||||
content: AppBskyEmbedImages.View
|
||||
labelInfo?: string
|
||||
}) {
|
||||
if (labelInfo) {
|
||||
return <Info>{labelInfo}</Info>
|
||||
}
|
||||
|
||||
switch (content.images.length) {
|
||||
case 1:
|
||||
return (
|
||||
<img
|
||||
src={content.images[0].thumb}
|
||||
alt={content.images[0].alt}
|
||||
className="w-full rounded-lg overflow-hidden object-cover h-auto max-h-[1000px]"
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
|
||||
{content.images.map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="w-1/2 h-full object-cover rounded-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
|
||||
<img
|
||||
src={content.images[0].thumb}
|
||||
alt={content.images[0].alt}
|
||||
className="flex-[3] object-cover rounded-sm"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 flex-[2]">
|
||||
{content.images.slice(1).map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="w-full h-full object-cover rounded-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
|
||||
{content.images.map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="aspect-square w-full object-cover rounded-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function ExternalEmbed({
|
||||
content,
|
||||
labelInfo,
|
||||
}: {
|
||||
content: AppBskyEmbedExternal.View
|
||||
labelInfo?: string
|
||||
}) {
|
||||
function toNiceDomain(url: string): string {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
return urlp.host ? urlp.host : url
|
||||
} catch (e) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
if (labelInfo) {
|
||||
return <Info>{labelInfo}</Info>
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={content.external.uri}
|
||||
className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch">
|
||||
{content.external.thumb && (
|
||||
<img
|
||||
src={content.external.thumb}
|
||||
className="aspect-[1.91/1] object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="py-3 px-4">
|
||||
<p className="text-sm text-textLight line-clamp-1">
|
||||
{toNiceDomain(content.external.uri)}
|
||||
</p>
|
||||
<p className="font-semibold line-clamp-3">{content.external.title}</p>
|
||||
<p className="text-sm text-textLight line-clamp-2 mt-0.5">
|
||||
{content.external.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function GenericWithImage({
|
||||
title,
|
||||
subtitle,
|
||||
href,
|
||||
image,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
href: string
|
||||
image?: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="w-full rounded-lg border py-2 px-3 flex flex-col gap-2">
|
||||
<div className="flex gap-2.5 items-center">
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-8 h-8 rounded-md bg-neutral-300 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-md bg-brand shrink-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-sm">{title}</p>
|
||||
<p className="text-textLight text-sm">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
{description && <p className="text-textLight text-sm">{description}</p>}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import {h} from 'preact'
|
||||
|
||||
export function Link({
|
||||
href,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
href: string
|
||||
className?: string
|
||||
} & h.JSX.HTMLAttributes<HTMLAnchorElement>) {
|
||||
const searchParam = new URLSearchParams(window.location.search)
|
||||
const ref_url = searchParam.get('ref_url')
|
||||
|
||||
const newSearchParam = new URLSearchParams()
|
||||
newSearchParam.set('ref_src', 'embed')
|
||||
if (ref_url) {
|
||||
newSearchParam.set('ref_url', ref_url)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`${
|
||||
href.startsWith('http') ? href : `https://bsky.app${href}`
|
||||
}?${newSearchParam.toString()}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
onClick={evt => evt.stopPropagation()}
|
||||
className={`cursor-pointer ${className || ''}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
|
||||
import {h} from 'preact'
|
||||
|
||||
import replyIcon from '../../assets/bubble_filled_stroke2_corner2_rounded.svg'
|
||||
import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
|
||||
import logo from '../../assets/logo.svg'
|
||||
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
|
||||
import {CONTENT_LABELS} from '../labels'
|
||||
import {getRkey, niceDate} from '../utils'
|
||||
import {Container} from './container'
|
||||
import {Embed} from './embed'
|
||||
import {Link} from './link'
|
||||
|
||||
interface Props {
|
||||
thread: AppBskyFeedDefs.ThreadViewPost
|
||||
}
|
||||
|
||||
export function Post({thread}: Props) {
|
||||
const post = thread.post
|
||||
|
||||
const isAuthorLabeled = post.author.labels?.some(label =>
|
||||
CONTENT_LABELS.includes(label.val),
|
||||
)
|
||||
|
||||
let record: AppBskyFeedPost.Record | null = null
|
||||
if (AppBskyFeedPost.isRecord(post.record)) {
|
||||
record = post.record
|
||||
}
|
||||
|
||||
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
|
||||
return (
|
||||
<Container href={href}>
|
||||
<div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
|
||||
<div className="flex gap-2.5 items-center cursor-pointer">
|
||||
<Link href={`/profile/${post.author.did}`} className="rounded-full">
|
||||
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 shrink-0">
|
||||
<img
|
||||
src={post.author.avatar}
|
||||
style={isAuthorLabeled ? {filter: 'blur(2.5px)'} : undefined}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<div>
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 decoration-2">
|
||||
<p>{post.author.displayName}</p>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="text-[15px] text-textLight hover:underline line-clamp-1">
|
||||
<p>@{post.author.handle}</p>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Link
|
||||
href={href}
|
||||
className="transition-transform hover:scale-110 shrink-0 self-start">
|
||||
<img src={logo as string} className="h-8" />
|
||||
</Link>
|
||||
</div>
|
||||
<PostContent record={record} />
|
||||
<Embed content={post.embed} labels={post.labels} />
|
||||
<Link href={href}>
|
||||
<time
|
||||
datetime={new Date(post.indexedAt).toISOString()}
|
||||
className="text-textLight mt-1 text-sm hover:underline">
|
||||
{niceDate(post.indexedAt)}
|
||||
</time>
|
||||
</Link>
|
||||
<div className="border-t w-full pt-2.5 flex items-center gap-5 text-sm cursor-pointer">
|
||||
{!!post.likeCount && (
|
||||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<img src={likeIcon as string} className="w-5 h-5" />
|
||||
<p className="font-bold text-neutral-500 mb-px">
|
||||
{post.likeCount}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!post.repostCount && (
|
||||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<img src={repostIcon as string} className="w-5 h-5" />
|
||||
<p className="font-bold text-neutral-500 mb-px">
|
||||
{post.repostCount}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<img src={replyIcon as string} className="w-5 h-5" />
|
||||
<p className="font-bold text-neutral-500 mb-px">Reply</p>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
|
||||
{post.replyCount
|
||||
? `Read ${post.replyCount} ${
|
||||
post.replyCount > 1 ? 'replies' : 'reply'
|
||||
} on Bluesky`
|
||||
: `View on Bluesky`}
|
||||
</p>
|
||||
<p className="cursor-pointer text-brand font-bold hover:underline min-[450px]:hidden">
|
||||
<span className="hidden min-[380px]:inline">View on </span>Bluesky
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
if (!record) return null
|
||||
|
||||
const rt = new RichText({
|
||||
text: record.text,
|
||||
facets: record.facets,
|
||||
})
|
||||
|
||||
const richText = []
|
||||
|
||||
let counter = 0
|
||||
for (const segment of rt.segments()) {
|
||||
if (segment.isLink() && segment.link) {
|
||||
richText.push(
|
||||
<Link
|
||||
key={counter}
|
||||
href={segment.link.uri}
|
||||
className="text-blue-400 hover:underline">
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
} else if (segment.isMention() && segment.mention) {
|
||||
richText.push(
|
||||
<Link
|
||||
key={counter}
|
||||
href={`/profile/${segment.mention.did}`}
|
||||
className="text-blue-500 hover:underline">
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
} else if (segment.isTag() && segment.tag) {
|
||||
richText.push(
|
||||
<Link
|
||||
key={counter}
|
||||
href={`/tag/${segment.tag.tag}`}
|
||||
className="text-blue-500 hover:underline">
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
} else {
|
||||
richText.push(segment.text)
|
||||
}
|
||||
|
||||
counter++
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="min-[300px]:text-lg leading-6 break-word break-words whitespace-pre-wrap">
|
||||
{richText}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.break-word {
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
|
||||
export const CONTENT_LABELS = ['porn', 'sexual', 'nudity', 'graphic-media']
|
||||
|
||||
export function labelsToInfo(
|
||||
labels?: AppBskyFeedDefs.PostView['labels'],
|
||||
): string | undefined {
|
||||
const label = labels?.find(label => CONTENT_LABELS.includes(label.val))
|
||||
|
||||
switch (label?.val) {
|
||||
case 'porn':
|
||||
case 'sexual':
|
||||
return 'Adult Content'
|
||||
case 'nudity':
|
||||
return 'Non-sexual Nudity'
|
||||
case 'graphic-media':
|
||||
return 'Graphic Media'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import '../index.css'
|
||||
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri, BskyAgent} from '@atproto/api'
|
||||
import {h, render} from 'preact'
|
||||
import {useEffect, useMemo, useRef, useState} from 'preact/hooks'
|
||||
|
||||
import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg'
|
||||
import logo from '../../assets/logo.svg'
|
||||
import {Container} from '../components/container'
|
||||
import {Link} from '../components/link'
|
||||
import {Post} from '../components/post'
|
||||
import {niceDate} from '../utils'
|
||||
|
||||
const DEFAULT_POST = 'https://bsky.app/profile/emilyliu.me/post/3jzn6g7ixgq2y'
|
||||
const DEFAULT_URI =
|
||||
'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y'
|
||||
|
||||
export const EMBED_SERVICE = 'https://embed.bsky.app'
|
||||
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
|
||||
const agent = new BskyAgent({
|
||||
service: 'https://public.api.bsky.app',
|
||||
})
|
||||
|
||||
render(<LandingPage />, root)
|
||||
|
||||
function LandingPage() {
|
||||
const [uri, setUri] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
setError(null)
|
||||
setThread(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
let atUri = DEFAULT_URI
|
||||
|
||||
if (uri) {
|
||||
if (uri.startsWith('at://')) {
|
||||
atUri = uri
|
||||
} else {
|
||||
try {
|
||||
const urlp = new URL(uri)
|
||||
if (!urlp.hostname.endsWith('bsky.app')) {
|
||||
throw new Error('Invalid hostname')
|
||||
}
|
||||
const split = urlp.pathname.slice(1).split('/')
|
||||
if (split.length < 4) {
|
||||
throw new Error('Invalid pathname')
|
||||
}
|
||||
const [profile, didOrHandle, type, rkey] = split
|
||||
if (profile !== 'profile' || type !== 'post') {
|
||||
throw new Error('Invalid profile or type')
|
||||
}
|
||||
|
||||
let did = didOrHandle
|
||||
if (!didOrHandle.startsWith('did:')) {
|
||||
const resolution = await agent.resolveHandle({
|
||||
handle: didOrHandle,
|
||||
})
|
||||
if (!resolution.data.did) {
|
||||
throw new Error('No DID found')
|
||||
}
|
||||
did = resolution.data.did
|
||||
}
|
||||
|
||||
atUri = `at://${did}/app.bsky.feed.post/${rkey}`
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
throw new Error('Invalid Bluesky URL')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {data} = await agent.getPostThread({
|
||||
uri: atUri,
|
||||
depth: 0,
|
||||
parentHeight: 0,
|
||||
})
|
||||
|
||||
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
|
||||
throw new Error('Post not found')
|
||||
}
|
||||
const pwiOptOut = !!data.thread.post.author.labels?.find(
|
||||
label => label.val === '!no-unauthenticated',
|
||||
)
|
||||
if (pwiOptOut) {
|
||||
throw new Error(
|
||||
'The author of this post has requested their posts not be displayed on external sites.',
|
||||
)
|
||||
}
|
||||
setThread(data.thread)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Invalid Bluesky URL')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [uri])
|
||||
|
||||
return (
|
||||
<main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32">
|
||||
<Link
|
||||
href="https://bsky.social/about"
|
||||
className="transition-transform hover:scale-110">
|
||||
<img src={logo as string} className="h-10" />
|
||||
</Link>
|
||||
|
||||
<h1 className="text-4xl font-bold text-center">Embed a Bluesky Post</h1>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={uri}
|
||||
onInput={e => setUri(e.currentTarget.value)}
|
||||
className="border rounded-lg py-3 w-full max-w-[600px] px-4"
|
||||
placeholder={DEFAULT_POST}
|
||||
/>
|
||||
|
||||
<img src={arrowBottom as string} className="w-6" />
|
||||
|
||||
{loading ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<div className="w-full max-w-[600px] gap-8 flex flex-col">
|
||||
{!error && thread && uri && <Snippet thread={thread} />}
|
||||
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
|
||||
{error && (
|
||||
<div className="w-full border border-red-500 bg-red-50 px-4 py-3 rounded-lg">
|
||||
<p className="text-red-500 text-center">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Skeleton() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex-1 flex-col flex gap-2 pb-8">
|
||||
<div className="flex gap-2.5 items-center">
|
||||
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-100 shrink-0 animate-pulse" />
|
||||
<div className="flex-1">
|
||||
<div className="bg-neutral-100 animate-pulse w-64 h-4 rounded" />
|
||||
<div className="bg-neutral-100 animate-pulse w-32 h-3 mt-1 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-4 mt-2 bg-neutral-100 rounded animate-pulse" />
|
||||
<div className="w-5/6 h-4 bg-neutral-100 rounded animate-pulse" />
|
||||
<div className="w-3/4 h-4 bg-neutral-100 rounded animate-pulse" />
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// reset copied state after 2 seconds
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
const timeout = setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 2000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [copied])
|
||||
|
||||
const snippet = useMemo(() => {
|
||||
const record = thread.post.record
|
||||
|
||||
if (!AppBskyFeedPost.isRecord(record)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const lang = record.langs && record.langs.length > 0 ? record.langs[0] : ''
|
||||
const profileHref = toShareUrl(
|
||||
['/profile', thread.post.author.did].join('/'),
|
||||
)
|
||||
const urip = new AtUri(thread.post.uri)
|
||||
const href = toShareUrl(
|
||||
['/profile', thread.post.author.did, 'post', urip.rkey].join('/'),
|
||||
)
|
||||
|
||||
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
||||
// DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM!
|
||||
// Also, keep this code synced with the app code in Embed.tsx.
|
||||
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
||||
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
|
||||
thread.post.uri,
|
||||
)}" data-bluesky-cid="${escapeHtml(thread.post.cid)}"><p lang="${escapeHtml(
|
||||
lang,
|
||||
)}">${escapeHtml(record.text)}${
|
||||
record.embed
|
||||
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
|
||||
: ''
|
||||
}</p>— ${escapeHtml(
|
||||
thread.post.author.displayName || thread.post.author.handle,
|
||||
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
|
||||
thread.post.author.handle,
|
||||
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
|
||||
niceDate(thread.post.indexedAt),
|
||||
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
|
||||
}, [thread])
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 w-full">
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={snippet}
|
||||
className="border rounded-lg py-3 w-full px-4"
|
||||
readOnly
|
||||
autoFocus
|
||||
onFocus={() => {
|
||||
ref.current?.select()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="rounded-lg bg-brand text-white color-white py-3 px-4 whitespace-nowrap min-w-28"
|
||||
onClick={() => {
|
||||
ref.current?.focus()
|
||||
ref.current?.select()
|
||||
void navigator.clipboard.writeText(snippet)
|
||||
setCopied(true)
|
||||
}}>
|
||||
{copied ? 'Copied!' : 'Copy code'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function toShareUrl(path: string) {
|
||||
return `https://bsky.app${path}?ref_src=embed`
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on a snippet of code from React, which itself was based on the escape-html library.
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
* Copyright (c) 2012-2013 TJ Holowaychuk
|
||||
* Copyright (c) 2015 Andreas Lubbe
|
||||
* Copyright (c) 2015 Tiancheng "Timothy" Gu
|
||||
* Licensed as MIT.
|
||||
*/
|
||||
const matchHtmlRegExp = /["'&<>]/
|
||||
function escapeHtml(string: string) {
|
||||
const str = String(string)
|
||||
const match = matchHtmlRegExp.exec(str)
|
||||
if (!match) {
|
||||
return str
|
||||
}
|
||||
let escape
|
||||
let html = ''
|
||||
let index
|
||||
let lastIndex = 0
|
||||
for (index = match.index; index < str.length; index++) {
|
||||
switch (str.charCodeAt(index)) {
|
||||
case 34: // "
|
||||
escape = '"'
|
||||
break
|
||||
case 38: // &
|
||||
escape = '&'
|
||||
break
|
||||
case 39: // '
|
||||
escape = '''
|
||||
break
|
||||
case 60: // <
|
||||
escape = '<'
|
||||
break
|
||||
case 62: // >
|
||||
escape = '>'
|
||||
break
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if (lastIndex !== index) {
|
||||
html += str.slice(lastIndex, index)
|
||||
}
|
||||
lastIndex = index + 1
|
||||
html += escape
|
||||
}
|
||||
return lastIndex !== index ? html + str.slice(lastIndex, index) : html
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import '../index.css'
|
||||
|
||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
import {h, render} from 'preact'
|
||||
|
||||
import logo from '../../assets/logo.svg'
|
||||
import {Container} from '../components/container'
|
||||
import {Link} from '../components/link'
|
||||
import {Post} from '../components/post'
|
||||
import {getRkey} from '../utils'
|
||||
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
|
||||
const agent = new BskyAgent({
|
||||
service: 'https://public.api.bsky.app',
|
||||
})
|
||||
|
||||
const uri = `at://${window.location.pathname.slice('/embed/'.length)}`
|
||||
|
||||
console.log(uri)
|
||||
|
||||
if (!uri) {
|
||||
throw new Error('No uri in path')
|
||||
}
|
||||
|
||||
agent
|
||||
.getPostThread({
|
||||
uri,
|
||||
depth: 0,
|
||||
parentHeight: 0,
|
||||
})
|
||||
.then(({data}) => {
|
||||
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
|
||||
throw new Error('Expected a ThreadViewPost')
|
||||
}
|
||||
const pwiOptOut = !!data.thread.post.author.labels?.find(
|
||||
label => label.val === '!no-unauthenticated',
|
||||
)
|
||||
if (pwiOptOut) {
|
||||
render(<PwiOptOut thread={data.thread} />, root)
|
||||
} else {
|
||||
render(<Post thread={data.thread} />, root)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
render(<ErrorMessage />, root)
|
||||
})
|
||||
|
||||
function PwiOptOut({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
const href = `/profile/${thread.post.author.did}/post/${getRkey(thread.post)}`
|
||||
return (
|
||||
<Container href={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className="transition-transform hover:scale-110 absolute top-4 right-4">
|
||||
<img src={logo as string} className="h-6" />
|
||||
</Link>
|
||||
<div className="w-full py-12 gap-4 flex flex-col items-center">
|
||||
<p className="max-w-80 text-center w-full text-textLight">
|
||||
The author of this post has requested their posts not be displayed on
|
||||
external sites.
|
||||
</p>
|
||||
<Link
|
||||
href={href}
|
||||
className="max-w-80 rounded-lg bg-brand text-white color-white text-center py-1 px-4 w-full mx-auto">
|
||||
View on Bluesky
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorMessage() {
|
||||
return (
|
||||
<Container href="https://bsky.app/">
|
||||
<Link
|
||||
href="https://bsky.app/"
|
||||
className="transition-transform hover:scale-110 absolute top-4 right-4">
|
||||
<img src={logo as string} className="h-6" />
|
||||
</Link>
|
||||
<p className="my-16 text-center w-full text-textLight">
|
||||
Post not found, it may have been deleted.
|
||||
</p>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import {AtUri} from '@atproto/api'
|
||||
|
||||
export function niceDate(date: number | string | Date) {
|
||||
const d = new Date(date)
|
||||
return `${d.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} at ${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
}
|
||||
|
||||
export function getRkey({uri}: {uri: string}): string {
|
||||
const at = new AtUri(uri)
|
||||
return at.rkey
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: 'rgb(10,122,255)',
|
||||
textLight: 'rgb(66,87,108)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES5",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"types": ["vite/client"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react",
|
||||
"jsxFactory": "h",
|
||||
"jsxFragmentFactory": "Fragment",
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES5",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"strict": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["snippet"],
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import {resolve} from 'node:path'
|
||||
|
||||
import preact from '@preact/preset-vite'
|
||||
import legacy from '@vitejs/plugin-legacy'
|
||||
import type {UserConfig} from 'vite'
|
||||
import paths from 'vite-tsconfig-paths'
|
||||
|
||||
const config: UserConfig = {
|
||||
plugins: [
|
||||
preact(),
|
||||
paths(),
|
||||
legacy({
|
||||
targets: ['defaults', 'not IE 11'],
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
assetsDir: 'static',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'index.html'),
|
||||
post: resolve(__dirname, 'post.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -9,10 +9,6 @@ static/js/*.js
|
||||
static/js/*.map
|
||||
static/js/*.js.LICENSE.txt
|
||||
templates/scripts.html
|
||||
templates/*-embed.html
|
||||
static/embed/*.html
|
||||
static/embed/assets/*.js
|
||||
static/embed/assets/*.css
|
||||
|
||||
# Don't ignore this file
|
||||
!.gitignore
|
||||
|
||||
@@ -220,32 +220,6 @@
|
||||
.nativeDropdown-item:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Spinner component */
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.rotate-500ms {
|
||||
position: absolute;
|
||||
inset:0;
|
||||
animation: rotate 500ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes avatarHoverFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes avatarHoverFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
{% include "scripts.html" %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
|
||||
@@ -83,10 +83,11 @@ To run the build with Go, use staging credentials, your own, or any other accoun
|
||||
```
|
||||
cd social-app
|
||||
yarn && yarn build-web
|
||||
cp ./web-build/static/js/*.* bskyweb/static/js/
|
||||
cd bskyweb/
|
||||
go mod tidy
|
||||
go build -v -tags timetzdata -o bskyweb ./cmd/bskyweb
|
||||
./bskyweb serve --appview-host=https://public.api.bsky.app
|
||||
./bskyweb serve --pds-host=https://staging.bsky.dev --handle=<HANDLE> --password=<PASSWORD>
|
||||
```
|
||||
|
||||
On build success, access the application at [http://localhost:8100/](http://localhost:8100/). Subsequent changes require re-running the above steps in order to be reflected.
|
||||
|
||||
@@ -16,20 +16,14 @@
|
||||
"ios": {
|
||||
"simulator": true,
|
||||
"resourceClass": "large"
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
"channel": "production",
|
||||
"channel": "preview",
|
||||
"ios": {
|
||||
"resourceClass": "large"
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
@@ -41,12 +35,9 @@
|
||||
"android": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
"channel": "production"
|
||||
},
|
||||
"testflight": {
|
||||
"github": {
|
||||
"extends": "base",
|
||||
"ios": {
|
||||
"autoIncrement": true
|
||||
@@ -54,24 +45,7 @@
|
||||
"android": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"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"
|
||||
}
|
||||
"channel": "production"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
|
||||
@@ -1,825 +0,0 @@
|
||||
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 propText={
|
||||
<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>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
function Stuff() {
|
||||
return <Text>foo</Text>
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
function Stuff({ foo }) {
|
||||
return <View>{foo}</View>
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
function MyText() {
|
||||
return <Text>foo</Text>
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
function MyText({ foo }) {
|
||||
if (foo) {
|
||||
return <Text>foo</Text>
|
||||
}
|
||||
return <Text>foo</Text>
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text>{'foo'}</Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text>{foo + 'foo'}</Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text><Trans>{'foo'}</Trans></Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{foo['bar'] && <Bar />}
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{(foo === 'bar') && <Bar />}
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{(foo !== 'bar') && <Bar />}
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text>{\`foo\`}</Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text><Trans>{\`foo\`}</Trans></Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text>{_(msg\`foo\`)}</Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Text><Trans>{_(msg\`foo\`)}</Trans></Text>
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Foo>
|
||||
<View prop={stuff('foo')}>
|
||||
<Bar />
|
||||
</View>
|
||||
</Foo>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Foo>
|
||||
<View onClick={() => stuff('foo')}>
|
||||
<Bar />
|
||||
</View>
|
||||
</Foo>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{renderItem('foo')}
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{foo === 'foo' && <Bar />}
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{foo['foo'] && <Bar />}
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{check('foo') && <Bar />}
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{foo.bar && <Bar />}
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
<Trans>{renderItem('foo')}</Trans>
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{null}
|
||||
</View>
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<Text>
|
||||
<Trans>{null}</Trans>
|
||||
</Text>
|
||||
`,
|
||||
},
|
||||
],
|
||||
|
||||
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,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
function MyText() {
|
||||
return <Foo />
|
||||
}
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
function MyText({ foo }) {
|
||||
return <Foo>{foo}</Foo>
|
||||
}
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
function MyText({ foo }) {
|
||||
if (foo) {
|
||||
return <Foo>{foo}</Foo>
|
||||
}
|
||||
return <Text>foo</Text>
|
||||
}
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{'foo'}
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{foo && 'foo'}
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>{'foo'}</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{foo && <Trans>{'foo'}</Trans>}
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{10}
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>{10}</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>{foo + 10}</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{\`foo\`}
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>{\`foo\`}</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>{foo + \`foo\`}</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{_(msg\`foo\`)}
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
{foo + _(msg\`foo\`)}
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>{_(msg\`foo\`)}</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>{foo + _(msg\`foo\`)}</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>foo</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans><Trans>foo</Trans></Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>{foo}</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>{'foo'}</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
<Trans><Text>foo</Text></Trans>
|
||||
}>
|
||||
<Bar />
|
||||
</View>
|
||||
`,
|
||||
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)
|
||||
})
|
||||
@@ -1,331 +0,0 @@
|
||||
'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 suggestedTextWrappers = options.suggestedTextWrappers ?? {}
|
||||
const textProps = [...impliedTextProps]
|
||||
const textComponents = ['Text', ...impliedTextComponents]
|
||||
|
||||
function isTextComponent(tagName) {
|
||||
return textComponents.includes(tagName) || tagName.endsWith('Text')
|
||||
}
|
||||
|
||||
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 (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
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
|
||||
}
|
||||
},
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
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 === 'BinaryExpression' && parent.operator === '+') {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXExpressionContainer' ||
|
||||
parent.type === 'LogicalExpression'
|
||||
) {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
// Be conservative for other types.
|
||||
return
|
||||
}
|
||||
},
|
||||
TemplateLiteral(node) {
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
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 === 'CallExpression' &&
|
||||
parent.callee.type === 'Identifier' &&
|
||||
parent.callee.name === '_'
|
||||
) {
|
||||
// This is a user-facing string, keep going up.
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXExpressionContainer' ||
|
||||
parent.type === 'LogicalExpression' ||
|
||||
parent.type === 'TaggedTemplateExpression'
|
||||
) {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
// Be conservative for other types.
|
||||
return
|
||||
}
|
||||
},
|
||||
JSXElement(node) {
|
||||
if (getTagName(node) !== 'Trans') {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for this JSXElement.
|
||||
// TODO: Should nested <Trans> even be allowed?
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
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 <Trans> 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
|
||||
}
|
||||
},
|
||||
ReturnStatement(node) {
|
||||
let fnScope = context.getScope()
|
||||
while (fnScope && fnScope.type !== 'function') {
|
||||
fnScope = fnScope.upper
|
||||
}
|
||||
if (!fnScope) {
|
||||
return
|
||||
}
|
||||
const fn = fnScope.block
|
||||
if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
|
||||
return
|
||||
}
|
||||
if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
|
||||
return
|
||||
}
|
||||
if (!node.argument || node.argument.type !== 'JSXElement') {
|
||||
return
|
||||
}
|
||||
const openingEl = node.argument.openingElement
|
||||
if (openingEl.name.type !== 'JSXIdentifier') {
|
||||
return
|
||||
}
|
||||
const returnedComponentName = openingEl.name.name
|
||||
if (!isTextComponent(returnedComponentName)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Components ending with *Text must return <Text> or <SomeText>.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
||||
'use-typed-gates': require('./use-typed-gates'),
|
||||
},
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
exports.create = function create(context) {
|
||||
return {
|
||||
ImportSpecifier(node) {
|
||||
if (
|
||||
!node.local ||
|
||||
node.local.type !== 'Identifier' ||
|
||||
node.local.name !== 'useGate'
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
node.parent.type !== 'ImportDeclaration' ||
|
||||
!node.parent.source ||
|
||||
node.parent.source.type !== 'Literal'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const source = node.parent.source.value
|
||||
if (source.startsWith('statsig') || source.startsWith('@statsig')) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
"Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.",
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import {AtUri, BskyAgent} from '@atproto/api'
|
||||
import {TestBsky, TestNetwork} from '@atproto/dev-env'
|
||||
import fs from 'fs'
|
||||
import net from 'net'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import {TestNetwork, TestPds} from '@atproto/dev-env'
|
||||
import {AtUri, BskyAgent} from '@atproto/api'
|
||||
|
||||
export interface TestUser {
|
||||
email: string
|
||||
@@ -55,8 +55,12 @@ class StringIdGenerator {
|
||||
const ids = new StringIdGenerator()
|
||||
|
||||
export async function createServer(
|
||||
{inviteRequired}: {inviteRequired: boolean} = {
|
||||
{
|
||||
inviteRequired,
|
||||
phoneRequired,
|
||||
}: {inviteRequired: boolean; phoneRequired: boolean} = {
|
||||
inviteRequired: false,
|
||||
phoneRequired: false,
|
||||
},
|
||||
): Promise<TestPDS> {
|
||||
const port = 3000
|
||||
@@ -65,11 +69,23 @@ export async function createServer(
|
||||
const pdsUrl = `http://localhost:${port}`
|
||||
const id = ids.next()
|
||||
|
||||
const phoneParams = phoneRequired
|
||||
? {
|
||||
phoneVerificationRequired: true,
|
||||
phoneVerificationProvider: 'twilio',
|
||||
twilioAccountSid: 'ACXXXXXXX',
|
||||
twilioAuthToken: 'AUTH',
|
||||
twilioServiceSid: 'VAXXXXXXXX',
|
||||
}
|
||||
: {}
|
||||
|
||||
const testNet = await TestNetwork.create({
|
||||
pds: {
|
||||
port,
|
||||
hostname: 'localhost',
|
||||
dbPostgresSchema: `pds_${id}`,
|
||||
inviteRequired,
|
||||
...phoneParams,
|
||||
},
|
||||
bsky: {
|
||||
dbPostgresSchema: `bsky_${id}`,
|
||||
@@ -78,33 +94,36 @@ export async function createServer(
|
||||
},
|
||||
plc: {port: port2},
|
||||
})
|
||||
mockTwilio(testNet.pds)
|
||||
|
||||
// add the test mod authority
|
||||
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: [],
|
||||
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`,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
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'),
|
||||
@@ -162,7 +181,7 @@ class Mocker {
|
||||
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1},
|
||||
{
|
||||
headers: this.pds.adminAuthHeaders(),
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -173,6 +192,8 @@ class Mocker {
|
||||
email,
|
||||
handle: name + '.test',
|
||||
password: 'hunter2',
|
||||
verificationPhone: '1234567890',
|
||||
verificationCode: '000000',
|
||||
})
|
||||
await agent.upsertProfile(async () => {
|
||||
const blob = await agent.uploadBlob(this.pic, {
|
||||
@@ -337,7 +358,7 @@ class Mocker {
|
||||
await agent.api.com.atproto.server.createInviteCode(
|
||||
{useCount: 1, forAccount},
|
||||
{
|
||||
headers: this.pds.adminAuthHeaders(),
|
||||
headers: this.pds.adminAuthHeaders('admin'),
|
||||
encoding: 'application/json',
|
||||
},
|
||||
)
|
||||
@@ -352,11 +373,18 @@ class Mocker {
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
await createLabel(this.bsky, {
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
})
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
// @ts-ignore
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async labelProfile(label: string, user: string) {
|
||||
@@ -375,11 +403,18 @@ class Mocker {
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
await createLabel(this.bsky, {
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
})
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
// @ts-ignore
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
|
||||
@@ -387,11 +422,18 @@ class Mocker {
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid appview')
|
||||
}
|
||||
await createLabel(this.bsky, {
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
})
|
||||
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
|
||||
await labelSrvc.createLabels([
|
||||
{
|
||||
// @ts-ignore
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
neg: false,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async createMuteList(user: string, name: string): Promise<string> {
|
||||
@@ -442,19 +484,14 @@ async function getPort(start = 3000) {
|
||||
throw new Error('Unable to find an available port')
|
||||
}
|
||||
|
||||
const createLabel = async (
|
||||
bsky: TestBsky,
|
||||
opts: {uri: string; cid: string; val: string},
|
||||
) => {
|
||||
await bsky.db.db
|
||||
.insertInto('label')
|
||||
.values({
|
||||
uri: opts.uri,
|
||||
cid: opts.cid,
|
||||
val: opts.val,
|
||||
cts: new Date().toISOString(),
|
||||
neg: false,
|
||||
src: 'did:example:labeler',
|
||||
})
|
||||
.execute()
|
||||
export const mockTwilio = (pds: TestPds) => {
|
||||
if (!pds.ctx.phoneVerifier) return
|
||||
|
||||
pds.ctx.phoneVerifier.sendCode = async (_number: string) => {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
pds.ctx.phoneVerifier.verifyCode = async (_number: string, code: string) => {
|
||||
return code === '000000'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,19 @@
|
||||
module.exports = {
|
||||
locales: [
|
||||
'en',
|
||||
'ca',
|
||||
'de',
|
||||
'es',
|
||||
'fi',
|
||||
'fr',
|
||||
'ga',
|
||||
'hi',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'pt-BR',
|
||||
'tr',
|
||||
'uk',
|
||||
'ca',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'it',
|
||||
],
|
||||
catalogs: [
|
||||
{
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["ExpoScrollForwarderModule"]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'
|
||||
@@ -1,21 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoScrollForwarder'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Forward scroll gesture from UIView to UIScrollView'
|
||||
s.description = 'Forward scroll gesture from UIView to UIScrollView'
|
||||
s.author = 'bluesky-social'
|
||||
s.homepage = 'https://github.com/bluesky-social/social-app'
|
||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoScrollForwarderModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoScrollForwarder")
|
||||
|
||||
View(ExpoScrollForwarderView.self) {
|
||||
Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
|
||||
view.scrollViewTag = prop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
||||
// to apply the proper styling (e.g. border radius and shadows).
|
||||
class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
|
||||
var scrollViewTag: Int? {
|
||||
didSet {
|
||||
self.tryFindScrollView()
|
||||
}
|
||||
}
|
||||
|
||||
private var rctScrollView: RCTScrollView?
|
||||
private var rctRefreshCtrl: RCTRefreshControl?
|
||||
private var cancelGestureRecognizers: [UIGestureRecognizer]?
|
||||
private var animTimer: Timer?
|
||||
private var initialOffset: CGFloat = 0.0
|
||||
private var didImpact: Bool = false
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
|
||||
let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
|
||||
pg.delegate = self
|
||||
self.addGestureRecognizer(pg)
|
||||
|
||||
let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
||||
tg.isEnabled = false
|
||||
tg.delegate = self
|
||||
|
||||
let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
||||
lpg.minimumPressDuration = 0.01
|
||||
lpg.isEnabled = false
|
||||
lpg.delegate = self
|
||||
|
||||
self.cancelGestureRecognizers = [lpg, tg]
|
||||
}
|
||||
|
||||
|
||||
// We don't want to recognize the scroll pan gesture and the swipe back gesture together
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
|
||||
// interfere with the native swipe back gesture.
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
|
||||
return true
|
||||
}
|
||||
|
||||
let velocity = gestureRecognizer.velocity(in: self)
|
||||
return abs(velocity.y) > abs(velocity.x)
|
||||
}
|
||||
|
||||
// This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another
|
||||
// recognizer for this one.
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
self.stopTimer()
|
||||
}
|
||||
|
||||
// This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
|
||||
// the scroll view gesture's delegate, so we add an additional recognizer to detect this.
|
||||
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
|
||||
self.stopTimer()
|
||||
}
|
||||
|
||||
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) -> Void {
|
||||
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
|
||||
return
|
||||
}
|
||||
|
||||
let translation = sender.translation(in: self).y
|
||||
|
||||
if sender.state == .began {
|
||||
if sv.contentOffset.y < 0 {
|
||||
sv.contentOffset.y = 0
|
||||
}
|
||||
|
||||
self.initialOffset = sv.contentOffset.y
|
||||
}
|
||||
|
||||
if sender.state == .changed {
|
||||
sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
|
||||
|
||||
if sv.contentOffset.y <= -130, !didImpact {
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.impactOccurred()
|
||||
|
||||
self.didImpact = true
|
||||
}
|
||||
}
|
||||
|
||||
if sender.state == .ended {
|
||||
let velocity = sender.velocity(in: self).y
|
||||
self.didImpact = false
|
||||
|
||||
if sv.contentOffset.y <= -130 {
|
||||
self.rctRefreshCtrl?.forwarderBeginRefreshing()
|
||||
return
|
||||
}
|
||||
|
||||
// A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal
|
||||
// scroll view
|
||||
if abs(velocity) < 250, sv.contentOffset.y >= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
self.startDecayAnimation(translation, velocity)
|
||||
}
|
||||
}
|
||||
|
||||
func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
|
||||
guard let sv = self.rctScrollView?.scrollView else {
|
||||
return
|
||||
}
|
||||
|
||||
var velocity = velocity
|
||||
|
||||
self.enableCancelGestureRecognizers()
|
||||
|
||||
if velocity > 0 {
|
||||
velocity = min(velocity, 5000)
|
||||
} else {
|
||||
velocity = max(velocity, -5000)
|
||||
}
|
||||
|
||||
var animTranslation = -translation
|
||||
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { timer in
|
||||
velocity *= 0.9875
|
||||
animTranslation = (-velocity / 120) + animTranslation
|
||||
|
||||
let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
|
||||
|
||||
if nextOffset <= 0 {
|
||||
if self.initialOffset <= 1 {
|
||||
self.scrollToOffset(0)
|
||||
} else {
|
||||
sv.contentOffset.y = 0
|
||||
}
|
||||
|
||||
self.stopTimer()
|
||||
return
|
||||
} else {
|
||||
sv.contentOffset.y = nextOffset
|
||||
}
|
||||
|
||||
if abs(velocity) < 5 {
|
||||
self.stopTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dampenOffset(_ offset: CGFloat) -> CGFloat {
|
||||
if offset < 0 {
|
||||
return offset - (offset * 0.55)
|
||||
}
|
||||
|
||||
return offset
|
||||
}
|
||||
|
||||
func tryFindScrollView() {
|
||||
guard let scrollViewTag = scrollViewTag else {
|
||||
return
|
||||
}
|
||||
|
||||
// Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer.
|
||||
// Otherwise we might end up with duplicates when we switch back to that scrollview.
|
||||
self.removeCancelGestureRecognizers()
|
||||
|
||||
self.rctScrollView = self.appContext?
|
||||
.findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
|
||||
self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
|
||||
|
||||
self.addCancelGestureRecognizers()
|
||||
}
|
||||
|
||||
func addCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
self.rctScrollView?.scrollView?.addGestureRecognizer(r)
|
||||
}
|
||||
}
|
||||
|
||||
func removeCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func enableCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
r.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
func disableCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
r.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToOffset(_ offset: Int, animated: Bool = true) -> Void {
|
||||
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
|
||||
}
|
||||
|
||||
func stopTimer() -> Void {
|
||||
self.disableCancelGestureRecognizers()
|
||||
self.animTimer?.invalidate()
|
||||
self.animTimer = nil
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface ExpoScrollForwarderViewProps {
|
||||
scrollViewTag: number | null
|
||||
children: React.ReactNode
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
import * as React from 'react'
|
||||
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
|
||||
requireNativeViewManager('ExpoScrollForwarder')
|
||||
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
...rest
|
||||
}: ExpoScrollForwarderViewProps) {
|
||||
return <NativeView {...rest}>{children}</NativeView>
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react'
|
||||
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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`
|
||||
@@ -0,0 +1,3 @@
|
||||
#import <React/RCTViewManager.h>
|
||||
#import <React/RCTBridge.h>
|
||||
#import <React/RCTBridge+Private.h>
|
||||
@@ -0,0 +1,153 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
class RNUITextViewChild: UIView {
|
||||
@objc var text: String?
|
||||
@objc var onPress: RCTDirectEventBlock?
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#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
|
||||
@@ -0,0 +1,30 @@
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
@@ -0,0 +1,76 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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'
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.77.0",
|
||||
"version": "1.74.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -14,13 +14,11 @@
|
||||
"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-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",
|
||||
"build-embed": "cd bskyembed && yarn build && cd .. && node ./scripts/post-embed-build.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",
|
||||
"start": "expo start --dev-client",
|
||||
"start:prod": "expo start --dev-client --no-dev --minify",
|
||||
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
||||
@@ -28,7 +26,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 --cache --ext .js,.jsx,.ts,.tsx src",
|
||||
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
|
||||
"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",
|
||||
@@ -36,18 +34,17 @@
|
||||
"e2e:run": "NODE_ENV=test detox test --configuration ios.sim.debug --take-screenshots all",
|
||||
"perf:test": "NODE_ENV=test maestro test",
|
||||
"perf:test:run": "NODE_ENV=test maestro test __e2e__/maestro/scroll.yaml",
|
||||
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"yarn perf:test\" --duration 150000 --resultsFilePath .perf/results.json",
|
||||
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
|
||||
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
|
||||
"perf:measure": "NODE_ENV=test flashlight measure",
|
||||
"intl:build": "yarn intl:extract && yarn intl:compile",
|
||||
"intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)",
|
||||
"intl:extract": "lingui extract",
|
||||
"intl:compile": "lingui compile",
|
||||
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
|
||||
"update-extensions": "bash scripts/updateExtensions.sh",
|
||||
"export": "npx expo export",
|
||||
"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"
|
||||
"make-deploy-bundle": "bash scripts/bundleUpdate.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.2",
|
||||
@@ -57,8 +54,6 @@
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@expo/html-elements": "^0.4.2",
|
||||
"@expo/webpack-config": "^19.0.0",
|
||||
"@floating-ui/dom": "^1.6.3",
|
||||
"@floating-ui/react-dom": "^2.0.8",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
@@ -67,7 +62,10 @@
|
||||
"@mattermost/react-native-paste-input": "^0.6.4",
|
||||
"@miblanchard/react-native-slider": "^2.3.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-async-storage/async-storage": "1.21.0",
|
||||
"@react-native-camera-roll/camera-roll": "^5.2.2",
|
||||
"@react-native-clipboard/clipboard": "^1.10.0",
|
||||
"@react-native-community/blur": "^4.3.0",
|
||||
"@react-native-masked-view/masked-view": "0.3.0",
|
||||
"@react-native-menu/menu": "^0.8.0",
|
||||
"@react-native-picker/picker": "2.6.1",
|
||||
@@ -108,31 +106,27 @@
|
||||
"email-validator": "^2.0.4",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "^50.0.8",
|
||||
"expo-application": "^5.8.3",
|
||||
"expo-build-properties": "^0.11.1",
|
||||
"expo-camera": "~14.0.4",
|
||||
"expo-clipboard": "^5.0.1",
|
||||
"expo-constants": "~15.4.5",
|
||||
"expo-dev-client": "~3.3.8",
|
||||
"expo-device": "~5.9.3",
|
||||
"expo-haptics": "^12.8.1",
|
||||
"expo-image": "~1.10.6",
|
||||
"expo": "^50.0.0-preview.10",
|
||||
"expo-application": "~5.8.2",
|
||||
"expo-build-properties": "^0.11.0",
|
||||
"expo-camera": "~14.0.1",
|
||||
"expo-constants": "~15.4.3",
|
||||
"expo-dev-client": "~3.3.5",
|
||||
"expo-device": "~5.9.2",
|
||||
"expo-image": "~1.10.3",
|
||||
"expo-image-manipulator": "^11.8.0",
|
||||
"expo-image-picker": "~14.7.1",
|
||||
"expo-linear-gradient": "^12.7.2",
|
||||
"expo-linking": "^6.2.2",
|
||||
"expo-localization": "~14.8.3",
|
||||
"expo-localization": "~14.8.2",
|
||||
"expo-media-library": "~15.9.1",
|
||||
"expo-navigation-bar": "~2.8.1",
|
||||
"expo-notifications": "~0.27.6",
|
||||
"expo-notifications": "~0.27.3",
|
||||
"expo-sharing": "^11.10.0",
|
||||
"expo-splash-screen": "~0.26.4",
|
||||
"expo-splash-screen": "~0.26.2",
|
||||
"expo-status-bar": "~1.11.1",
|
||||
"expo-system-ui": "~2.9.3",
|
||||
"expo-task-manager": "~11.7.2",
|
||||
"expo-updates": "~0.24.10",
|
||||
"expo-web-browser": "~12.8.2",
|
||||
"expo-task-manager": "~11.7.0",
|
||||
"expo-updates": "~0.24.7",
|
||||
"expo-web-browser": "~12.8.1",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
"js-sha256": "^0.9.0",
|
||||
@@ -147,6 +141,7 @@
|
||||
"lodash.samplesize": "^4.2.0",
|
||||
"lodash.set": "^4.3.2",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lru_map": "^0.4.1",
|
||||
"mobx": "^6.6.1",
|
||||
"mobx-react-lite": "^3.4.0",
|
||||
"mobx-utils": "^6.0.6",
|
||||
@@ -157,16 +152,20 @@
|
||||
"psl": "^1.9.0",
|
||||
"react": "18.2.0",
|
||||
"react-avatar-editor": "^13.0.0",
|
||||
"react-circular-progressbar": "^2.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-keyed-flatten-children": "^3.0.0",
|
||||
"react-native": "0.73.2",
|
||||
"react-native-appstate-hook": "^1.0.6",
|
||||
"react-native-date-picker": "^4.4.0",
|
||||
"react-native-drawer-layout": "^4.0.0-alpha.3",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "~2.14.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-get-random-values": "~1.8.0",
|
||||
"react-native-haptic-feedback": "^1.14.0",
|
||||
"react-native-image-crop-picker": "^0.38.1",
|
||||
"react-native-ios-context-menu": "^1.15.3",
|
||||
"react-native-linear-gradient": "^2.6.2",
|
||||
"react-native-pager-view": "6.2.3",
|
||||
"react-native-picker-select": "^8.1.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
@@ -175,10 +174,12 @@
|
||||
"react-native-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "~3.29.0",
|
||||
"react-native-svg": "14.1.0",
|
||||
"react-native-uitextview": "^1.1.6",
|
||||
"react-native-ui-text-view": "link:./modules/react-native-ui-text-view",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"react-native-uuid": "^2.0.1",
|
||||
"react-native-version-number": "^0.3.6",
|
||||
"react-native-web": "~0.19.6",
|
||||
"react-native-web-linear-gradient": "^1.1.2",
|
||||
"react-native-web-webview": "^1.0.2",
|
||||
"react-native-webview": "13.6.4",
|
||||
"react-responsive": "^9.0.2",
|
||||
@@ -187,11 +188,12 @@
|
||||
"statsig-react-native-expo": "^4.6.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
"use-deep-compare": "^1.1.0",
|
||||
"zeego": "^1.6.2",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.4",
|
||||
"@atproto/dev-env": "^0.2.28",
|
||||
"@babel/core": "^7.23.2",
|
||||
"@babel/preset-env": "^7.20.0",
|
||||
"@babel/runtime": "^7.20.0",
|
||||
@@ -232,7 +234,6 @@
|
||||
"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",
|
||||
@@ -256,7 +257,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -314,9 +314,6 @@
|
||||
]
|
||||
},
|
||||
"lint-staged": {
|
||||
"*{.js,.jsx,.ts,.tsx}": [
|
||||
"eslint --cache --fix",
|
||||
"prettier --cache --write --ignore-unknown"
|
||||
]
|
||||
"*{.js,.jsx,.ts,.tsx}": "yarn eslint --fix"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3594,19 +3594,3 @@ 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,11 +0,0 @@
|
||||
# Expo Haptics Patch
|
||||
|
||||
Whenever we migrated to Expo Haptics, there was a difference between how the previous and new libraries handled the
|
||||
Android implementation of an iOS "light" haptic. The previous library used the `Vibration` API solely, which does not
|
||||
have any configuration for intensity of vibration. The `Vibration` API has also been deprecated since SDK 26. See:
|
||||
https://github.com/mkuczera/react-native-haptic-feedback/blob/master/android/src/main/java/com/mkuczera/vibrateFactory/VibrateWithDuration.java
|
||||
|
||||
Expo Haptics is using `VibrationManager` API on SDK >= 31. See: https://github.com/expo/expo/blob/main/packages/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt#L19
|
||||
The timing and intensity of their haptic configurations though differs greatly from the original implementation. This
|
||||
patch uses the new `VibrationManager` API to create the same vibration that would have been seen in the deprecated
|
||||
`Vibration` API.
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||
index 26c52af..b949a4c 100644
|
||||
--- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||
+++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||
@@ -42,7 +42,7 @@ class HapticsModule : Module() {
|
||||
|
||||
private fun vibrate(type: HapticsVibrationType) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
- vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1))
|
||||
+ vibrator.vibrate(VibrationEffect.createWaveform(type.oldSDKPattern, intArrayOf(0, 100), -1))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(type.oldSDKPattern, -1)
|
||||
@@ -1,26 +0,0 @@
|
||||
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,
|
||||
@@ -1,7 +0,0 @@
|
||||
# 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,22 +1,11 @@
|
||||
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
index e9b330f..1ecdf0a 100644
|
||||
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
@@ -16,4 +16,6 @@
|
||||
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
|
||||
@property (nonatomic, weak) UIScrollView *scrollView;
|
||||
|
||||
+- (void)forwarderBeginRefreshing;
|
||||
+
|
||||
@end
|
||||
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
index b09e653..4c32b31 100644
|
||||
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,9 +198,53 @@ - (void)refreshControlValueChanged
|
||||
@@ -198,6 +198,14 @@ - (void)refreshControlValueChanged
|
||||
[self setCurrentRefreshingState:super.refreshing];
|
||||
_refreshingProgrammatically = NO;
|
||||
|
||||
|
||||
+ if (@available(iOS 17.4, *)) {
|
||||
+ if (_currentRefreshingState) {
|
||||
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
@@ -27,43 +16,4 @@ index b09e653..4c32b31 100644
|
||||
+
|
||||
if (_onRefresh) {
|
||||
_onRefresh(nil);
|
||||
}
|
||||
}
|
||||
|
||||
+/*
|
||||
+ This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
|
||||
+ libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
|
||||
+ function.
|
||||
+ */
|
||||
+- (void)forwarderBeginRefreshing
|
||||
+{
|
||||
+ _refreshingProgrammatically = NO;
|
||||
+
|
||||
+ [self sizeToFit];
|
||||
+
|
||||
+ if (!self.scrollView) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
|
||||
+
|
||||
+ [UIView animateWithDuration:0.3
|
||||
+ delay:0
|
||||
+ options:UIViewAnimationOptionBeginFromCurrentState
|
||||
+ animations:^(void) {
|
||||
+ // Whenever we call this method, the scrollview will always be at a position of
|
||||
+ // -130 or less. Scrolling back to -65 simulates the default behavior of RCTRefreshControl
|
||||
+ [scrollView setContentOffset:CGPointMake(0, -65)];
|
||||
+ }
|
||||
+ completion:^(__unused BOOL finished) {
|
||||
+ [super beginRefreshing];
|
||||
+ [self setCurrentRefreshingState:super.refreshing];
|
||||
+
|
||||
+ if (self->_onRefresh) {
|
||||
+ self->_onRefresh(nil);
|
||||
+ }
|
||||
+ }
|
||||
+ ];
|
||||
+}
|
||||
+
|
||||
@end
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
# ***This second part of this patch is load bearing, do not remove.***
|
||||
|
||||
## RefreshControl Patch - iOS 17.4 Haptic Regression
|
||||
# RefreshControl Patch
|
||||
|
||||
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
|
||||
|
||||
## RefreshControl Path - ScrollForwarder
|
||||
|
||||
Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class.
|
||||
This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that
|
||||
module.
|
||||
in the RN repo: https://github.com/facebook/react-native/issues/43388
|
||||
@@ -9,13 +9,10 @@ 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"
|
||||
DEPLOYMENT_URL="https://updates.bsky.app/v1/upload?runtime-version=$RUNTIME_VERSION&bundle-version=$BUNDLE_VERSION"
|
||||
|
||||
tar czvf bundle.tar.gz ./*
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// const path = require('node:path')
|
||||
// const fs = require('node:fs')
|
||||
|
||||
// const projectRoot = path.join(__dirname, '..')
|
||||
|
||||
// // copy embed assets to web-build
|
||||
|
||||
// const embedAssetSource = path.join(
|
||||
// projectRoot,
|
||||
// 'bskyembed',
|
||||
// 'dist',
|
||||
// 'static',
|
||||
// 'embed',
|
||||
// 'assets',
|
||||
// )
|
||||
|
||||
// const embedAssetDest = path.join(
|
||||
// projectRoot,
|
||||
// 'web-build',
|
||||
// 'static',
|
||||
// 'embed',
|
||||
// 'assets',
|
||||
// )
|
||||
|
||||
// fs.cpSync(embedAssetSource, embedAssetDest, {recursive: true})
|
||||
|
||||
// // copy entrypoint(s) to web-build
|
||||
|
||||
// // additional entrypoints will need more work, but this'll do for now
|
||||
// const embedHtmlSource = path.join(
|
||||
// projectRoot,
|
||||
// 'bskyembed',
|
||||
// 'dist',
|
||||
// 'index.html',
|
||||
// )
|
||||
|
||||
// const embedHtmlDest = path.join(
|
||||
// projectRoot,
|
||||
// 'web-build',
|
||||
// 'static',
|
||||
// 'embed',
|
||||
// 'post.html',
|
||||
// )
|
||||
|
||||
// fs.copyFileSync(embedHtmlSource, embedHtmlDest)
|
||||
|
||||
// console.log(`Copied embed assets to web-build`)
|
||||
|
||||
console.log('post-embed-build.js - waiting for embedr!')
|
||||
@@ -1,7 +1,11 @@
|
||||
#!/bin/bash
|
||||
outputIos=$(eas build:version:get -p ios)
|
||||
outputAndroid=$(eas build:version:get -p android)
|
||||
BSKY_IOS_BUILD_NUMBER=${outputIos#*buildNumber - }
|
||||
BSKY_ANDROID_VERSION_CODE=${outputAndroid#*versionCode - }
|
||||
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 $*"
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/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 $*"
|
||||
|
||||
@@ -12,15 +12,19 @@ import {
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
|
||||
|
||||
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 {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {useNotificationsListener} from 'lib/notifications/notifications'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import * as notifications from 'lib/notifications/notifications'
|
||||
import {
|
||||
asyncStoragePersister,
|
||||
dehydrateOptions,
|
||||
queryClient,
|
||||
} from 'lib/react-query'
|
||||
import {s} from 'lib/styles'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {Provider as DialogStateProvider} from 'state/dialogs'
|
||||
@@ -55,16 +59,16 @@ function InnerApp() {
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
notifications.init(queryClient)
|
||||
listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
|
||||
const account = readLastActiveAccount()
|
||||
const account = persisted.get('session').currentAccount
|
||||
resumeSession(account)
|
||||
}, [resumeSession, _])
|
||||
|
||||
@@ -75,29 +79,25 @@ function InnerApp() {
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<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>
|
||||
<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>
|
||||
</React.Fragment>
|
||||
</Splash>
|
||||
</Alf>
|
||||
@@ -105,12 +105,6 @@ function InnerApp() {
|
||||
)
|
||||
}
|
||||
|
||||
function PushNotificationsListener({children}: {children: React.ReactNode}) {
|
||||
const queryClient = useQueryClient()
|
||||
useNotificationsListener(queryClient)
|
||||
return children
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
@@ -127,27 +121,31 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,44 @@
|
||||
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 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 {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
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 {
|
||||
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 UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
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 UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
@@ -42,7 +48,7 @@ function InnerApp() {
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
const account = readLastActiveAccount()
|
||||
const account = persisted.get('session').currentAccount
|
||||
resumeSession(account)
|
||||
}, [resumeSession])
|
||||
|
||||
@@ -54,27 +60,25 @@ function InnerApp() {
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<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>
|
||||
<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>
|
||||
</React.Fragment>
|
||||
</Alf>
|
||||
)
|
||||
@@ -96,27 +100,31 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
<Stack.Screen
|
||||
name="ProfileFeed"
|
||||
getComponent={() => ProfileFeedScreen}
|
||||
options={{title: title(msg`Feed`)}}
|
||||
options={{title: title(msg`Feed`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFeedLikedBy"
|
||||
@@ -331,7 +331,11 @@ function HomeTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||
<HomeTab.Screen
|
||||
name="Home"
|
||||
getComponent={() => HomeScreen}
|
||||
options={{requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(HomeTab)}
|
||||
</HomeTab.Navigator>
|
||||
)
|
||||
@@ -367,7 +371,11 @@ function FeedsTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
|
||||
<FeedsTab.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(FeedsTab as typeof HomeTab)}
|
||||
</FeedsTab.Navigator>
|
||||
)
|
||||
@@ -443,7 +451,7 @@ const FlatNavigator = () => {
|
||||
<Flat.Screen
|
||||
name="Home"
|
||||
getComponent={() => HomeScreen}
|
||||
options={{title: title(msg`Home`)}}
|
||||
options={{title: title(msg`Home`), requireAuth: true}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Search"
|
||||
@@ -453,7 +461,7 @@ const FlatNavigator = () => {
|
||||
<Flat.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{title: title(msg`Feeds`)}}
|
||||
options={{title: title(msg`Feeds`), requireAuth: true}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Notifications"
|
||||
@@ -531,10 +539,8 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
|
||||
const {currentAccount} = useSession()
|
||||
const {openModal} = useModalControls()
|
||||
const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
|
||||
|
||||
function onReady() {
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
initAnalytics(currentAccount)
|
||||
|
||||
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
|
||||
@@ -549,10 +555,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
logEvent('router:navigate', {
|
||||
from: prevLoggedRouteName.current,
|
||||
})
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
logEvent('router:navigate', {})
|
||||
}}
|
||||
onReady={() => {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
|
||||
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {APP_LANGUAGES} from '#/locale/languages'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
|
||||
|
||||
export function AppLanguageDropdown() {
|
||||
const t = useTheme()
|
||||
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
}
|
||||
},
|
||||
[sanitizedLang, setLangPrefs],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={a.relative}>
|
||||
<RNPickerSelect
|
||||
placeholder={{}}
|
||||
value={sanitizedLang}
|
||||
onValueChange={onChangeAppLanguage}
|
||||
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
|
||||
label: l.name,
|
||||
value: l.code2,
|
||||
key: l.code2,
|
||||
}))}
|
||||
useNativeAndroidPickerStyle={false}
|
||||
style={{
|
||||
inputAndroid: {
|
||||
color: t.atoms.text_contrast_medium.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 12 + 4,
|
||||
},
|
||||
inputIOS: {
|
||||
color: t.atoms.text.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 12 + 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{left: 'auto'},
|
||||
{pointerEvents: 'none'},
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
]}>
|
||||
<ChevronDown fill={t.atoms.text.color} size="xs" />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {APP_LANGUAGES} from '#/locale/languages'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function AppLanguageDropdown() {
|
||||
const t = useTheme()
|
||||
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = ev.target.value
|
||||
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
}
|
||||
},
|
||||
[sanitizedLang, setLangPrefs],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center, a.flex_shrink]}>
|
||||
<Text aria-hidden={true} style={t.atoms.text_contrast_medium}>
|
||||
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
|
||||
</Text>
|
||||
<ChevronDown fill={t.atoms.text.color} size="xs" style={a.flex_shrink} />
|
||||
|
||||
<select
|
||||
value={sanitizedLang}
|
||||
onChange={onChangeAppLanguage}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
MozAppearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
color: 'transparent',
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
padding: 0,
|
||||
}}>
|
||||
{APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => (
|
||||
<option key={l.code2} value={l.code2}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {android, atoms as a, flatten, tokens, useTheme} from '#/alf'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {normalizeTextStyles} from '#/components/Typography'
|
||||
@@ -58,10 +60,6 @@ export type ButtonState = {
|
||||
|
||||
export type ButtonContext = VariantProps & ButtonState
|
||||
|
||||
type NonTextElements =
|
||||
| React.ReactElement
|
||||
| Iterable<React.ReactElement | null | undefined | boolean>
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID'
|
||||
@@ -71,9 +69,11 @@ export type ButtonProps = Pick<
|
||||
testID?: string
|
||||
label: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
|
||||
children:
|
||||
| React.ReactNode
|
||||
| string
|
||||
| ((context: ButtonContext) => React.ReactNode | string)
|
||||
}
|
||||
|
||||
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
|
||||
|
||||
const Context = React.createContext<VariantProps & ButtonState>({
|
||||
@@ -405,12 +405,51 @@ export function Button({
|
||||
</View>
|
||||
)}
|
||||
<Context.Provider value={context}>
|
||||
{typeof children === 'function' ? children(context) : children}
|
||||
<ButtonTextErrorBoundary>
|
||||
{/* @ts-ignore */}
|
||||
{typeof children === 'string' || children?.type === Trans ? (
|
||||
/* @ts-ignore */
|
||||
<ButtonText>{children}</ButtonText>
|
||||
) : typeof children === 'function' ? (
|
||||
children(context)
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ButtonTextErrorBoundary>
|
||||
</Context.Provider>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export class ButtonTextErrorBoundary extends React.Component<
|
||||
React.PropsWithChildren<{}>,
|
||||
{hasError: boolean; error: Error | undefined}
|
||||
> {
|
||||
public state = {
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
}
|
||||
|
||||
public static getDerivedStateFromError(error: Error) {
|
||||
return {hasError: true, error}
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
logger.error('ButtonTextErrorBoundary caught an error', {
|
||||
message: error.message,
|
||||
errorInfo,
|
||||
})
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return <ButtonText>ERROR</ButtonText>
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export function useSharedButtonTextStyles() {
|
||||
const t = useTheme()
|
||||
const {color, variant, disabled, size} = useButtonContext()
|
||||
|
||||
@@ -83,7 +83,7 @@ export function Outer({
|
||||
const sheetOptions = nativeOptions?.sheet || {}
|
||||
const hasSnapPoints = !!sheetOptions.snapPoints
|
||||
const insets = useSafeAreaInsets()
|
||||
const closeCallbacks = React.useRef<(() => void)[]>([])
|
||||
const closeCallback = React.useRef<() => void>()
|
||||
const {setDialogIsOpen} = useDialogStateControlContext()
|
||||
|
||||
/*
|
||||
@@ -96,51 +96,22 @@ export function Outer({
|
||||
*/
|
||||
const isOpen = openIndex > -1
|
||||
|
||||
const callQueuedCallbacks = React.useCallback(() => {
|
||||
for (const cb of closeCallbacks.current) {
|
||||
try {
|
||||
cb()
|
||||
} catch (e: any) {
|
||||
logger.error('Error running close callback', e)
|
||||
}
|
||||
}
|
||||
|
||||
closeCallbacks.current = []
|
||||
}, [])
|
||||
|
||||
const open = React.useCallback<DialogControlProps['open']>(
|
||||
({index} = {}) => {
|
||||
// Run any leftover callbacks that might have been queued up before calling `.open()`
|
||||
callQueuedCallbacks()
|
||||
|
||||
setDialogIsOpen(control.id, true)
|
||||
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
|
||||
setOpenIndex(index || 0)
|
||||
sheet.current?.snapToIndex(index || 0)
|
||||
},
|
||||
[setDialogIsOpen, control.id, callQueuedCallbacks],
|
||||
[setOpenIndex, setDialogIsOpen, control.id],
|
||||
)
|
||||
|
||||
// This is the function that we call when we want to dismiss the dialog.
|
||||
const close = React.useCallback<DialogControlProps['close']>(cb => {
|
||||
if (typeof cb === 'function') {
|
||||
closeCallbacks.current.push(cb)
|
||||
if (cb && typeof cb === 'function') {
|
||||
closeCallback.current = cb
|
||||
}
|
||||
sheet.current?.close()
|
||||
}, [])
|
||||
|
||||
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
|
||||
// happen before we run this. It is passed to the `BottomSheet` component.
|
||||
const onCloseAnimationComplete = React.useCallback(() => {
|
||||
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
|
||||
// tells us that we need to toggle the accessibility overlay setting
|
||||
setDialogIsOpen(control.id, false)
|
||||
setOpenIndex(-1)
|
||||
|
||||
callQueuedCallbacks()
|
||||
onClose?.()
|
||||
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
|
||||
|
||||
useImperativeHandle(
|
||||
control.ref,
|
||||
() => ({
|
||||
@@ -150,6 +121,21 @@ export function Outer({
|
||||
[open, close],
|
||||
)
|
||||
|
||||
const onCloseInner = React.useCallback(() => {
|
||||
try {
|
||||
closeCallback.current?.()
|
||||
} catch (e: any) {
|
||||
logger.error(`Dialog closeCallback failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
} finally {
|
||||
closeCallback.current = undefined
|
||||
}
|
||||
setDialogIsOpen(control.id, false)
|
||||
onClose?.()
|
||||
setOpenIndex(-1)
|
||||
}, [control.id, onClose, setDialogIsOpen])
|
||||
|
||||
const context = React.useMemo(() => ({close}), [close])
|
||||
|
||||
return (
|
||||
@@ -177,7 +163,7 @@ export function Outer({
|
||||
backdropComponent={Backdrop}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onClose={onCloseAnimationComplete}>
|
||||
onClose={onCloseInner}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {TouchableWithoutFeedback, View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
|
||||
import {View, TouchableWithoutFeedback} from 'react-native'
|
||||
import {FocusScope} from '@tamagui/focus-scope'
|
||||
import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {FocusScope} from '@tamagui/focus-scope'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {
|
||||
DialogControlProps,
|
||||
DialogInnerProps,
|
||||
DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
|
||||
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/types'
|
||||
export {Input} from '#/components/forms/TextField'
|
||||
|
||||
@@ -33,40 +29,22 @@ export function Outer({
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [isVisible, setIsVisible] = React.useState(true)
|
||||
const {setDialogIsOpen} = useDialogStateControlContext()
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
setDialogIsOpen(control.id, true)
|
||||
setIsOpen(true)
|
||||
setDialogIsOpen(control.id, true)
|
||||
}, [setIsOpen, setDialogIsOpen, control.id])
|
||||
|
||||
const close = React.useCallback<DialogControlProps['close']>(
|
||||
cb => {
|
||||
setDialogIsOpen(control.id, false)
|
||||
setIsOpen(false)
|
||||
|
||||
try {
|
||||
if (cb && typeof cb === 'function') {
|
||||
// This timeout ensures that the callback runs at the same time as it would on native. I.e.
|
||||
// console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2')
|
||||
// This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output
|
||||
// 'Step 1', 'Step 3', 'Step 2'.
|
||||
setTimeout(cb)
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(`Dialog closeCallback failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
}
|
||||
|
||||
onClose?.()
|
||||
},
|
||||
[control.id, onClose, setDialogIsOpen],
|
||||
)
|
||||
|
||||
const handleBackgroundPress = React.useCallback(async () => {
|
||||
close()
|
||||
}, [close])
|
||||
const close = React.useCallback(async () => {
|
||||
setIsVisible(false)
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
setIsOpen(false)
|
||||
setIsVisible(true)
|
||||
setDialogIsOpen(control.id, false)
|
||||
onClose?.()
|
||||
}, [onClose, setIsOpen, setDialogIsOpen, control.id])
|
||||
|
||||
useImperativeHandle(
|
||||
control.ref,
|
||||
@@ -74,7 +52,7 @@ export function Outer({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[close, open],
|
||||
[open, close],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -87,7 +65,7 @@ export function Outer({
|
||||
document.addEventListener('keydown', handler)
|
||||
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [close, isOpen])
|
||||
}, [isOpen, close])
|
||||
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
@@ -104,7 +82,7 @@ export function Outer({
|
||||
<TouchableWithoutFeedback
|
||||
accessibilityHint={undefined}
|
||||
accessibilityLabel={_(msg`Close active dialog`)}
|
||||
onPress={handleBackgroundPress}>
|
||||
onPress={close}>
|
||||
<View
|
||||
style={[
|
||||
web(a.fixed),
|
||||
@@ -114,15 +92,17 @@ export function Outer({
|
||||
gtMobile ? a.p_lg : a.p_md,
|
||||
{overflowY: 'auto'},
|
||||
]}>
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(150)}
|
||||
// exiting={FadeOut.duration(150)}
|
||||
style={[
|
||||
web(a.fixed),
|
||||
a.inset_0,
|
||||
{opacity: 0.8, backgroundColor: t.palette.black},
|
||||
]}
|
||||
/>
|
||||
{isVisible && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(150)}
|
||||
// exiting={FadeOut.duration(150)}
|
||||
style={[
|
||||
web(a.fixed),
|
||||
a.inset_0,
|
||||
{opacity: 0.8, backgroundColor: t.palette.black},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View
|
||||
style={[
|
||||
@@ -134,7 +114,7 @@ export function Outer({
|
||||
minHeight: web('calc(90vh - 36px)') || undefined,
|
||||
},
|
||||
]}>
|
||||
{children}
|
||||
{isVisible ? children : null}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
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 {View} from 'react-native'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useNavigation} from '@react-navigation/core'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export function Error({
|
||||
@@ -22,7 +20,6 @@ export function Error({
|
||||
onRetry?: () => unknown
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
@@ -71,25 +68,21 @@ export function Error({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label={_(msg`Press to retry`)}
|
||||
label="Click here"
|
||||
onPress={onRetry}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label={_(msg`Return to previous page`)}
|
||||
label="Click here"
|
||||
onPress={onGoBack}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
Go Back
|
||||
</Button>
|
||||
</View>
|
||||
</CenteredView>
|
||||
|
||||