diff --git a/.env.example b/.env.example index ac8dcab1f8..96a1548d66 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,3 @@ EXPO_PUBLIC_BITDRIFT_API_KEY= # bapp-config web worker URL BAPP_CONFIG_DEV_URL= - -# Dev-only passthrough value for bapp-config web worker -BAPP_CONFIG_DEV_BYPASS_SECRET= diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml index 6d573b0f71..45ff55810b 100644 --- a/.github/workflows/build-and-push-bskyweb-aws.yaml +++ b/.github/workflows/build-and-push-bskyweb-aws.yaml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Docker buildx uses: docker/setup-buildx-action@v1 diff --git a/.github/workflows/build-and-push-bskyweb-ghcr.yaml b/.github/workflows/build-and-push-bskyweb-ghcr.yaml index de687f66c1..6a959be304 100644 --- a/.github/workflows/build-and-push-bskyweb-ghcr.yaml +++ b/.github/workflows/build-and-push-bskyweb-ghcr.yaml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Docker buildx uses: docker/setup-buildx-action@v1 diff --git a/.github/workflows/build-and-push-embedr-aws.yaml b/.github/workflows/build-and-push-embedr-aws.yaml index a6b2d9c3e0..fa1e8bd7dd 100644 --- a/.github/workflows/build-and-push-embedr-aws.yaml +++ b/.github/workflows/build-and-push-embedr-aws.yaml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Docker buildx uses: docker/setup-buildx-action@v1 diff --git a/.github/workflows/build-and-push-link-aws.yaml b/.github/workflows/build-and-push-link-aws.yaml index 7fbbc23c82..be77ff1841 100644 --- a/.github/workflows/build-and-push-link-aws.yaml +++ b/.github/workflows/build-and-push-link-aws.yaml @@ -3,9 +3,9 @@ on: workflow_dispatch: pull_request: paths: - - 'bskylink/**' - - 'Dockerfile.bskylink' - - '.github/workflows/build-and-push-link-aws.yaml' + - "bskylink/**" + - "Dockerfile.bskylink" + - ".github/workflows/build-and-push-link-aws.yaml" env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Docker buildx uses: docker/setup-buildx-action@v1 diff --git a/.github/workflows/build-and-push-ogcard-aws.yaml b/.github/workflows/build-and-push-ogcard-aws.yaml index 3093ff3f59..961768b2b1 100644 --- a/.github/workflows/build-and-push-ogcard-aws.yaml +++ b/.github/workflows/build-and-push-ogcard-aws.yaml @@ -3,9 +3,9 @@ on: workflow_dispatch: pull_request: paths: - - 'bskyogcard/**' - - 'Dockerfile.bskyogcard' - - '.github/workflows/build-and-push-ogcard-aws.yaml' + - "bskyogcard/**" + - "Dockerfile.bskyogcard" + - ".github/workflows/build-and-push-ogcard-aws.yaml" env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Docker buildx uses: docker/setup-buildx-action@v1 diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index 235aa9af2c..595cfa74d2 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -30,7 +30,7 @@ jobs: fetch-depth: 5 - name: 🔧 Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -39,7 +39,7 @@ jobs: uses: dcarbone/install-jq-action@v2 - name: 🔨 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@main with: expo-version: latest eas-version: latest @@ -54,7 +54,7 @@ jobs: java-version: "17" - name: ⚙️ Install dependencies - run: yarn install + run: yarn install --frozen-lockfile - name: 🔤 Compile translations run: yarn intl:build 2>&1 | tee i18n.log diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml index 6e5692a9d9..24eba2489f 100644 --- a/.github/workflows/build-submit-ios.yml +++ b/.github/workflows/build-submit-ios.yml @@ -15,7 +15,7 @@ jobs: build: if: github.repository == 'bluesky-social/social-app' name: Build and Submit iOS - runs-on: macos-26 + runs-on: macos-26-xlarge steps: - name: Check for EXPO_TOKEN run: > @@ -30,7 +30,7 @@ jobs: fetch-depth: 5 - name: 🔧 Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -39,7 +39,7 @@ jobs: uses: dcarbone/install-jq-action@v2 - name: 🔨 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@main with: expo-version: latest eas-version: latest @@ -49,7 +49,7 @@ jobs: run: yarn global add eas-cli-local-build-plugin - name: ⚙️ Install dependencies - run: yarn install + run: yarn install --frozen-lockfile - uses: maxim-lobanov/setup-xcode@v1 with: @@ -61,7 +61,7 @@ jobs: version: 1.16.2 - name: 💾 Cache Pods - uses: actions/cache@v3 + uses: actions/cache@v4 id: pods-cache with: path: ./ios/Pods diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index cf1541098a..3204c7bb81 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -55,7 +55,7 @@ jobs: run: git fetch origin main:main --depth 100 - name: 🔧 Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -86,7 +86,7 @@ jobs: run: yarn typecheck - name: 🔨 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@main if: ${{ !steps.fingerprint.outputs.includes-changes }} with: expo-version: latest @@ -172,13 +172,13 @@ jobs: fetch-depth: 5 - name: 🔧 Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn - name: 🔨 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@main with: expo-version: latest eas-version: latest @@ -188,7 +188,7 @@ jobs: run: yarn global add eas-cli-local-build-plugin - name: ⚙️ Install dependencies - run: yarn install + run: yarn install --frozen-lockfile - uses: maxim-lobanov/setup-xcode@v1 with: @@ -200,7 +200,7 @@ jobs: version: 1.16.2 - name: 💾 Cache Pods - uses: actions/cache@v3 + uses: actions/cache@v4 id: pods-cache with: path: ./ios/Pods @@ -276,13 +276,13 @@ jobs: fetch-depth: 5 - name: 🔧 Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn - name: 🔨 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@main with: expo-version: latest eas-version: latest @@ -297,7 +297,7 @@ jobs: java-version: "17" - name: ⚙️ Install dependencies - run: yarn install + run: yarn install --frozen-lockfile - name: 🔤 Compile translations run: yarn intl:build diff --git a/.github/workflows/golang-test-lint.yml b/.github/workflows/golang-test-lint.yml index af849d1355..cd3e27cb7b 100644 --- a/.github/workflows/golang-test-lint.yml +++ b/.github/workflows/golang-test-lint.yml @@ -7,7 +7,7 @@ on: - main concurrency: - group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' + group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}" cancel-in-progress: true jobs: @@ -15,11 +15,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Git Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Go tooling uses: actions/setup-go@v3 with: - go-version: '1.23' + go-version: "1.25" - name: Dummy Static Files run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt - name: Check @@ -32,11 +32,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Git Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Go tooling uses: actions/setup-go@v3 with: - go-version: '1.23' + go-version: "1.25" - name: Dummy Static Files run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt - name: Lint diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2268ce359c..6d6ee7eddd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,7 +6,7 @@ on: branches: - main concurrency: - group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' + group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}" cancel-in-progress: true jobs: @@ -15,11 +15,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out Git repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc + cache: yarn - name: Yarn install uses: Wandalen/wretry.action@master with: @@ -41,11 +42,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out Git repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc + cache: yarn - name: Yarn install uses: Wandalen/wretry.action@master with: diff --git a/.github/workflows/nightly-update-source-languages.yaml b/.github/workflows/nightly-update-source-languages.yaml index 9460bb071f..42112c24c4 100644 --- a/.github/workflows/nightly-update-source-languages.yaml +++ b/.github/workflows/nightly-update-source-languages.yaml @@ -1,7 +1,7 @@ name: Nightly Update Source Languages on: schedule: - - cron: '0 2 * * *' # run at 2 AM UTC + - cron: "0 2 * * *" # run at 2 AM UTC workflow_dispatch: jobs: @@ -16,13 +16,14 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}} - name: Install node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc + cache: yarn - name: Yarn install uses: Wandalen/wretry.action@master with: @@ -46,4 +47,4 @@ jobs: push_sources: false create_pull_request: false env: - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} \ No newline at end of file + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/pull-request-comment.yml b/.github/workflows/pull-request-comment.yml index beb1e2a03f..9fde88ad30 100644 --- a/.github/workflows/pull-request-comment.yml +++ b/.github/workflows/pull-request-comment.yml @@ -116,7 +116,7 @@ jobs: ref: ${{ steps.pr-info.outputs.head-sha }} - name: 🔧 Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -140,7 +140,7 @@ jobs: run: yarn typecheck - name: 🔨 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@main with: expo-version: latest eas-version: latest diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml index 8c48b54db0..bca92d4701 100644 --- a/.github/workflows/pull-request-commit.yml +++ b/.github/workflows/pull-request-commit.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: 🔧 Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -82,9 +82,9 @@ jobs: 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' + 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 @@ -110,7 +110,7 @@ jobs: if: github.event_name == 'pull_request' - name: 🔧 Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn diff --git a/.github/workflows/verify-yarn-lock.yml b/.github/workflows/verify-yarn-lock.yml index 5525bf6644..afa554c59a 100644 --- a/.github/workflows/verify-yarn-lock.yml +++ b/.github/workflows/verify-yarn-lock.yml @@ -3,7 +3,7 @@ name: Lockfile on: pull_request: concurrency: - group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' + group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}" cancel-in-progress: true jobs: @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out PR HEAD - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -20,7 +20,7 @@ jobs: run: git fetch origin ${{ github.base_ref }} --depth=1 - name: Install node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc diff --git a/Dockerfile b/Dockerfile index 93b17a86b0..371e8402c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24.5-bullseye AS build-env +FROM golang:1.25-bookworm AS build-env WORKDIR /usr/src/social-app @@ -89,7 +89,7 @@ RUN cd bskyweb/ && \ -o /bskyweb \ ./cmd/bskyweb -FROM debian:bullseye-slim +FROM debian:bookworm-slim ENV GODEBUG=netdns=go ENV TZ=Etc/UTC diff --git a/Dockerfile.embedr b/Dockerfile.embedr index 7fa8b9ae53..29b042dddc 100644 --- a/Dockerfile.embedr +++ b/Dockerfile.embedr @@ -1,4 +1,4 @@ -FROM golang:1.24.5-bullseye AS build-env +FROM golang:1.25-bookworm AS build-env WORKDIR /usr/src/social-app @@ -58,7 +58,7 @@ RUN cd bskyweb/ && \ -o /embedr \ ./cmd/embedr -FROM debian:bullseye-slim +FROM debian:bookworm-slim ENV GODEBUG=netdns=go ENV TZ=Etc/UTC diff --git a/__tests__/lib/link-meta.test.ts b/__tests__/lib/link-meta.test.ts index 504b11c22e..bcd3acff47 100644 --- a/__tests__/lib/link-meta.test.ts +++ b/__tests__/lib/link-meta.test.ts @@ -1,4 +1,4 @@ -import {LikelyType, getLikelyType} from '../../src/lib/link-meta/link-meta' +import {getLikelyType, LikelyType} from '../../src/lib/link-meta/link-meta' describe('getLikelyType', () => { it('correctly handles non-parsed url', async () => { diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 6087ba5b14..c59c8b3e34 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -957,20 +957,20 @@ describe('parseStarterPackHttpUri', () => { }) it('returns the at uri when the input is a valid starterpack at uri', () => { - const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey' + const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack/rkey' expect(parseStarterPackUri(validAtUri)).toEqual({ - name: 'did:123', + name: 'did:plc:123', rkey: 'rkey', }) }) it('returns null when the at uri has no rkey', () => { - const validAtUri = 'at://did:123/app.bsky.graph.starterpack' + const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack' expect(parseStarterPackUri(validAtUri)).toEqual(null) }) it('returns null when the collection is not app.bsky.graph.starterpack', () => { - const validAtUri = 'at://did:123/app.bsky.graph.list/rkey' + const validAtUri = 'at://did:plc:123/app.bsky.graph.list/rkey' expect(parseStarterPackUri(validAtUri)).toEqual(null) }) diff --git a/assets/icons/bulletlist_stroke1_corner0_rounded.svg b/assets/icons/bulletlist_stroke1_corner0_rounded.svg new file mode 100644 index 0000000000..a626238600 --- /dev/null +++ b/assets/icons/bulletlist_stroke1_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/circle_and_square_stroke1_corner0_rounded_filled.svg b/assets/icons/circle_and_square_stroke1_corner0_rounded_filled.svg new file mode 100644 index 0000000000..cd1465c9b7 --- /dev/null +++ b/assets/icons/circle_and_square_stroke1_corner0_rounded_filled.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/editbig_stroke1_corner0_rounded.svg b/assets/icons/editbig_stroke1_corner0_rounded.svg new file mode 100644 index 0000000000..3c978b04dd --- /dev/null +++ b/assets/icons/editbig_stroke1_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/hashtagwide_stroke1_corner0_rounded.svg b/assets/icons/hashtagwide_stroke1_corner0_rounded.svg new file mode 100644 index 0000000000..97cc78d8dc --- /dev/null +++ b/assets/icons/hashtagwide_stroke1_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/heart2_stroke1_corner0_rounded.svg b/assets/icons/heart2_stroke1_corner0_rounded.svg new file mode 100644 index 0000000000..788c97168b --- /dev/null +++ b/assets/icons/heart2_stroke1_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/image_stroke1_corner0_rounded.svg b/assets/icons/image_stroke1_corner0_rounded.svg new file mode 100644 index 0000000000..68c3215de4 --- /dev/null +++ b/assets/icons/image_stroke1_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/message_stroke1_corner0_rounded_filled.svg b/assets/icons/message_stroke1_corner0_rounded_filled.svg new file mode 100644 index 0000000000..e45f2b2449 --- /dev/null +++ b/assets/icons/message_stroke1_corner0_rounded_filled.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/peopleremove2_stroke1_corner0_rounded.svg b/assets/icons/peopleremove2_stroke1_corner0_rounded.svg new file mode 100644 index 0000000000..a94ed73053 --- /dev/null +++ b/assets/icons/peopleremove2_stroke1_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/videoclip_stroke1_corner0_rounded.svg b/assets/icons/videoclip_stroke1_corner0_rounded.svg new file mode 100644 index 0000000000..44679a173c --- /dev/null +++ b/assets/icons/videoclip_stroke1_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyweb/cmd/bskyweb/renderer.go b/bskyweb/cmd/bskyweb/renderer.go index 4bf8b80c5c..6e02456f53 100644 --- a/bskyweb/cmd/bskyweb/renderer.go +++ b/bskyweb/cmd/bskyweb/renderer.go @@ -71,7 +71,7 @@ func (r Renderer) Render(w io.Writer, name string, data interface{}, c echo.Cont if r.Debug { t, err = pongo2.FromFile(name) } else { - t, err = r.TemplateSet.FromFile(name) + t, err = r.TemplateSet.FromCache(name) } if err != nil { diff --git a/bskyweb/go.mod b/bskyweb/go.mod index 407ba7d55d..654c40a131 100644 --- a/bskyweb/go.mod +++ b/bskyweb/go.mod @@ -1,6 +1,6 @@ module github.com/bluesky-social/social-app/bskyweb -go 1.24.5 +go 1.25 require ( github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index 0bde22e77c..b5f504904f 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -68,11 +68,19 @@ width: 100%; } #splash { + display: flex; position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + align-items: center; + justify-content: center; + } + #splash svg { + position: relative; + top: -50px; width: 100px; - left: 50%; - top: 50%; - transform: translateX(-50%) translateY(-50%) translateY(-50px); } /** * We need these styles to prevent shifting due to scrollbar show/hide on @@ -106,7 +114,7 @@
- +
diff --git a/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt b/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt index abae882b48..2aaee3b8ec 100644 --- a/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt +++ b/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt @@ -23,6 +23,10 @@ class ExpoReceiveAndroidIntentsModule : Module() { ModuleDefinition { Name("ExpoReceiveAndroidIntents") + OnCreate { + handleIntent(appContext.currentActivity?.intent) + } + OnNewIntent { handleIntent(it) } @@ -74,10 +78,12 @@ class ExpoReceiveAndroidIntentsModule : Module() { intent.getParcelableExtra(Intent.EXTRA_STREAM) } + val text = intent.getStringExtra(Intent.EXTRA_TEXT) + uri?.let { when (type) { - AttachmentType.IMAGE -> handleImageIntents(listOf(it)) - AttachmentType.VIDEO -> handleVideoIntents(listOf(it)) + AttachmentType.IMAGE -> handleImageIntents(listOf(it), text) + AttachmentType.VIDEO -> handleVideoIntents(listOf(it), text) } } } @@ -99,15 +105,20 @@ class ExpoReceiveAndroidIntentsModule : Module() { ?.take(4) } + val text = intent.getStringExtra(Intent.EXTRA_TEXT) + uris?.let { when (type) { - AttachmentType.IMAGE -> handleImageIntents(it) + AttachmentType.IMAGE -> handleImageIntents(it, text) else -> return } } } - private fun handleImageIntents(uris: List) { + private fun handleImageIntents( + uris: List, + text: String? + ) { var allParams = "" uris.forEachIndexed { index, uri -> @@ -120,15 +131,22 @@ class ExpoReceiveAndroidIntentsModule : Module() { } } - val encoded = URLEncoder.encode(allParams, "UTF-8") + val encodedUris = URLEncoder.encode(allParams, "UTF-8") + val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") } - "bluesky://intent/compose?imageUris=$encoded".toUri().let { + var composeIntent = "bluesky://intent/compose?imageUris=$encodedUris" + encodedText?.let { composeIntent += "&text=$it" } + + composeIntent.toUri().let { val newIntent = Intent(Intent.ACTION_VIEW, it) appContext.currentActivity?.startActivity(newIntent) } } - private fun handleVideoIntents(uris: List) { + private fun handleVideoIntents( + uris: List, + text: String? + ) { val uri = uris[0] // If there is no extension for the file, substringAfterLast returns the original string - not // null, so we check for that below @@ -147,7 +165,12 @@ class ExpoReceiveAndroidIntentsModule : Module() { val info = getVideoInfo(uri) ?: return - "bluesky://intent/compose?videoUri=${URLEncoder.encode(file.path, "UTF-8")}|${info["width"]}|${info["height"]}".toUri().let { + val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") } + + var composeIntent = "bluesky://intent/compose?videoUri=${URLEncoder.encode(file.path, "UTF-8")}|${info["width"]}|${info["height"]}" + encodedText?.let { composeIntent += "&text=$it" } + + composeIntent.toUri().let { val newIntent = Intent(Intent.ACTION_VIEW, it) appContext.currentActivity?.startActivity(newIntent) } diff --git a/package.json b/package.json index c11ae7685c..f7be70a77b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.110.0", + "version": "1.111.0", "private": true, "engines": { "node": ">=20" @@ -72,7 +72,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.18.0", + "@atproto/api": "^0.18.4", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.5", @@ -103,7 +103,7 @@ "@react-navigation/native-stack": "^7.3.13", "@sentry/react-native": "~6.20.0", "@tanstack/query-async-storage-persister": "^5.25.0", - "@tanstack/react-query": "^5.8.1", + "@tanstack/react-query": "5.25.0", "@tanstack/react-query-persist-client": "^5.25.0", "@tiptap/core": "^2.9.1", "@tiptap/extension-document": "^2.9.1", @@ -176,7 +176,7 @@ "multiformats": "9.9.0", "nanoid": "^5.0.5", "normalize-url": "^8.0.0", - "patch-package": "^6.5.1", + "patch-package": "^8.0.1", "postinstall-postinstall": "^2.1.0", "psl": "^1.9.0", "radix-ui": "^1.4.3", diff --git a/patches/expo-updates+29.0.12.patch b/patches/expo-updates+29.0.12.patch index 6fc4fc5fcf..7f198e96ef 100644 --- a/patches/expo-updates+29.0.12.patch +++ b/patches/expo-updates+29.0.12.patch @@ -1,5 +1,26 @@ +diff --git a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt +index e9a8e3d..3c684e0 100644 +--- a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt ++++ b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt +@@ -296,14 +296,13 @@ class LoaderTask( + ) { + try { + val embeddedLoader = EmbeddedLoader(context, configuration, logger, database, directory) +- val result = embeddedLoader.load { updateResponse -> ++ embeddedLoader.load { _ -> + Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true) + } +- launcher.launch(database) + } catch (e: Exception) { + logger.error("Unexpected error copying embedded update", e, UpdatesErrorCode.Unknown) +- launcher.launch(database) + } ++ launcher.launch(database) + } else { + launcher.launch(database) + } diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift -index b85291e..546709d 100644 +index 68086bd..78c7761 100644 --- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift +++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift @@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update { diff --git a/patches/react-native+0.81.5.patch b/patches/react-native+0.81.5+001+initial.patch similarity index 52% rename from patches/react-native+0.81.5.patch rename to patches/react-native+0.81.5+001+initial.patch index 5a372d81bc..cc73cec3a8 100644 --- a/patches/react-native+0.81.5.patch +++ b/patches/react-native+0.81.5+001+initial.patch @@ -1,47 +1,16 @@ -diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h -index 914a249..0deac55 100644 ---- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h -+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h -@@ -19,6 +19,8 @@ NS_ASSUME_NONNULL_BEGIN - */ - @interface RCTPullToRefreshViewComponentView : RCTViewComponentView - -+- (void)beginRefreshingProgrammatically; -+ - @end - - NS_ASSUME_NONNULL_END -diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm -index d029337..0f63ea3 100644 ---- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm -+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm -@@ -1038,6 +1038,11 @@ - (void)_adjustForMaintainVisibleContentPosition - } - } - -++ (BOOL)shouldBeRecycled -+{ -+ return NO; -+} -+ - @end - - Class RCTScrollViewCls(void) diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h -index e9b330f..ec5f58c 100644 +index e9b330f..5fbb2e0 100644 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h -@@ -15,5 +15,8 @@ +@@ -15,5 +15,6 @@ @property (nonatomic, copy) NSString *title; @property (nonatomic, copy) RCTDirectEventBlock onRefresh; @property (nonatomic, weak) UIScrollView *scrollView; +@property (nonatomic, copy) UIColor *customTintColor; -+ -+- (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 53bfd04..ff1b1ed 100644 +index 53bfd04..e2e0c9f 100644 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m @@ -23,6 +23,7 @@ @implementation RCTRefreshControl { @@ -65,7 +34,7 @@ index 53bfd04..ff1b1ed 100644 - (void)didMoveToWindow { [super didMoveToWindow]; -@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged +@@ -221,4 +228,16 @@ - (void)refreshControlValueChanged } } @@ -80,40 +49,6 @@ index 53bfd04..ff1b1ed 100644 + [super setTintColor:tintColor]; + } +} -+ -+// 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 diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m diff --git a/patches/react-native+0.81.5+002+ScrollForwarder.patch b/patches/react-native+0.81.5+002+ScrollForwarder.patch new file mode 100644 index 0000000000..9e28d20459 --- /dev/null +++ b/patches/react-native+0.81.5+002+ScrollForwarder.patch @@ -0,0 +1,67 @@ +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h +index 914a249..0deac55 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h +@@ -19,6 +19,8 @@ NS_ASSUME_NONNULL_BEGIN + */ + @interface RCTPullToRefreshViewComponentView : RCTViewComponentView + ++- (void)beginRefreshingProgrammatically; ++ + @end + + NS_ASSUME_NONNULL_END +diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h +index 5fbb2e0..ec5f58c 100644 +--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h ++++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h +@@ -17,4 +17,6 @@ + @property (nonatomic, weak) UIScrollView *scrollView; + @property (nonatomic, copy) UIColor *customTintColor; + ++- (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 e2e0c9f..ff1b1ed 100644 +--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m ++++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m +@@ -240,4 +240,38 @@ - (void)setTintColor:(UIColor *)tintColor + } + } + ++// 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 diff --git a/patches/react-native+0.81.5+003+ScrollView-disable-recycling.patch b/patches/react-native+0.81.5+003+ScrollView-disable-recycling.patch new file mode 100644 index 0000000000..2488a8edf2 --- /dev/null +++ b/patches/react-native+0.81.5+003+ScrollView-disable-recycling.patch @@ -0,0 +1,16 @@ +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +index d029337..0f63ea3 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +@@ -1038,6 +1038,11 @@ - (void)_adjustForMaintainVisibleContentPosition + } + } + +++ (BOOL)shouldBeRecycled ++{ ++ return NO; ++} ++ + @end + + Class RCTScrollViewCls(void) diff --git a/src/App.native.tsx b/src/App.native.tsx index 30a5e81296..fb30086273 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -25,16 +25,10 @@ import I18nProvider from '#/locale/i18nProvider' import {logger} from '#/logger' import {isAndroid, isIOS} from '#/platform/detection' import {Provider as A11yProvider} from '#/state/a11y' -import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' import {Provider as EmailVerificationProvider} from '#/state/email-verification' import {listenSessionDropped} from '#/state/events' -import { - beginResolveGeolocationConfig, - ensureGeolocationConfigIsResolved, - Provider as GeolocationProvider, -} from '#/state/geolocation' import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' import {Provider as HomeBadgeProvider} from '#/state/home-badge' import {Provider as LightboxStateProvider} from '#/state/lightbox' @@ -56,6 +50,7 @@ import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ComposerProvider} from '#/state/shell/composer' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' +import {Provider as OnboardingProvider} from '#/state/shell/onboarding' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' @@ -73,6 +68,9 @@ import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdate import {Provider as PortalProvider} from '#/components/Portal' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {ToastOutlet} from '#/components/Toast' +import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' +import {prefetchAgeAssuranceConfig} from '#/ageAssurance' +import * as Geo from '#/geolocation' import {Splash} from '#/Splash' import {BottomSheetProvider} from '../modules/bottom-sheet' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' @@ -93,7 +91,8 @@ if (isAndroid) { /** * Begin geolocation ASAP */ -beginResolveGeolocationConfig() +Geo.resolve() +prefetchAgeAssuranceConfig() function InnerApp() { const [isReady, setIsReady] = React.useState(false) @@ -143,7 +142,7 @@ function InnerApp() { - + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} @@ -186,7 +185,7 @@ function InnerApp() { - + @@ -203,10 +202,9 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { - Promise.all([ - initPersistedState(), - ensureGeolocationConfigIsResolved(), - ]).then(() => setReady(true)) + Promise.all([initPersistedState(), Geo.resolve()]).then(() => + setReady(true), + ) }, []) if (!isReady) { @@ -218,36 +216,38 @@ function App() { * that is set up in the InnerApp component above. */ return ( - + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index b7cba6122e..f4b514dfc1 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -14,16 +14,10 @@ import {ThemeProvider} from '#/lib/ThemeContext' import I18nProvider from '#/locale/i18nProvider' import {logger} from '#/logger' import {Provider as A11yProvider} from '#/state/a11y' -import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' import {Provider as EmailVerificationProvider} from '#/state/email-verification' import {listenSessionDropped} from '#/state/events' -import { - beginResolveGeolocationConfig, - ensureGeolocationConfigIsResolved, - Provider as GeolocationProvider, -} from '#/state/geolocation' import {Provider as HomeBadgeProvider} from '#/state/home-badge' import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' @@ -44,6 +38,7 @@ import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ComposerProvider} from '#/state/shell/composer' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' +import {Provider as OnboardingProvider} from '#/state/shell/onboarding' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' @@ -61,13 +56,18 @@ import {Provider as PortalProvider} from '#/components/Portal' import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {ToastOutlet} from '#/components/Toast' +import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' +import {prefetchAgeAssuranceConfig} from '#/ageAssurance' +import * as Geo from '#/geolocation' +import {Splash} from '#/Splash' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' /** * Begin geolocation ASAP */ -beginResolveGeolocationConfig() +Geo.resolve() +prefetchAgeAssuranceConfig() function InnerApp() { const [isReady, setIsReady] = React.useState(false) @@ -104,7 +104,7 @@ function InnerApp() { }, [_]) // wait for session to resume - if (!isReady || !hasCheckedReferrer) return null + if (!isReady || !hasCheckedReferrer) return return ( @@ -118,7 +118,7 @@ function InnerApp() { - + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} @@ -157,7 +157,7 @@ function InnerApp() { - + @@ -174,14 +174,13 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { - Promise.all([ - initPersistedState(), - ensureGeolocationConfigIsResolved(), - ]).then(() => setReady(true)) + Promise.all([initPersistedState(), Geo.resolve()]).then(() => + setReady(true), + ) }, []) if (!isReady) { - return null + return } /* @@ -189,29 +188,31 @@ function App() { * that is set up in the InnerApp component above. */ return ( - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - + ) } diff --git a/src/Splash.web.tsx b/src/Splash.web.tsx new file mode 100644 index 0000000000..eb4a405c85 --- /dev/null +++ b/src/Splash.web.tsx @@ -0,0 +1,29 @@ +/* + * This is a reimplementation of what exists in our HTML template files + * already. Once the React tree mounts, this is what gets rendered first, until + * the app is ready to go. + */ + +import {View} from 'react-native' +import Svg, {Path} from 'react-native-svg' + +import {atoms as a} from '#/alf' + +const size = 100 +const ratio = 57 / 64 + +export function Splash() { + return ( + + + + + + ) +} diff --git a/src/ageAssurance/__mocks__/data.tsx b/src/ageAssurance/__mocks__/data.tsx new file mode 100644 index 0000000000..b548a2f866 --- /dev/null +++ b/src/ageAssurance/__mocks__/data.tsx @@ -0,0 +1,3 @@ +export const prefetchAgeAssuranceData = () => {} +export const setBirthdateForDid = () => {} +export const setCreatedAtForDid = () => {} diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx new file mode 100644 index 0000000000..3b4f7d7088 --- /dev/null +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -0,0 +1,355 @@ +import {useCallback, useEffect} from 'react' +import {ScrollView, View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import { + SupportCode, + useCreateSupportLink, +} from '#/lib/hooks/useCreateSupportLink' +import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {isNative} from '#/platform/detection' +import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' +import {useSessionApi} from '#/state/session' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' +import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' +import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import * as Dialog from '#/components/Dialog' +import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' +import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' +import {Full as Logo} from '#/components/icons/Logo' +import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' +import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' +import {Outlet as PortalOutlet} from '#/components/Portal' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {BottomSheetOutlet} from '#/../modules/bottom-sheet' +import {useAgeAssurance} from '#/ageAssurance' +import {useAgeAssuranceDataContext} from '#/ageAssurance/data' +import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' +import { + isLegacyBirthdateBug, + useAgeAssuranceRegionConfig, +} from '#/ageAssurance/util' +import {useDeviceGeolocationApi} from '#/geolocation' + +const textStyles = [a.text_md, a.leading_snug] + +export function NoAccessScreen() { + const t = useTheme() + const {_} = useLingui() + const {gtPhone} = useBreakpoints() + const insets = useSafeAreaInsets() + const birthdateControl = useDialogControl() + const {data} = useAgeAssuranceDataContext() + const region = useAgeAssuranceRegionConfig() + const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed() + const {logoutCurrentAccount} = useSessionApi() + const createSupportLink = useCreateSupportLink() + + const aa = useAgeAssurance() + const isBlocked = aa.state.status === aa.Status.Blocked + const isAARegion = !!region + const hasDeclaredAge = data?.declaredAge !== undefined + const canUpdateBirthday = + isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '') + + useEffect(() => { + // just counting overall hits here + logger.metric(`blockedGeoOverlay:shown`, {}) + }, []) + + const onPressLogout = useCallback(() => { + if (isWeb) { + // We're switching accounts, which remounts the entire app. + // On mobile, this gets us Home, but on the web we also need reset the URL. + // We can't change the URL via a navigate() call because the navigator + // itself is about to unmount, and it calls pushState() too late. + // So we change the URL ourselves. The navigator will pick it up on remount. + history.pushState(null, '', '/') + } + logoutCurrentAccount('AgeAssuranceNoAccessScreen') + }, [logoutCurrentAccount]) + + const birthdateUpdateText = canUpdateBirthday ? ( + + + If you believe your birthdate is incorrect, you can update it by{' '} + { + birthdateControl.open() + })}> + clicking here + + . + + + ) : ( + + + If you believe your birthdate is incorrect, please{' '} + + contact our support team + + . + + + ) + + return ( + <> + + + + + + + {hasDeclaredAge ? ( + <> + {isAARegion ? ( + <> + + + + You are accessing Bluesky from a region that legally + requires us to verify your age before allowing you to + access the app. + + + + {!isBlocked && birthdateUpdateText} + + + + + ) : ( + + + + Unfortunately, the birthdate you have saved to your + profile makes you too young to access Bluesky. + + + + {birthdateUpdateText} + + )} + + ) : ( + + + + It looks like you haven't added your birthdate. You must + provide an accurate date of birth to use Bluesky. + + + + + )} + + + + + + To log out,{' '} + { + onPressLogout() + })}> + click here + + . + + + + + + + + + {/* + * While this blocking overlay is up, other dialogs in the shell + * are not mounted, so it _should_ be safe to use these here + * without fear of other modals showing up. + */} + + + + ) +} + +function AccessSection() { + const t = useTheme() + const {_, i18n} = useLingui() + const control = useDialogControl() + const appealControl = Dialog.useDialogControl() + const locationControl = Dialog.useDialogControl() + const getTimeAgo = useGetTimeAgo() + const {setDeviceGeolocation} = useDeviceGeolocationApi() + const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess() + + const aa = useAgeAssurance() + const {status, lastInitiatedAt} = aa.state + const isBlocked = status === aa.Status.Blocked + const hasInitiated = !!lastInitiatedAt + const timeAgo = lastInitiatedAt + ? getTimeAgo(lastInitiatedAt, new Date()) + : null + const diff = lastInitiatedAt + ? dateDiff(lastInitiatedAt, new Date(), 'down') + : null + + return ( + <> + + + + + {isBlocked ? ( + + + You are currently unable to access Bluesky's Age Assurance flow. + Please{' '} + { + appealControl.open() + logger.metric('ageAssurance:appealDialogOpen', {}) + })}> + contact our moderation team + {' '} + if you believe this is an error. + + + ) : ( + <> + + + + {lastInitiatedAt && timeAgo && diff ? ( + + {diff.value === 0 ? ( + Last initiated just now + ) : ( + Last initiated {timeAgo} ago + )} + + ) : ( + + Age assurance only takes a few minutes + + )} + + + )} + + + {isNative && ( + <> + + + Is your location not accurate?{' '} + { + locationControl.open() + })}> + Tap here to confirm your location. + {' '} + + + + { + const access = computeAgeAssuranceRegionAccess( + props.geolocation, + ) + if (access !== aa.Access.Full) { + props.disableDialogAction() + props.setDialogError( + _( + msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`, + ), + ) + } else { + props.closeDialog(() => { + // set this after close! + setDeviceGeolocation(props.geolocation) + Toast.show(_(msg`Thanks! You're all set.`), { + type: 'success', + }) + }) + } + }} + /> + + )} + + + + ) +} diff --git a/src/ageAssurance/components/RedirectOverlay.tsx b/src/ageAssurance/components/RedirectOverlay.tsx new file mode 100644 index 0000000000..dc4475187f --- /dev/null +++ b/src/ageAssurance/components/RedirectOverlay.tsx @@ -0,0 +1,334 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import {Dimensions, View} from 'react-native' +import * as Linking from 'expo-linking' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {retry} from '#/lib/async/retry' +import {wait} from '#/lib/async/wait' +import {parseLinkingUrl} from '#/lib/parseLinkingUrl' +import {isWeb} from '#/platform/detection' +import {isIOS} from '#/platform/detection' +import {useAgent, useSession} from '#/state/session' +import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' +import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' +import {Button, ButtonText} from '#/components/Button' +import {FullWindowOverlay} from '#/components/FullWindowOverlay' +import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check' +import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {refetchAgeAssuranceServerState} from '#/ageAssurance' +import {logger} from '#/ageAssurance' + +export type RedirectOverlayState = { + result: 'success' | 'unknown' + actorDid: string +} + +/** + * Validate and parse the query parameters returned from the age assurance + * redirect. If not valid, returns `undefined` and the dialog will not open. + */ +export function parseRedirectOverlayState( + state: { + result?: string + actorDid?: string + } = {}, +): RedirectOverlayState | undefined { + let result: RedirectOverlayState['result'] = 'unknown' + const actorDid = state.actorDid + + switch (state.result) { + case 'success': + result = 'success' + break + case 'unknown': + default: + result = 'unknown' + break + } + + if (actorDid) { + return { + result, + actorDid, + } + } +} + +const Context = createContext<{ + isOpen: boolean + open: (state: RedirectOverlayState) => void + close: () => void +}>({ + isOpen: false, + open: () => {}, + close: () => {}, +}) + +export function useRedirectOverlayContext() { + return useContext(Context) +} + +export function Provider({children}: {children?: React.ReactNode}) { + const {currentAccount} = useSession() + const incomingUrl = Linking.useLinkingURL() + const [state, setState] = useState(() => { + if (!incomingUrl) return null + const url = parseLinkingUrl(incomingUrl) + if (url.pathname !== '/intent/age-assurance') return null + const params = url.searchParams + const state = parseRedirectOverlayState({ + result: params.get('result') ?? undefined, + actorDid: params.get('actorDid') ?? undefined, + }) + + if (isWeb) { + // Clear the URL parameters so they don't re-trigger + history.pushState(null, '', '/') + } + + /* + * If we don't have an account or the account doesn't match, do + * nothing. By the time the user switches to their other account, AA + * state should be ready for them. + */ + if (state && currentAccount && state.actorDid === currentAccount.did) { + return state + } + + return null + }) + const open = useCallback((state: RedirectOverlayState) => { + setState(state) + }, []) + const close = useCallback(() => { + setState(null) + }, []) + + return ( + ({ + isOpen: state !== null, + open, + close, + }), + [state, open, close], + )}> + {children} + + ) +} + +export function RedirectOverlay() { + const t = useTheme() + const {_} = useLingui() + const {isOpen} = useRedirectOverlayContext() + const {gtMobile} = useBreakpoints() + + return isOpen ? ( + + + + + + + + + + ) : null +} + +function Inner() { + const t = useTheme() + const {_} = useLingui() + const agent = useAgent() + const polling = useRef(false) + const unmounted = useRef(false) + const [error, setError] = useState(false) + const [success, setSuccess] = useState(false) + const {close} = useRedirectOverlayContext() + + useEffect(() => { + if (polling.current) return + + polling.current = true + + logger.metric('ageAssurance:redirectDialogOpen', {}) + + wait( + 3e3, + retry( + 5, + () => true, + async () => { + if (!agent.session) return + if (unmounted.current) return + + const data = await refetchAgeAssuranceServerState({agent}) + + if (data?.state.status !== 'assured') { + throw new Error( + `Polling for age assurance state did not receive assured status`, + ) + } + + return data + }, + 1e3, + ), + ) + .then(async data => { + if (!data) return + if (!agent.session) return + if (unmounted.current) return + + setSuccess(true) + + logger.metric('ageAssurance:redirectDialogSuccess', {}) + }) + .catch(() => { + if (unmounted.current) return + setError(true) + logger.metric('ageAssurance:redirectDialogFail', {}) + }) + + return () => { + unmounted.current = true + } + }, [agent]) + + if (success) { + return ( + <> + + + + + + + Success + + + + + + We've confirmed your age assurance status. You can now close this + dialog. + + + + + + + + + ) + } + + return ( + <> + + + + + {error && } + + + {error ? Connection issue : Verifying} + + + {!error && } + + + + {error ? ( + + We were unable to receive the verification due to a connection + issue. It may arrive later. If it does, your account will update + automatically. + + ) : ( + + We're confirming your age assurance status with our servers. This + should only take a few seconds. + + )} + + + {error && ( + + + + )} + + + ) +} diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx new file mode 100644 index 0000000000..24619890c8 --- /dev/null +++ b/src/ageAssurance/data.tsx @@ -0,0 +1,495 @@ +import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' +import { + type AppBskyAgeassuranceDefs, + type AppBskyAgeassuranceGetConfig, + type AppBskyAgeassuranceGetState, + AtpAgent, + getAgeAssuranceRegionConfig, +} from '@atproto/api' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' +import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' +import {persistQueryClient} from '@tanstack/react-query-persist-client' +import debounce from 'lodash.debounce' + +import {networkRetry} from '#/lib/async/retry' +import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {getAge} from '#/lib/strings/time' +import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' +import {useAgent, useSession} from '#/state/session' +import * as debug from '#/ageAssurance/debug' +import {logger} from '#/ageAssurance/logger' +import {isLegacyBirthdateBug} from '#/ageAssurance/util' +import {IS_DEV} from '#/env' +import {device} from '#/storage' + +/** + * Special query client for age assurance data so we can prefetch on app + * load without interfering with other queries. + */ +const qc = new QueryClient({ + defaultOptions: { + queries: { + /** + * We clear this manually, so disable automatic garbage collection. + * @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works + */ + gcTime: Infinity, + }, + }, +}) +const persister = createAsyncStoragePersister({ + storage: AsyncStorage, + key: 'age-assurance-query-client', +}) +const [, cacheHydrationPromise] = persistQueryClient({ + queryClient: qc, + persister, +}) + +function getDidFromAgentSession(agent: AtpAgent) { + const sessionManager = agent.sessionManager + if (!sessionManager || !sessionManager.did) return + return sessionManager.did +} + +/* + * Optimistic data + */ + +const createdAtCache = new Map() +export function setCreatedAtForDid({ + did, + createdAt, +}: { + did: string + createdAt: string +}) { + createdAtCache.set(did, createdAt) +} +const birthdateCache = new Map() +export function setBirthdateForDid({ + did, + birthdate, +}: { + did: string + birthdate: string +}) { + birthdateCache.set(did, birthdate) +} + +/* + * Config + */ + +export const configQueryKey = ['config'] +export async function getConfig() { + if (debug.enabled) return debug.resolve(debug.config) + const agent = new AtpAgent({ + service: PUBLIC_BSKY_SERVICE, + }) + const res = await agent.app.bsky.ageassurance.getConfig() + return res.data +} +export function getConfigFromCache(): + | AppBskyAgeassuranceGetConfig.OutputSchema + | undefined { + return qc.getQueryData( + configQueryKey, + ) +} +let configPrefetchPromise: Promise | undefined +export async function prefetchConfig() { + if (configPrefetchPromise) { + logger.debug(`prefetchAgeAssuranceConfig: already in progress`) + return + } + + configPrefetchPromise = new Promise(async resolve => { + await cacheHydrationPromise + const cached = getConfigFromCache() + + if (cached) { + logger.debug(`prefetchAgeAssuranceConfig: using cache`) + resolve() + } else { + try { + logger.debug(`prefetchAgeAssuranceConfig: resolving...`) + const res = await networkRetry(3, () => getConfig()) + qc.setQueryData( + configQueryKey, + res, + ) + } catch (e: any) { + logger.warn(`prefetchAgeAssuranceConfig: failed`, { + safeMessage: e.message, + }) + } finally { + resolve() + } + } + }) +} +export function useConfigQuery() { + return useQuery( + { + /** + * Will re-fetch when stale, at most every hour (or 5s in dev for easier + * testing). + * + * @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat + */ + staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60, + initialData: getConfigFromCache(), + initialDataUpdatedAt: () => + qc.getQueryState(configQueryKey)?.dataUpdatedAt, + queryKey: configQueryKey, + async queryFn() { + logger.debug(`useConfigQuery: fetching config`) + return getConfig() + }, + }, + qc, + ) +} + +/* + * Server state + */ + +export function createServerStateQueryKey({did}: {did: string}) { + return ['serverState', did] +} +export async function getServerState({agent}: {agent: AtpAgent}) { + if (debug.enabled && debug.serverState) + return debug.resolve(debug.serverState) + const geolocation = device.get(['mergedGeolocation']) + if (!geolocation || !geolocation.countryCode) { + logger.error(`getServerState: missing geolocation countryCode`) + return + } + const {data} = await agent.app.bsky.ageassurance.getState({ + countryCode: geolocation.countryCode, + regionCode: geolocation.regionCode, + }) + const did = getDidFromAgentSession(agent) + if (data && did && createdAtCache.has(did)) { + /* + * If account was just created, just use the local cache if available. On + * subsequent reloads, the server should have the correct value. + */ + data.metadata.accountCreatedAt = createdAtCache.get(did) + } + return data ?? null +} +export function getServerStateFromCache({ + did, +}: { + did: string +}): AppBskyAgeassuranceGetState.OutputSchema | undefined { + return qc.getQueryData( + createServerStateQueryKey({did}), + ) +} +export async function prefetchServerState({agent}: {agent: AtpAgent}) { + const did = getDidFromAgentSession(agent) + + if (!did) return + + await cacheHydrationPromise + const qk = createServerStateQueryKey({did}) + const cached = getServerStateFromCache({did}) + + if (cached) { + logger.debug(`prefetchServerState: using cache`) + return + } + + try { + logger.debug(`prefetchServerState: resolving...`) + const res = await networkRetry(3, () => getServerState({agent})) + qc.setQueryData(qk, res) + } catch (e: any) { + logger.warn(`prefetchServerState: failed`, { + safeMessage: e.message, + }) + } +} +export async function refetchServerState({agent}: {agent: AtpAgent}) { + const did = getDidFromAgentSession(agent) + if (!did) return + logger.debug(`refetchServerState: fetching...`) + const res = await networkRetry(3, () => getServerState({agent})) + qc.setQueryData( + createServerStateQueryKey({did}), + res, + ) + return res +} +export function usePatchServerState() { + const {currentAccount} = useSession() + return useCallback( + async (next: AppBskyAgeassuranceDefs.State) => { + if (!currentAccount) return + const did = currentAccount.did + const prev = getServerStateFromCache({did}) + const merged: AppBskyAgeassuranceGetState.OutputSchema = { + metadata: {}, + ...(prev || {}), + state: next, + } + qc.setQueryData( + createServerStateQueryKey({did}), + merged, + ) + }, + [currentAccount], + ) +} +export function useServerStateQuery() { + const agent = useAgent() + const did = getDidFromAgentSession(agent) + const query = useQuery( + { + enabled: !!did, + initialData: () => { + if (!did) return + return getServerStateFromCache({did}) + }, + queryKey: createServerStateQueryKey({did: did!}), + async queryFn() { + return getServerState({agent}) + }, + }, + qc, + ) + const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch]) + + const isAssured = query.data?.state?.status === 'assured' + + /** + * `refetchOnWindowFocus` doesn't seem to want to work for this custom query + * client, so we manually subscribe to focus changes. + */ + useEffect(() => { + return focusManager.subscribe(() => { + // logged out + if (!did) return + + const isFocused = focusManager.isFocused() + + if (!isFocused) return + + const config = getConfigFromCache() + const geolocation = device.get(['mergedGeolocation']) + const isAArequired = Boolean( + config && + geolocation && + !!getAgeAssuranceRegionConfig(config, { + countryCode: geolocation?.countryCode ?? '', + regionCode: geolocation?.regionCode, + }), + ) + + // only refetch when needed + if (isAssured || !isAArequired) return + + refetch() + }) + }, [did, refetch, isAssured]) + + return query +} + +/* + * Other required data + */ + +export type OtherRequiredData = { + birthdate: string | undefined +} +export function createOtherRequiredDataQueryKey({did}: {did: string}) { + return ['otherRequiredData', did] +} +export async function getOtherRequiredData({ + agent, +}: { + agent: AtpAgent +}): Promise { + if (debug.enabled) return debug.resolve(debug.otherRequiredData) + const [prefs] = await Promise.all([agent.getPreferences()]) + const data: OtherRequiredData = { + birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined, + } + const did = getDidFromAgentSession(agent) + if (data && did && birthdateCache.has(did)) { + /* + * If birthdate was just set, use the local cache value. On subsequent + * reloads, the server should have the correct value. + */ + data.birthdate = birthdateCache.get(did) + } + + /** + * If the user is under the minimum age, and the birthdate is not due to + * the legacy bug, snooze further birthdate updates for this user. + */ + if (data.birthdate && !isLegacyBirthdateBug(data.birthdate)) { + snoozeBirthdateUpdateAllowedForDid(did!) + } + + return data +} +export function getOtherRequiredDataFromCache({ + did, +}: { + did: string +}): OtherRequiredData | undefined { + return qc.getQueryData( + createOtherRequiredDataQueryKey({did}), + ) +} +export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) { + const did = getDidFromAgentSession(agent) + + if (!did) return + + await cacheHydrationPromise + const qk = createOtherRequiredDataQueryKey({did}) + const cached = getOtherRequiredDataFromCache({did}) + + if (cached) { + logger.debug(`prefetchOtherRequiredData: using cache`) + return + } + + try { + logger.debug(`prefetchOtherRequiredData: resolving...`) + const res = await networkRetry(3, () => getOtherRequiredData({agent})) + qc.setQueryData(qk, res) + } catch (e: any) { + logger.warn(`prefetchOtherRequiredData: failed`, { + safeMessage: e.message, + }) + } +} +export function usePatchOtherRequiredData() { + const {currentAccount} = useSession() + return useCallback( + async (next: OtherRequiredData) => { + if (!currentAccount) return + const did = currentAccount.did + const prev = getOtherRequiredDataFromCache({did}) + const merged: OtherRequiredData = { + ...(prev || {}), + ...next, + } + qc.setQueryData( + createOtherRequiredDataQueryKey({did}), + merged, + ) + }, + [currentAccount], + ) +} +export function useOtherRequiredDataQuery() { + const agent = useAgent() + const did = getDidFromAgentSession(agent) + return useQuery( + { + enabled: !!did, + initialData: () => { + if (!did) return + return getOtherRequiredDataFromCache({did}) + }, + queryKey: createOtherRequiredDataQueryKey({did: did!}), + async queryFn() { + return getOtherRequiredData({agent}) + }, + }, + qc, + ) +} + +/** + * Helper to prefetch all age assurance data. + */ +export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) { + return Promise.allSettled([ + // config fetch initiated at the top of the App.platform.tsx files, awaited here + configPrefetchPromise, + prefetchServerState({agent}), + prefetchOtherRequiredData({agent}), + ]) +} + +export function clearAgeAssuranceDataForDid({did}: {did: string}) { + logger.debug(`clearAgeAssuranceDataForDid: ${did}`) + qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true}) + qc.removeQueries({ + queryKey: createOtherRequiredDataQueryKey({did}), + exact: true, + }) +} + +export function clearAgeAssuranceData() { + logger.debug(`clearAgeAssuranceData`) + qc.clear() +} + +/* + * Context + */ + +export type AgeAssuranceData = { + config: AppBskyAgeassuranceDefs.Config | undefined + state: AppBskyAgeassuranceDefs.State | undefined + data: + | { + accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt'] + declaredAge: number | undefined + birthdate: string | undefined + } + | undefined +} +export const AgeAssuranceDataContext = createContext({ + config: undefined, + state: undefined, + data: { + accountCreatedAt: undefined, + declaredAge: undefined, + birthdate: undefined, + }, +}) +export function useAgeAssuranceDataContext() { + return useContext(AgeAssuranceDataContext) +} +export function AgeAssuranceDataProvider({ + children, +}: { + children: React.ReactNode +}) { + const {data: config} = useConfigQuery() + const serverState = useServerStateQuery() + const {state, metadata} = serverState.data || {} + const {data} = useOtherRequiredDataQuery() + const ctx = useMemo( + () => ({ + config, + state, + data: { + accountCreatedAt: metadata?.accountCreatedAt, + declaredAge: data?.birthdate + ? getAge(new Date(data.birthdate)) + : undefined, + birthdate: data?.birthdate, + }, + }), + [config, state, data, metadata], + ) + return ( + + {children} + + ) +} diff --git a/src/ageAssurance/debug.ts b/src/ageAssurance/debug.ts new file mode 100644 index 0000000000..d31024755c --- /dev/null +++ b/src/ageAssurance/debug.ts @@ -0,0 +1,84 @@ +import { + ageAssuranceRuleIDs as ids, + type AppBskyAgeassuranceDefs, + type AppBskyAgeassuranceGetState, +} from '@atproto/api' + +import {type OtherRequiredData} from '#/ageAssurance/data' +import {IS_DEV} from '#/env' +import {type Geolocation} from '#/geolocation' + +export const enabled = IS_DEV && false + +export const geolocation: Geolocation | undefined = enabled + ? { + countryCode: 'AA', + regionCode: undefined, + } + : undefined + +export const deviceGeolocation: Geolocation | undefined = enabled + ? { + countryCode: 'AA', + regionCode: undefined, + } + : undefined + +export const config: AppBskyAgeassuranceDefs.Config = { + regions: [ + { + countryCode: 'AA', + regionCode: undefined, + rules: [ + { + $type: ids.IfAccountNewerThan, + date: '2025-12-01T00:00:00Z', + access: 'none', + }, + { + $type: ids.IfAssuredOverAge, + age: 18, + access: 'full', + }, + { + $type: ids.IfAssuredOverAge, + age: 16, + access: 'safe', + }, + { + $type: ids.IfDeclaredUnderAge, + age: 16, + access: 'none', + }, + { + $type: ids.Default, + access: 'safe', + }, + ], + }, + ], +} + +export const otherRequiredData: OtherRequiredData = { + birthdate: new Date(2000, 1, 1).toISOString(), +} + +const serverStateEnabled = false +export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined = + serverStateEnabled + ? { + state: { + lastInitiatedAt: new Date(2023, 5, 1).toISOString(), + status: 'assured', + access: 'safe', + }, + metadata: { + accountCreatedAt: new Date(2023, 11, 1).toISOString(), + }, + } + : undefined + +export async function resolve(data: T) { + await new Promise(y => setTimeout(y, 2000)) // simulate network + return data +} diff --git a/src/ageAssurance/index.tsx b/src/ageAssurance/index.tsx new file mode 100644 index 0000000000..9a0a9c9d51 --- /dev/null +++ b/src/ageAssurance/index.tsx @@ -0,0 +1,110 @@ +import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' + +import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' +import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay' +import {AgeAssuranceDataProvider} from '#/ageAssurance/data' +import {useAgeAssuranceDataContext} from '#/ageAssurance/data' +import {logger} from '#/ageAssurance/logger' +import { + useAgeAssuranceState, + useOnAgeAssuranceAccessUpdate, +} from '#/ageAssurance/state' +import { + AgeAssuranceAccess, + type AgeAssuranceState, + AgeAssuranceStatus, +} from '#/ageAssurance/types' +import {isUserUnderAdultAge} from '#/ageAssurance/util' + +export { + prefetchConfig as prefetchAgeAssuranceConfig, + prefetchAgeAssuranceData, + refetchServerState as refetchAgeAssuranceServerState, + usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData, + usePatchServerState as usePatchAgeAssuranceServerState, +} from '#/ageAssurance/data' +export {logger} from '#/ageAssurance/logger' + +const AgeAssuranceStateContext = createContext<{ + Access: typeof AgeAssuranceAccess + Status: typeof AgeAssuranceStatus + state: AgeAssuranceState + flags: { + adultContentDisabled: boolean + chatDisabled: boolean + } +}>({ + Access: AgeAssuranceAccess, + Status: AgeAssuranceStatus, + state: { + lastInitiatedAt: undefined, + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Full, + }, + flags: { + adultContentDisabled: false, + chatDisabled: false, + }, +}) + +/** + * THE MAIN AGE ASSURANCE CONTEXT HOOK + * + * Prefer this to using any of the lower-level data-provider hooks. + */ +export function useAgeAssurance() { + return useContext(AgeAssuranceStateContext) +} + +export function Provider({children}: {children: React.ReactNode}) { + return ( + + + {children} + + + ) +} + +function InnerProvider({children}: {children: React.ReactNode}) { + const state = useAgeAssuranceState() + const {data} = useAgeAssuranceDataContext() + const getAndRegisterPushToken = useGetAndRegisterPushToken() + + const handleAccessUpdate = useCallback( + (s: AgeAssuranceState) => { + getAndRegisterPushToken({ + isAgeRestricted: s.access !== AgeAssuranceAccess.Full, + }) + }, + [getAndRegisterPushToken], + ) + useOnAgeAssuranceAccessUpdate(handleAccessUpdate) + + useEffect(() => { + logger.debug(`useAgeAssuranceState`, {state}) + }, [state]) + + return ( + { + const chatDisabled = state.access !== AgeAssuranceAccess.Full + const isUnderage = data?.birthdate + ? isUserUnderAdultAge(data.birthdate) + : true + const adultContentDisabled = + state.access !== AgeAssuranceAccess.Full || isUnderage + return { + Access: AgeAssuranceAccess, + Status: AgeAssuranceStatus, + state, + flags: { + adultContentDisabled, + chatDisabled, + }, + } + }, [state, data])}> + {children} + + ) +} diff --git a/src/state/ageAssurance/util.ts b/src/ageAssurance/logger.ts similarity index 100% rename from src/state/ageAssurance/util.ts rename to src/ageAssurance/logger.ts diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts new file mode 100644 index 0000000000..cc8b60ac52 --- /dev/null +++ b/src/ageAssurance/state.ts @@ -0,0 +1,100 @@ +import {useEffect, useMemo, useState} from 'react' +import {computeAgeAssuranceRegionAccess} from '@atproto/api' + +import {useSession} from '#/state/session' +import {useAgeAssuranceDataContext} from '#/ageAssurance/data' +import {logger} from '#/ageAssurance/logger' +import { + AgeAssuranceAccess, + type AgeAssuranceState, + AgeAssuranceStatus, + parseAccessFromString, + parseStatusFromString, +} from '#/ageAssurance/types' +import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util' +import {useGeolocation} from '#/geolocation' + +export function useAgeAssuranceState(): AgeAssuranceState { + const {hasSession} = useSession() + const geolocation = useGeolocation() + const {config, state, data} = useAgeAssuranceDataContext() + + return useMemo(() => { + /** + * This is where we control logged-out moderation prefs. It's all + * downstream of AA now. + */ + if (!hasSession) + return { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + } + + // should never happen, but need to guard + if (!config) { + logger.warn('useAgeAssuranceState: missing config') + return { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Unknown, + } + } + + const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation) + const isAARequired = region.countryCode !== '*' + const isTerminalState = + state?.status === 'assured' || state?.status === 'blocked' + + /* + * If we are in a terminal state and AA is required for this region, + * we can trust the server state completely and avoid recomputing. + */ + if (isTerminalState && isAARequired) { + return { + lastInitiatedAt: state.lastInitiatedAt, + status: parseStatusFromString(state.status), + access: parseAccessFromString(state.access), + } + } + + /* + * Otherwise, we need to compute the access based on the latest data. For + * accounts with an accurate birthdate, our default fallback rules should + * ensure correct access. + */ + const result = computeAgeAssuranceRegionAccess(region, data) + const computed = { + lastInitiatedAt: state?.lastInitiatedAt, + // prefer server state + status: state?.status + ? parseStatusFromString(state?.status) + : AgeAssuranceStatus.Unknown, + // prefer server state + access: result + ? parseAccessFromString(result.access) + : AgeAssuranceAccess.Full, + } + logger.debug('debug useAgeAssuranceState', { + region, + state, + data, + computed, + }) + return computed + }, [hasSession, geolocation, config, state, data]) +} + +export function useOnAgeAssuranceAccessUpdate( + cb: (state: AgeAssuranceState) => void, +) { + const state = useAgeAssuranceState() + // start with null to ensure callback is called on first render + const [prevAccess, setPrevAccess] = useState(null) + + useEffect(() => { + if (prevAccess !== state.access) { + setPrevAccess(state.access) + cb(state) + logger.debug(`useOnAgeAssuranceAccessUpdate`, {state}) + } + }, [cb, state, prevAccess]) +} diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts new file mode 100644 index 0000000000..9f83975d3e --- /dev/null +++ b/src/ageAssurance/types.ts @@ -0,0 +1,53 @@ +import {logger} from '#/ageAssurance/logger' + +export enum AgeAssuranceAccess { + Unknown = 'unknown', + None = 'none', + Safe = 'safe', + Full = 'full', +} + +export enum AgeAssuranceStatus { + Unknown = 'unknown', + Pending = 'pending', + Assured = 'assured', + Blocked = 'blocked', +} + +export type AgeAssuranceState = { + lastInitiatedAt?: string + status: AgeAssuranceStatus + access: AgeAssuranceAccess +} + +export function parseStatusFromString(raw: string) { + switch (raw) { + case 'unknown': + return AgeAssuranceStatus.Unknown + case 'pending': + return AgeAssuranceStatus.Pending + case 'assured': + return AgeAssuranceStatus.Assured + case 'blocked': + return AgeAssuranceStatus.Blocked + default: + logger.error(`parseStatusFromString: unknown status value: ${raw}`) + return AgeAssuranceStatus.Unknown + } +} + +export function parseAccessFromString(raw: string) { + switch (raw) { + case 'unknown': + return AgeAssuranceAccess.Unknown + case 'none': + return AgeAssuranceAccess.None + case 'safe': + return AgeAssuranceAccess.Safe + case 'full': + return AgeAssuranceAccess.Full + default: + logger.error(`parseAccessFromString: unknown access value: ${raw}`) + return AgeAssuranceAccess.Full + } +} diff --git a/src/ageAssurance/useBeginAgeAssurance.ts b/src/ageAssurance/useBeginAgeAssurance.ts new file mode 100644 index 0000000000..9614155698 --- /dev/null +++ b/src/ageAssurance/useBeginAgeAssurance.ts @@ -0,0 +1,74 @@ +import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api' +import {useMutation} from '@tanstack/react-query' + +import {wait} from '#/lib/async/wait' +import { + DEV_ENV_APPVIEW, + PUBLIC_APPVIEW, + PUBLIC_APPVIEW_DID, +} from '#/lib/constants' +import {isNetworkError} from '#/lib/hooks/useCleanError' +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {usePatchAgeAssuranceServerState} from '#/ageAssurance' +import {BLUESKY_PROXY_DID} from '#/env' +import {useGeolocation} from '#/geolocation' + +const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID +const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW + +export function useBeginAgeAssurance() { + const agent = useAgent() + const geolocation = useGeolocation() + const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState() + + return useMutation({ + async mutationFn( + props: Omit< + AppBskyAgeassuranceBegin.InputSchema, + 'countryCode' | 'regionCode' + >, + ) { + const countryCode = geolocation?.countryCode + const regionCode = geolocation?.regionCode + if (!countryCode) { + throw new Error(`Geolocation not available, cannot init age assurance.`) + } + + const { + data: {token}, + } = await agent.com.atproto.server.getServiceAuth({ + aud: BLUESKY_PROXY_DID, + lxm: `app.bsky.ageassurance.begin`, + }) + + const appView = new AtpAgent({service: APPVIEW}) + appView.sessionManager.session = {...agent.session!} + appView.sessionManager.session.accessJwt = token + appView.sessionManager.session.refreshJwt = '' + + /* + * 2s wait is good actually. Email sending takes a hot sec and this helps + * ensure the email is ready for the user once they open their inbox. + */ + const {data} = await wait( + 2e3, + appView.app.bsky.ageassurance.begin({ + ...props, + countryCode: countryCode.toUpperCase(), + regionCode: regionCode ? regionCode.toUpperCase() : undefined, + }), + ) + + // Just keeps this in sync, not necessarily used right now + patchAgeAssuranceStateResponse(data) + }, + onError(e) { + if (!isNetworkError(e)) { + logger.error(`useBeginAgeAssurance failed`, { + safeMessage: e, + }) + } + }, + }) +} diff --git a/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts b/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts new file mode 100644 index 0000000000..e3ea48860f --- /dev/null +++ b/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts @@ -0,0 +1,29 @@ +import {useCallback} from 'react' +import {computeAgeAssuranceRegionAccess} from '@atproto/api' + +import {useAgeAssuranceDataContext} from '#/ageAssurance/data' +import {logger} from '#/ageAssurance/logger' +import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types' +import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util' +import {type Geolocation} from '#/geolocation' + +export function useComputeAgeAssuranceRegionAccess() { + const {config, data} = useAgeAssuranceDataContext() + return useCallback( + (geolocation: Geolocation) => { + if (!config) { + logger.warn('useComputeAgeAssuranceRegionAccess: missing config') + return AgeAssuranceAccess.Unknown + } + const region = getAgeAssuranceRegionConfigWithFallback( + config, + geolocation, + ) + const result = computeAgeAssuranceRegionAccess(region, data) + return result + ? parseAccessFromString(result.access) + : AgeAssuranceAccess.Full + }, + [config, data], + ) +} diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts new file mode 100644 index 0000000000..1043283305 --- /dev/null +++ b/src/ageAssurance/util.ts @@ -0,0 +1,88 @@ +import {useMemo} from 'react' +import { + ageAssuranceRuleIDs as ids, + type AppBskyAgeassuranceDefs, + getAgeAssuranceRegionConfig, +} from '@atproto/api' + +import {getAge} from '#/lib/strings/time' +import {useAgeAssuranceDataContext} from '#/ageAssurance/data' +import {AgeAssuranceAccess} from '#/ageAssurance/types' +import {type Geolocation, useGeolocation} from '#/geolocation' + +const DEFAULT_MIN_AGE = 13 + +/** + * Get age assurance region config based on geolocation, with fallback to + * app defaults if no region config is found. + * + * See {@link getAgeAssuranceRegionConfig} for the generic option, which can + * return undefined if the geolocation does not match any AA region. + */ +export function getAgeAssuranceRegionConfigWithFallback( + config: AppBskyAgeassuranceDefs.Config, + geolocation: Geolocation, +): AppBskyAgeassuranceDefs.ConfigRegion { + const region = getAgeAssuranceRegionConfig(config, { + countryCode: geolocation.countryCode ?? '', + regionCode: geolocation.regionCode, + }) + + return ( + region || { + countryCode: '*', + regionCode: undefined, + rules: [ + { + $type: ids.IfDeclaredOverAge, + age: DEFAULT_MIN_AGE, + access: AgeAssuranceAccess.Full, + }, + { + $type: ids.Default, + access: AgeAssuranceAccess.None, + }, + ], + } + ) +} + +/** + * Hook to get the age assurance region config based on current geolocation. + * Does not fall-back to our app defaults. If no config is found, returns + * undefined, which indicates no regional age assurance rules apply. + */ +export function useAgeAssuranceRegionConfig() { + const geolocation = useGeolocation() + const {config} = useAgeAssuranceDataContext() + return useMemo(() => { + if (!config) return + // use generic helper, we want to potentially return undefined + return getAgeAssuranceRegionConfig(config, { + countryCode: geolocation.countryCode ?? '', + regionCode: geolocation.regionCode, + }) + }, [config, geolocation]) +} + +/** + * Some users may have erroneously set their birth date to the current date + * if one wasn't set on their account. We previously didn't do validation on + * the bday dialog, and it defaulted to the current date. This bug _has_ been + * seen in production, so we need to check for it where possible. + */ +export function isLegacyBirthdateBug(birthDate: string) { + return ['2025', '2024', '2023'].includes((birthDate || '').slice(0, 4)) +} + +/** + * Returns whether the user is under the minimum age required to use the app. + * This applies to all regions. + */ +export function isUserUnderMinimumAge(birthDate: string) { + return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE +} + +export function isUserUnderAdultAge(birthDate: string) { + return getAge(new Date(birthDate)) < 18 +} diff --git a/src/components/BlockedGeoOverlay.tsx b/src/components/BlockedGeoOverlay.tsx deleted file mode 100644 index d6e2626875..0000000000 --- a/src/components/BlockedGeoOverlay.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import {useEffect} from 'react' -import {ScrollView, View} from 'react-native' -import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' -import {useDeviceGeolocationApi} from '#/state/geolocation' -import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' -import {Divider} from '#/components/Divider' -import {Full as Logo, Mark} from '#/components/icons/Logo' -import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation' -import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link' -import {Outlet as PortalOutlet} from '#/components/Portal' -import * as Toast from '#/components/Toast' -import {Text} from '#/components/Typography' -import {BottomSheetOutlet} from '#/../modules/bottom-sheet' - -export function BlockedGeoOverlay() { - const t = useTheme() - const {_} = useLingui() - const {gtPhone} = useBreakpoints() - const insets = useSafeAreaInsets() - const geoDialog = Dialog.useDialogControl() - const {setDeviceGeolocation} = useDeviceGeolocationApi() - - useEffect(() => { - // just counting overall hits here - logger.metric(`blockedGeoOverlay:shown`, {}) - }, []) - - const textStyles = [a.text_md, a.leading_normal] - const links = { - blog: { - to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`, - label: _(msg`Read our blog post`), - overridePresentation: false, - disableMismatchWarning: true, - style: textStyles, - }, - } - - const blocks = [ - _(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`), - _( - msg`A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies.`, - ), - _( - msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`, - ), - _( - msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`, - ), - <> - To learn more, read our{' '} - blog post. - , - ] - - return ( - <> - - - - - - - Announcement - - - - - - {blocks.map((block, index) => ( - - {block} - - ))} - - - {!isWeb && ( - <> - - - - - - - Not in Mississippi? - - - - Confirm your location with GPS. Your location data is not - tracked and does not leave your device. - - - - - - { - if (props.geolocationStatus.isAgeBlockedGeo) { - props.disableDialogAction() - props.setDialogError( - _( - msg`We're sorry, but based on your device's location, you are currently located in a region where we cannot provide access at this time.`, - ), - ) - } else { - props.closeDialog(() => { - // set this after close! - setDeviceGeolocation({ - countryCode: props.geolocationStatus.countryCode, - regionCode: props.geolocationStatus.regionCode, - }) - Toast.show(_(msg`Thanks! You're all set.`), { - type: 'success', - }) - }) - } - }} - /> - - )} - - - - - - - - {/* - * While this blocking overlay is up, other dialogs in the shell - * are not mounted, so it _should_ be safe to use these here - * without fear of other modals showing up. - */} - - - - ) -} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index efac8468d0..810442fea0 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -798,13 +798,14 @@ export function ButtonIcon({ * also so that we can calculate transforms. */ const iconSize = { - '2xs': 8, xs: 12, sm: 16, md: 18, lg: 24, xl: 28, + '2xs': 8, '2xl': 32, + '3xl': 40, }[iconSizeShorthand] /* diff --git a/src/components/Link.tsx b/src/components/Link.tsx index b075fa2e0a..8618364281 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -421,6 +421,7 @@ export function SimpleInlineLinkText({ label, disableUnderline, shouldProxy, + onPress: outerOnPress, ...rest }: Omit< InlineLinkProps, @@ -428,7 +429,6 @@ export function SimpleInlineLinkText({ | 'action' | 'disableMismatchWarning' | 'overridePresentation' - | 'onPress' | 'onLongPress' | 'shareOnLongPress' > & { @@ -448,7 +448,9 @@ export function SimpleInlineLinkText({ href = createProxiedUrl(href) } - const onPress = () => { + const onPress = (e: GestureResponderEvent) => { + const exitEarlyIfFalse = outerOnPress?.(e) + if (exitEarlyIfFalse === false) return Linking.openURL(href) } @@ -517,7 +519,7 @@ export function WebOnlyInlineLinkText({ export function createStaticClick( onPressHandler: Exclude, ): { - to: BaseLinkProps['to'] + to: string onPress: Exclude } { return { diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index fdfaec64de..24dc0017a4 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -4,6 +4,10 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {cleanError} from '#/lib/strings/errors' +import { + EmptyState, + type EmptyStateButtonProps, +} from '#/view/com/util/EmptyState' import {CenteredView} from '#/view/com/util/Views' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' @@ -129,6 +133,9 @@ let ListMaybePlaceholder = ({ hideBackButton, sideBorders, topBorder = false, + emptyStateIcon, + emptyStateButton, + useEmptyState = false, }: { isLoading: boolean noEmpty?: boolean @@ -143,6 +150,9 @@ let ListMaybePlaceholder = ({ hideBackButton?: boolean sideBorders?: boolean topBorder?: boolean + emptyStateIcon?: React.ComponentType | React.ReactElement + emptyStateButton?: EmptyStateButtonProps + useEmptyState?: boolean }): React.ReactNode => { const t = useTheme() const {_} = useLingui() @@ -180,6 +190,23 @@ let ListMaybePlaceholder = ({ ) } + if (useEmptyState) { + return ( + + + + ) + } + if (!noEmpty) { return ( - + {typeof repostCount !== 'undefined' && repostCount > 0 && ( {formatPostStatCount(repostCount)} @@ -156,7 +156,7 @@ let RepostButtonDialogInner = ({ size="large" variant="ghost" color="primary"> - + {isReposted ? ( Remove repost @@ -178,7 +178,7 @@ let RepostButtonDialogInner = ({ size="large" variant="ghost" color="primary"> - () const sendViaChatControl = useDialogControl() const [devModeEnabled] = useDevMode() - const {isAgeRestricted} = useAgeAssurance() + const aa = useAgeAssurance() const postUri = post.uri const postAuthor = useProfileShadow(post.author) @@ -91,7 +91,7 @@ let ShareMenuItems = ({ return ( <> - {hasSession && !isAgeRestricted && ( + {hasSession && aa.state.access === aa.Access.Full && ( diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx index ac424c37a0..e8657dac29 100644 --- a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx @@ -10,7 +10,6 @@ import {shareText, shareUrl} from '#/lib/sharing' import {toShareUrl} from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' -import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' import {useBreakpoints} from '#/alf' @@ -22,6 +21,7 @@ import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/i import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' import * as Menu from '#/components/Menu' +import {useAgeAssurance} from '#/ageAssurance' import {useDevMode} from '#/storage/hooks/dev-mode' import {type ShareMenuItemsProps} from './ShareMenuItems.types' @@ -38,7 +38,7 @@ let ShareMenuItems = ({ const embedPostControl = useDialogControl() const sendViaChatControl = useDialogControl() const [devModeEnabled] = useDevMode() - const {isAgeRestricted} = useAgeAssurance() + const aa = useAgeAssurance() const postUri = post.uri const postCid = post.cid @@ -97,7 +97,7 @@ let ShareMenuItems = ({ {!hideInPWI && copyLinkItem} - {hasSession && !isAgeRestricted && ( + {hasSession && aa.state.access === aa.Access.Full && ( + textStyle?: StyleProp }) { const moderation = moderateProfile(profile, moderationOpts) const name = sanitizeDisplayName( @@ -294,7 +304,7 @@ export function Name({ ) const verification = useSimpleVerificationState({profile}) return ( - + {name} diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index 18afa462af..511cee1a68 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -8,7 +8,7 @@ import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' import {logEvent} from '#/lib/statsig/statsig' import {isWeb} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useActorSearchPaginated} from '#/state/queries/actor-search' +import {useActorSearch} from '#/state/queries/actor-search' import {usePreferencesQuery} from '#/state/queries/preferences' import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery' import {useSession} from '#/state/session' @@ -128,7 +128,7 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) { isFetching: isFetchingSearchResults, error: searchResultsError, isError: isSearchResultsError, - } = useActorSearchPaginated({ + } = useActorSearch({ enabled: !!searchText, query: searchText, }) diff --git a/src/components/StarterPack/Main/PostsList.tsx b/src/components/StarterPack/Main/PostsList.tsx index 0a17e038ec..0830a31bd5 100644 --- a/src/components/StarterPack/Main/PostsList.tsx +++ b/src/components/StarterPack/Main/PostsList.tsx @@ -9,6 +9,7 @@ import {PostFeed} from '#/view/com/posts/PostFeed' import {EmptyState} from '#/view/com/util/EmptyState' import {type ListRef} from '#/view/com/util/List' import {type SectionRef} from '#/screens/Profile/Sections/types' +import {HashtagWide_Stroke1_Corner0_Rounded as HashtagWideIcon} from '#/components/icons/Hashtag' interface ProfilesListProps { listUri: string @@ -33,7 +34,13 @@ export const PostsList = React.forwardRef( })) const renderPostsEmpty = useCallback(() => { - return + return ( + + ) }, [_]) return ( diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index ecb5357e0e..b2a0f02989 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -21,6 +21,10 @@ import {parseStarterPackUri} from '#/lib/strings/starter-pack' import {logger} from '#/logger' import {isIOS} from '#/platform/detection' import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs' +import { + EmptyState, + type EmptyStateButtonProps, +} from '#/view/com/util/EmptyState' import {List, type ListRef} from '#/view/com/util/List' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {atoms as a, ios, useTheme} from '#/alf' @@ -47,6 +51,9 @@ interface ProfileFeedgensProps { testID?: string setScrollViewTag: (tag: number | null) => void isMe: boolean + emptyStateMessage?: string + emptyStateButton?: EmptyStateButtonProps + emptyStateIcon?: React.ComponentType | React.ReactElement } function keyExtractor(item: AppBskyGraphDefs.StarterPackView) { @@ -63,6 +70,9 @@ export function ProfileStarterPacks({ testID, setScrollViewTag, isMe, + emptyStateMessage, + emptyStateButton, + emptyStateIcon, }: ProfileFeedgensProps) { const t = useTheme() const bottomBarOffset = useBottomBarOffset(100) @@ -79,6 +89,28 @@ export function ProfileStarterPacks({ const {isTabletOrDesktop} = useWebMediaQueries() const items = data?.pages.flatMap(page => page.starterPacks) + const {_} = useLingui() + + const EmptyComponent = useCallback(() => { + if (emptyStateMessage || emptyStateButton || emptyStateIcon) { + return ( + + + + ) + } + return + }, [_, emptyStateMessage, emptyStateButton, emptyStateIcon]) useImperativeHandle(ref, () => ({ scrollToTop: () => {}, @@ -146,7 +178,7 @@ export function ProfileStarterPacks({ onEndReached={onEndReached} onRefresh={onRefresh} ListEmptyComponent={ - data ? (isMe ? Empty : undefined) : FeedLoadingPlaceholder + data ? (isMe ? EmptyComponent : undefined) : FeedLoadingPlaceholder } ListFooterComponent={ !!data && items?.length !== 0 && isMe ? CreateAnother : undefined diff --git a/src/components/activity-notifications/SubscribeProfileButton.tsx b/src/components/activity-notifications/SubscribeProfileButton.tsx index 84d8c80518..d42d438d4b 100644 --- a/src/components/activity-notifications/SubscribeProfileButton.tsx +++ b/src/components/activity-notifications/SubscribeProfileButton.tsx @@ -18,9 +18,11 @@ import {SubscribeProfileDialog} from './SubscribeProfileDialog' export function SubscribeProfileButton({ profile, moderationOpts, + disableHint, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts + disableHint?: boolean }) { const {_} = useLingui() const requireEmailVerification = useRequireEmailVerification() @@ -69,7 +71,7 @@ export function SubscribeProfileButton({ return ( <> diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx index a7accfa749..a75e75d133 100644 --- a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx +++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx @@ -4,9 +4,6 @@ import {useLingui} from '@lingui/react' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {isNative} from '#/platform/detection' -import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' -import {logger} from '#/state/ageAssurance/util' -import {useDeviceGeolocationApi} from '#/state/geolocation' import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' import {Admonition} from '#/components/Admonition' import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' @@ -23,14 +20,13 @@ import {Divider} from '#/components/Divider' import {createStaticClick, InlineLinkText} from '#/components/Link' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {logger, useAgeAssurance} from '#/ageAssurance' +import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' +import {useDeviceGeolocationApi} from '#/geolocation' export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) { - const {isReady, isAgeRestricted, isDeclaredUnderage} = useAgeAssurance() - - if (!isReady) return null - if (isDeclaredUnderage) return null - if (!isAgeRestricted) return null - + const aa = useAgeAssurance() + if (aa.state.access === aa.Access.Full) return null return } @@ -43,10 +39,12 @@ function Inner({style}: ViewStyleProp & {}) { const getTimeAgo = useGetTimeAgo() const {gtPhone} = useBreakpoints() const {setDeviceGeolocation} = useDeviceGeolocationApi() + const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess() const copy = useAgeAssuranceCopy() - const {status, lastInitiatedAt} = useAgeAssurance() - const isBlocked = status === 'blocked' + const aa = useAgeAssurance() + const {status, lastInitiatedAt} = aa.state + const isBlocked = status === aa.Status.Blocked const hasInitiated = !!lastInitiatedAt const timeAgo = lastInitiatedAt ? getTimeAgo(lastInitiatedAt, new Date()) @@ -98,7 +96,10 @@ function Inner({style}: ViewStyleProp & {}) { { - if (props.geolocationStatus.isAgeRestrictedGeo) { + const access = computeAgeAssuranceRegionAccess( + props.geolocation, + ) + if (access !== aa.Access.Full) { props.disableDialogAction() props.setDialogError( _( @@ -108,10 +109,7 @@ function Inner({style}: ViewStyleProp & {}) { } else { props.closeDialog(() => { // set this after close! - setDeviceGeolocation({ - countryCode: props.geolocationStatus.countryCode, - regionCode: props.geolocationStatus.regionCode, - }) + setDeviceGeolocation(props.geolocation) Toast.show(_(msg`Thanks! You're all set.`), { type: 'success', }) diff --git a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx index 028e1dad52..7889070c09 100644 --- a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx +++ b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx @@ -2,25 +2,23 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' -import {logger} from '#/state/ageAssurance/util' import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf' import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog' import type * as Dialog from '#/components/Dialog' import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' +import {useAgeAssurance} from '#/ageAssurance' +import {logger} from '#/ageAssurance' export function AgeAssuranceAdmonition({ children, style, }: ViewStyleProp & {children: React.ReactNode}) { const control = useDialogControl() - const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance() + const aa = useAgeAssurance() - if (!isReady) return null - if (isDeclaredUnderage) return null - if (!isAgeRestricted) return null + if (aa.state.access === aa.Access.Full) return null return ( diff --git a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx index 9fbe0c428d..d8330a94c3 100644 --- a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx @@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react' import {useMutation} from '@tanstack/react-query' import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants' -import {logger} from '#/state/ageAssurance/util' import {useAgent, useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useBreakpoints, web} from '#/alf' @@ -15,6 +14,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {logger} from '#/ageAssurance' export function AgeAssuranceAppealDialog({ control, diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx index cad7e2dc87..3471393451 100644 --- a/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx +++ b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx @@ -3,8 +3,6 @@ import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' -import {logger} from '#/state/ageAssurance/util' import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' import {atoms as a, select, useTheme} from '#/alf' import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' @@ -13,30 +11,22 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {Link} from '#/components/Link' import {Text} from '#/components/Typography' +import {useAgeAssurance} from '#/ageAssurance' +import {logger} from '#/ageAssurance' export function useInternalState() { - const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} = - useAgeAssurance() + const aa = useAgeAssurance() const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner) const {mutate: save, variables} = useSaveNux() const hidden = !!variables const visible = useMemo(() => { - if (!isReady) return false - if (isDeclaredUnderage) return false - if (!isAgeRestricted) return false - if (lastInitiatedAt) return false + if (aa.state.access === aa.Access.Full) return false + if (aa.state.lastInitiatedAt) return false if (hidden) return false if (nux && nux.completed) return false return true - }, [ - isReady, - isDeclaredUnderage, - isAgeRestricted, - lastInitiatedAt, - hidden, - nux, - ]) + }, [aa, hidden, nux]) const close = () => { save({ diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx index c9f242ca88..934ac8d14a 100644 --- a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx +++ b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx @@ -2,28 +2,25 @@ import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' -import {logger} from '#/state/ageAssurance/util' import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' import {atoms as a, type ViewStyleProp} from '#/alf' import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {useAgeAssurance} from '#/ageAssurance' +import {logger} from '#/ageAssurance' export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { const {_} = useLingui() - const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} = - useAgeAssurance() + const aa = useAgeAssurance() const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice) const copy = useAgeAssuranceCopy() const {mutate: save, variables} = useSaveNux() const hidden = !!variables - if (!isReady) return null - if (isDeclaredUnderage) return null - if (!isAgeRestricted) return null - if (lastInitiatedAt) return null + if (aa.state.access === aa.Access.Full) return null + if (aa.state.lastInitiatedAt) return null if (hidden) return null if (nux && nux.completed) return null diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx index 2f6c041dc2..bce3bdc0f8 100644 --- a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx @@ -14,9 +14,6 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useTLDs} from '#/lib/hooks/useTLDs' import {isEmailMaybeInvalid} from '#/lib/strings/email' import {type AppLanguage} from '#/locale/languages' -import {useAgeAssuranceContext} from '#/state/ageAssurance' -import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance' -import {logger} from '#/state/ageAssurance/util' import {useLanguagePrefs} from '#/state/preferences' import {useSession} from '#/state/session' import {atoms as a, useTheme, web} from '#/alf' @@ -30,9 +27,12 @@ import {Divider} from '#/components/Divider' import * as TextField from '#/components/forms/TextField' import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' import {LanguageSelect} from '#/components/LanguageSelect' -import {InlineLinkText} from '#/components/Link' +import {SimpleInlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {logger} from '#/ageAssurance' +import {useAgeAssurance} from '#/ageAssurance' +import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance' export {useDialogControl} from '#/components/Dialog/context' @@ -69,7 +69,8 @@ function Inner() { const langPrefs = useLanguagePrefs() const cleanError = useCleanError() const {close} = Dialog.useDialogContext() - const {lastInitiatedAt} = useAgeAssuranceContext() + const aa = useAgeAssurance() + const lastInitiatedAt = aa.state.lastInitiatedAt const getTimeAgo = useGetTimeAgo() const tlds = useTLDs() const createSupportLink = useCreateSupportLink() @@ -88,7 +89,7 @@ function Inner() { ) const [error, setError] = useState(null) - const {mutateAsync: init, isPending} = useInitAgeAssurance() + const {mutateAsync: begin, isPending} = useBeginAgeAssurance() const runEmailValidation = () => { if (validateEmail(email)) { @@ -127,7 +128,7 @@ function Inner() { return } - await init({ + await begin({ email, language, }) @@ -150,11 +151,11 @@ function Inner() { We're having issues initializing the age assurance process for your account. Please{' '} - contact support - {' '} + {' '} for assistance. @@ -195,14 +196,12 @@ function Inner() { We have partnered with{' '} - KWS - {' '} + {' '} to verify that you’re an adult. When you click "Begin" below, KWS will check if you have previously verified your age using this email address for other games/services powered by KWS @@ -328,24 +327,20 @@ function Inner() { style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}> By continuing, you agree to the{' '} - KWS Terms of Use - {' '} + {' '} and acknowledge that KWS will store your verified status with your hashed email address in accordance with the{' '} - KWS Privacy Policy - + . This means you won’t need to verify again the next time you use this email for other apps, games, and services powered by KWS technology. diff --git a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx index 3146ddc80e..a28b1aeac9 100644 --- a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx @@ -6,8 +6,6 @@ import {useLingui} from '@lingui/react' import {retry} from '#/lib/async/retry' import {wait} from '#/lib/async/wait' import {isNative} from '#/platform/detection' -import {useAgeAssuranceAPIContext} from '#/state/ageAssurance' -import {logger} from '#/state/ageAssurance/util' import {useAgent} from '#/state/session' import {atoms as a, useTheme, web} from '#/alf' import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' @@ -18,6 +16,8 @@ import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/ic import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {refetchAgeAssuranceServerState} from '#/ageAssurance' +import {logger} from '#/ageAssurance' export type AgeAssuranceRedirectDialogState = { result: 'success' | 'unknown' @@ -63,7 +63,7 @@ export function AgeAssuranceRedirectDialog() { const {_} = useLingui() const control = useAgeAssuranceRedirectDialogControl() - // TODO for testing + // for testing // Dialog.useAutoOpen(control.control, 3e3) return ( @@ -88,7 +88,6 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { const control = useAgeAssuranceRedirectDialogControl() const [error, setError] = useState(false) const [success, setSuccess] = useState(false) - const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext() useEffect(() => { if (polling.current) return @@ -106,9 +105,9 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { if (!agent.session) return if (unmounted.current) return - const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState() + const data = await refetchAgeAssuranceServerState({agent}) - if (data.status !== 'assured') { + if (data?.state.status !== 'assured') { throw new Error( `Polling for age assurance state did not receive assured status`, ) @@ -124,9 +123,6 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { if (!agent.session) return if (unmounted.current) return - // success! update state - await refreshAgeAssuranceState() - setSuccess(true) logger.metric('ageAssurance:redirectDialogSuccess', {}) @@ -134,15 +130,13 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { .catch(() => { if (unmounted.current) return setError(true) - // try a refetch anyway - refreshAgeAssuranceState() logger.metric('ageAssurance:redirectDialogFail', {}) }) return () => { unmounted.current = true } - }, [agent, control, refreshAgeAssuranceState]) + }, [agent, control]) if (success) { return ( diff --git a/src/components/ageAssurance/AgeRestrictedScreen.tsx b/src/components/ageAssurance/AgeRestrictedScreen.tsx index b6a8c26a36..85881a3ada 100644 --- a/src/components/ageAssurance/AgeRestrictedScreen.tsx +++ b/src/components/ageAssurance/AgeRestrictedScreen.tsx @@ -2,8 +2,6 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' -import {logger} from '#/state/ageAssurance/util' import {atoms as a} from '#/alf' import {Admonition} from '#/components/Admonition' import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' @@ -13,6 +11,8 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' import {Text} from '#/components/Typography' +import {useAgeAssurance} from '#/ageAssurance' +import {logger} from '#/ageAssurance' export function AgeRestrictedScreen({ children, @@ -27,22 +27,9 @@ export function AgeRestrictedScreen({ }) { const {_} = useLingui() const copy = useAgeAssuranceCopy() - const {isReady, isAgeRestricted} = useAgeAssurance() + const aa = useAgeAssurance() - if (!isReady) { - return ( - - - - - - - - - - ) - } - if (!isAgeRestricted) return children + if (aa.state.access === aa.Access.Full) return children return ( diff --git a/src/components/ageAssurance/useAgeAssuranceCopy.ts b/src/components/ageAssurance/useAgeAssuranceCopy.ts index c861f8336b..e75f84feed 100644 --- a/src/components/ageAssurance/useAgeAssuranceCopy.ts +++ b/src/components/ageAssurance/useAgeAssuranceCopy.ts @@ -8,7 +8,7 @@ export function useAgeAssuranceCopy() { return useMemo(() => { return { notice: _( - msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`, + msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`, ), banner: _( msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`, diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx index e1c73b67cb..9915d0a2d7 100644 --- a/src/components/dialogs/BirthDateSettings.tsx +++ b/src/components/dialogs/BirthDateSettings.tsx @@ -7,10 +7,13 @@ import {cleanError} from '#/lib/strings/errors' import {getAge, getDateAgo} from '#/lib/strings/time' import {logger} from '#/logger' import {isIOS, isWeb} from '#/platform/detection' +import { + useBirthdateMutation, + useIsBirthdateUpdateAllowed, +} from '#/state/birthdate' import { usePreferencesQuery, type UsePreferencesQueryResponse, - usePreferencesSetBirthDateMutation, } from '#/state/queries/preferences' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {atoms as a, useTheme, web} from '#/alf' @@ -18,7 +21,7 @@ import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {DateField} from '#/components/forms/DateField' -import {InlineLinkText} from '#/components/Link' +import {SimpleInlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' @@ -30,42 +33,71 @@ export function BirthDateSettingsDialog({ const t = useTheme() const {_} = useLingui() const {isLoading, error, data: preferences} = usePreferencesQuery() + const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed() return ( - - - - My Birthday - - - - This information is private and not shared with other users. - - + {isBirthdateUpdateAllowed ? ( + + + + My Birthdate + + + + This information is private and not shared with other users. + + - {isLoading ? ( - - ) : error || !preferences ? ( - - ) : ( - - )} - + {isLoading ? ( + + ) : error || !preferences ? ( + + ) : ( + + )} + - - + + + ) : ( + + + + You recently changed your birthdate + + + + There is a limit to how often you can change your birthdate. You + may need to wait a day or two before updating it again. + + + + + + + )} ) } @@ -86,7 +118,7 @@ function BirthdayInner({ isError, error, mutateAsync: setBirthDate, - } = usePreferencesSetBirthDateMutation() + } = useBirthdateMutation() const hasChanged = date !== preferences.birthDate const age = getAge(new Date(date)) @@ -112,8 +144,8 @@ function BirthdayInner({ testID="birthdayInput" value={date} onChangeDate={newDate => setDate(new Date(newDate))} - label={_(msg`Birthday`)} - accessibilityHint={_(msg`Enter your birth date`)} + label={_(msg`Birthdate`)} + accessibilityHint={_(msg`Enter your birthdate`)} /> @@ -130,11 +162,11 @@ function BirthdayInner({ You must be at least 13 years old to use Bluesky. Read our{' '} - Terms of Service - {' '} + {' '} for more information. @@ -146,7 +178,7 @@ function BirthdayInner({ - - - - ) : !isDeclaredUnderage ? ( - <> - - - You must complete age assurance in order to access the settings - below. - - - - - - {!isDeclaredUnderage && ( - <> - - - Enable adult content + {aa.state.access === aa.Access.Full && ( + <> + + + Enable adult content + + + + + {adultContentEnabled ? ( + Enabled + ) : ( + Disabled + )} - - - - {adultContentEnabled ? ( - Enabled - ) : ( - Disabled - )} - - - - + - {disabledOnIOS && ( - - - - Adult content can only be enabled via the Web at{' '} - { - evt.preventDefault() - Linking.openURL('https://bsky.app/') - return false - }}> - bsky.app - - . - - - - )} + + + {adultContentUIDisabledOnIOS && ( + + + + Adult content can only be enabled via the Web at{' '} + { + evt.preventDefault() + Linking.openURL('https://bsky.app/') + return false + }}> + bsky.app + + . + + + + )} - {adultContentEnabled && ( - <> - - - - - - - - - - )} + {adultContentEnabled && ( + <> + + + + + + + + )} - - - - ) : null} + + )} + + ( - + )} renderEndOfFeed={() => } /> diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 6066e42976..453184639a 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -15,6 +15,8 @@ import {openCropper} from '#/lib/media/picker' import {getDataUriSize} from '#/lib/media/util' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {logEvent, useGate} from '#/lib/statsig/statsig' +import {isCancelledError} from '#/lib/strings/errors' +import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' import { DescriptionText, @@ -184,11 +186,17 @@ export function StepProfile() { if (!image) return if (!isWeb) { - image = await openCropper({ - imageUri: image.path, - shape: 'circle', - aspectRatio: 1 / 1, - }) + try { + image = await openCropper({ + imageUri: image.path, + shape: 'circle', + aspectRatio: 1 / 1, + }) + } catch (e) { + if (!isCancelledError(e)) { + logger.error('Failed to crop avatar in onboarding', {error: e}) + } + } } image = await compressIfNeeded(image, 1000000) diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx index 29399331c5..946efda68f 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -312,7 +312,6 @@ function SuggestedProfileCard({ return ( - {niceDate(i18n, post.indexedAt, 'medium')} + {niceDate(i18n, post.indexedAt, 'dot separated')} {isRootPost && ( @@ -655,7 +655,7 @@ function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { a.leading_tight, t.atoms.text_contrast_medium, ]}> - Archived from {niceDate(i18n, createdAt)} + Archived from {niceDate(i18n, createdAt, 'medium')} )} diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 64a6f0f295..92ef5e8661 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -1,10 +1,11 @@ -import {useCallback, useMemo, useRef, useState} from 'react' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {useWindowDimensions, View} from 'react-native' import Animated, {useAnimatedStyle} from 'react-native-reanimated' import {Trans} from '@lingui/macro' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {logger} from '#/logger' import {useFeedFeedback} from '#/state/feed-feedback' import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' import { @@ -73,6 +74,29 @@ export function PostThread({uri}: {uri: string}) { return {hasParents} }, [thread.data.items]) + // Track post:view event when anchor post is viewed + const seenPostUriRef = useRef(null) + useEffect(() => { + if ( + anchor?.type === 'threadPost' && + anchor.value.post.uri !== seenPostUriRef.current + ) { + const post = anchor.value.post + seenPostUriRef.current = post.uri + + logger.metric( + 'post:view', + { + uri: post.uri, + authorDid: post.author.did, + logContext: 'Post', + feedDescriptor: feedFeedback.feedDescriptor, + }, + {statsig: false}, + ) + } + }, [anchor, feedFeedback.feedDescriptor]) + const {openComposer} = useOpenComposer() const optimisticOnPostReply = useCallback( (payload: OnPostSuccessData) => { diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 59f090f47e..6b5794ba95 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -23,7 +23,6 @@ import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' import {usePreferencesQuery} from '#/state/queries/preferences' import {useRequireAuth, useSession} from '#/state/session' import {ProfileMenu} from '#/view/com/profile/ProfileMenu' -import * as Toast from '#/view/com/util/Toast' import {atoms as a, tokens, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {type DialogOuterProps, useDialogControl} from '#/components/Dialog' @@ -34,6 +33,7 @@ import { import {Link} from '#/components/Link' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' +import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {ProfileHeaderDisplayName} from './DisplayName' import {EditProfileDialog} from './EditProfileDialog' @@ -63,28 +63,17 @@ let ProfileHeaderLabeler = ({ const t = useTheme() const {_} = useLingui() const {currentAccount, hasSession} = useSession() - const requireAuth = useRequireAuth() const playHaptic = useHaptics() - const cantSubscribePrompt = Prompt.usePromptControl() const isSelf = currentAccount?.did === profile.did const moderation = useMemo( () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], ) - const {data: preferences} = usePreferencesQuery() - const { - mutateAsync: toggleSubscription, - variables, - reset, - } = useLabelerSubscriptionMutation() - const isSubscribed = - variables?.subscribe ?? - preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation() const {mutateAsync: unlikeMod, isPending: isUnlikePending} = useUnlikeMutation() - const [likeUri, setLikeUri] = useState(labeler.viewer?.like || '') + const [likeUri, setLikeUri] = useState(labeler.viewer?.like || '') const [likeCount, setLikeCount] = useState(labeler.likeCount || 0) const onToggleLiked = useCallback(async () => { @@ -108,56 +97,12 @@ let ProfileHeaderLabeler = ({ _( msg`There was an issue contacting the server, please check your internet connection and try again.`, ), - 'xmark', + {type: 'error'}, ) logger.error(`Failed to toggle labeler like`, {message: e.message}) } }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) - const editProfileControl = useDialogControl() - - const onPressSubscribe = useCallback(() => { - requireAuth(async (): Promise => { - playHaptic() - const subscribe = !isSubscribed - - try { - await toggleSubscription({ - did: profile.did, - subscribe, - }) - - logger.metric( - subscribe - ? 'moderation:subscribedToLabeler' - : 'moderation:unsubscribedFromLabeler', - {}, - {statsig: true}, - ) - } catch (e: any) { - reset() - if (e.message === 'MAX_LABELERS') { - cantSubscribePrompt.open() - return - } - logger.error(`Failed to subscribe to labeler`, {message: e.message}) - } - }) - }, [ - playHaptic, - requireAuth, - toggleSubscription, - isSubscribed, - profile, - cantSubscribePrompt, - reset, - ]) - - const isMe = useMemo( - () => currentAccount?.did === profile.did, - [currentAccount, profile], - ) - return ( - {isMe ? ( - <> - - - - ) : !isAppLabeler(profile.did) ? ( - <> - - - ) : null} - + @@ -265,7 +142,6 @@ let ProfileHeaderLabeler = ({ testID="toggleLikeBtn" size="small" color="secondary" - variant="solid" shape="round" label={_(msg`Like this labeler`)} disabled={!hasSession || isLikePending || isUnlikePending} @@ -318,7 +194,6 @@ let ProfileHeaderLabeler = ({ )} - ) } @@ -349,3 +224,132 @@ function CantSubscribePrompt({ ) } + +export function HeaderLabelerButtons({ + profile, + minimal = false, +}: { + profile: Shadow + /** disable the subscribe button */ + minimal?: boolean +}) { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + const requireAuth = useRequireAuth() + const playHaptic = useHaptics() + const editProfileControl = useDialogControl() + const {data: preferences} = usePreferencesQuery() + const { + mutateAsync: toggleSubscription, + variables, + reset, + } = useLabelerSubscriptionMutation() + const isSubscribed = + variables?.subscribe ?? + preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) + + const cantSubscribePrompt = Prompt.usePromptControl() + + const isMe = currentAccount?.did === profile.did + + const onPressSubscribe = () => + requireAuth(async (): Promise => { + playHaptic() + const subscribe = !isSubscribed + + try { + await toggleSubscription({ + did: profile.did, + subscribe, + }) + + logger.metric( + subscribe + ? 'moderation:subscribedToLabeler' + : 'moderation:unsubscribedFromLabeler', + {}, + {statsig: true}, + ) + } catch (e: any) { + reset() + if (e.message === 'MAX_LABELERS') { + cantSubscribePrompt.open() + return + } + logger.error(`Failed to subscribe to labeler`, {message: e.message}) + } + }) + return ( + <> + {isMe ? ( + <> + + + + ) : !isAppLabeler(profile.did) && !minimal ? ( + // hidden in the minimal header, because it's not shadowed so the two buttons + // can get out of sync. if you want to reenable, you'll need to add shadowing + // to the subscribed state -sfn + + ) : null} + + + + + ) +} diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 66a2352fa2..b1893f2df8 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -1,8 +1,9 @@ -import {memo, useCallback, useMemo, useState} from 'react' +import {memo, useMemo, useState} from 'react' import {View} from 'react-native' import { type AppBskyActorDefs, moderateProfile, + type ModerationDecision, type ModerationOpts, type RichText as RichTextAPI, } from '@atproto/api' @@ -15,14 +16,13 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {isIOS} from '#/platform/detection' -import {useProfileShadow} from '#/state/cache/profile-shadow' +import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' import { useProfileBlockMutationQueue, useProfileFollowMutationQueue, } from '#/state/queries/profile' import {useRequireAuth, useSession} from '#/state/session' import {ProfileMenu} from '#/view/com/profile/ProfileMenu' -import * as Toast from '#/view/com/util/Toast' import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -36,6 +36,7 @@ import { } from '#/components/KnownFollowers' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' +import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {EditProfileDialog} from './EditProfileDialog' @@ -63,107 +64,36 @@ let ProfileHeaderStandard = ({ const {gtMobile} = useBreakpoints() const profile = useProfileShadow(profileUnshadowed) - const {currentAccount, hasSession} = useSession() + const {currentAccount} = useSession() const {_} = useLingui() const moderation = useMemo( () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], ) - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( - profile, - 'ProfileHeader', - ) - const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) + const [, queueUnblock] = useProfileBlockMutationQueue(profile) const unblockPromptControl = Prompt.usePromptControl() - const requireAuth = useRequireAuth() const [showSuggestedFollows, setShowSuggestedFollows] = useState(false) const isBlockedUser = profile.viewer?.blocking || profile.viewer?.blockedBy || profile.viewer?.blockingByList - const playHaptic = useHaptics() - const editProfileControl = useDialogControl() - - const onPressFollow = () => { - playHaptic() - requireAuth(async () => { - setShowSuggestedFollows(true) - try { - await queueFollow() - Toast.show( - _( - msg`Following ${sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - )}`, - ), - ) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to follow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') - } - } - }) - } - - const onPressUnfollow = () => { - playHaptic() - setShowSuggestedFollows(false) - requireAuth(async () => { - try { - await queueUnfollow() - Toast.show( - _( - msg`No longer following ${sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - )}`, - ), - ) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to unfollow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') - } - } - }) - } - - const unblockAccount = useCallback(async () => { - playHaptic() + const unblockAccount = async () => { try { await queueUnblock() Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) } catch (e: any) { if (e?.name !== 'AbortError') { logger.error('Failed to unblock account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') + Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'}) } } - }, [_, queueUnblock, playHaptic]) + } - const isMe = useMemo( - () => currentAccount?.did === profile.did, - [currentAccount, profile], - ) + const isMe = currentAccount?.did === profile.did const {isActive: live} = useActorStatus(profile) - const subscriptionsAllowed = useMemo(() => { - switch (profile.associated?.activitySubscription?.allowSubscriptions) { - case 'followers': - case undefined: - return !!profile.viewer?.following - case 'mutuals': - return !!profile.viewer?.following && !!profile.viewer.followedBy - case 'none': - default: - return false - } - }, [profile]) - return ( <> - {isMe ? ( - <> - - - - ) : profile.viewer?.blocking ? ( - profile.viewer?.blockingByList ? null : ( - - ) - ) : !profile.viewer?.blockedBy ? ( - <> - {hasSession && subscriptionsAllowed && ( - - )} - {hasSession && } - - - - ) : null} - + setShowSuggestedFollows(true)} + onUnfollow={() => setShowSuggestedFollows(false)} + /> @@ -280,13 +140,7 @@ let ProfileHeaderStandard = ({ profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), )} - + @@ -349,3 +203,197 @@ let ProfileHeaderStandard = ({ ProfileHeaderStandard = memo(ProfileHeaderStandard) export {ProfileHeaderStandard} + +export function HeaderStandardButtons({ + profile, + moderation, + moderationOpts, + onFollow, + onUnfollow, + minimal, +}: { + profile: Shadow + moderation: ModerationDecision + moderationOpts: ModerationOpts + onFollow?: () => void + onUnfollow?: () => void + minimal?: boolean +}) { + const {_} = useLingui() + const {hasSession, currentAccount} = useSession() + const playHaptic = useHaptics() + const requireAuth = useRequireAuth() + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profile, + 'ProfileHeader', + ) + const [, queueUnblock] = useProfileBlockMutationQueue(profile) + const editProfileControl = useDialogControl() + const unblockPromptControl = Prompt.usePromptControl() + + const isMe = currentAccount?.did === profile.did + + const onPressFollow = () => { + playHaptic() + requireAuth(async () => { + try { + await queueFollow() + onFollow?.() + Toast.show( + _( + msg`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + )}`, + ), + ) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to follow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), { + type: 'error', + }) + } + } + }) + } + + const onPressUnfollow = () => { + playHaptic() + requireAuth(async () => { + try { + await queueUnfollow() + onUnfollow?.() + Toast.show( + _( + msg`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + )}`, + ), + {type: 'default'}, + ) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unfollow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), { + type: 'error', + }) + } + } + }) + } + + const unblockAccount = async () => { + try { + await queueUnblock() + Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unblock account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), {type: 'error'}) + } + } + } + + const subscriptionsAllowed = useMemo(() => { + switch (profile.associated?.activitySubscription?.allowSubscriptions) { + case 'followers': + case undefined: + return !!profile.viewer?.following + case 'mutuals': + return !!profile.viewer?.following && !!profile.viewer.followedBy + case 'none': + default: + return false + } + }, [profile]) + + return ( + <> + {isMe ? ( + <> + + + + ) : profile.viewer?.blocking ? ( + profile.viewer?.blockingByList ? null : ( + + ) + ) : !profile.viewer?.blockedBy ? ( + <> + {hasSession && (!minimal || profile.viewer?.following) && ( + <> + {subscriptionsAllowed && ( + + )} + + + + )} + + {(!minimal || !profile.viewer?.following) && ( + + )} + + ) : null} + + + + + ) +} diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx index 1158a8aa53..3c872cd17c 100644 --- a/src/screens/Profile/Header/index.tsx +++ b/src/screens/Profile/Header/index.tsx @@ -1,4 +1,4 @@ -import React, {memo, useState} from 'react' +import {memo, useMemo, useState} from 'react' import {type LayoutChangeEvent, StyleSheet, View} from 'react-native' import Animated, { runOnJS, @@ -10,18 +10,30 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context' import { type AppBskyActorDefs, type AppBskyLabelerDefs, + moderateProfile, type ModerationOpts, type RichText as RichTextAPI, } from '@atproto/api' import {useIsFocused} from '@react-navigation/native' +import {sanitizeHandle} from '#/lib/strings/handles' import {isNative} from '#/platform/detection' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useSetLightStatusBar} from '#/state/shell/light-status-bar' import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {atoms as a, useTheme} from '#/alf' -import {ProfileHeaderLabeler} from './ProfileHeaderLabeler' -import {ProfileHeaderStandard} from './ProfileHeaderStandard' +import {Header} from '#/components/Layout' +import * as ProfileCard from '#/components/ProfileCard' +import { + HeaderLabelerButtons, + ProfileHeaderLabeler, +} from './ProfileHeaderLabeler' +import { + HeaderStandardButtons, + ProfileHeaderStandard, +} from './ProfileHeaderStandard' let ProfileHeaderLoading = (_props: {}): React.ReactNode => { const t = useTheme() @@ -75,6 +87,7 @@ let ProfileHeader = ({setMinimumHeight, ...props}: Props): React.ReactNode => { setMinimumHeight(evt.nativeEvent.layout.height)} profile={props.profile} + labeler={props.labeler} hideBackButton={props.hideBackButton} /> )} @@ -85,18 +98,28 @@ let ProfileHeader = ({setMinimumHeight, ...props}: Props): React.ReactNode => { ProfileHeader = memo(ProfileHeader) export {ProfileHeader} -const MinimalHeader = React.memo(function MinimalHeader({ +const MinimalHeader = memo(function MinimalHeader({ onLayout, + profile: profileUnshadowed, + labeler, + hideBackButton = false, }: { onLayout: (e: LayoutChangeEvent) => void profile: AppBskyActorDefs.ProfileViewDetailed + labeler?: AppBskyLabelerDefs.LabelerViewDetailed hideBackButton?: boolean }) { const t = useTheme() const insets = useSafeAreaInsets() const ctx = usePagerHeaderContext() + const profile = useProfileShadow(profileUnshadowed) + const moderationOpts = useModerationOpts() + const moderation = useMemo( + () => (moderationOpts ? moderateProfile(profile, moderationOpts) : null), + [moderationOpts, profile], + ) const [visible, setVisible] = useState(false) - const [minimalHeaderHeight, setMinimalHeaderHeight] = React.useState(0) + const [minimalHeaderHeight, setMinimalHeaderHeight] = useState(insets.top) const isScreenFocused = useIsFocused() if (!ctx) throw new Error('MinimalHeader cannot be used on web') const {scrollY, headerHeight} = ctx @@ -156,8 +179,42 @@ const MinimalHeader = React.memo(function MinimalHeader({ paddingTop: insets.top, }, animatedStyle, - ]} - /> + ]}> + + {hideBackButton ? : } + + {moderationOpts ? ( + + ) : ( + + )} + + {sanitizeHandle(profile.handle, '@')} + + + {!profile.associated?.labeler + ? moderationOpts && + moderation && ( + + + + ) + : labeler && ( + + + + )} + + ) }) MinimalHeader.displayName = 'MinimalHeader' diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx index b97fc4ed58..14f7709c0e 100644 --- a/src/screens/Profile/ProfileFeed/index.tsx +++ b/src/screens/Profile/ProfileFeed/index.tsx @@ -45,6 +45,7 @@ import { ProfileFeedHeader, ProfileFeedHeaderSkeleton, } from '#/screens/Profile/components/ProfileFeedHeader' +import {HashtagWide_Stroke1_Corner0_Rounded as HashtagWideIcon} from '#/components/icons/Hashtag' import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps @@ -189,7 +190,13 @@ export function ProfileFeedScreenInner({ }, [onScrollToTop, isScreenFocused]) const renderPostsEmpty = useCallback(() => { - return + return ( + + ) }, [_]) const isVideoFeed = React.useMemo(() => { diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx index 2f54eda7b3..1591218b78 100644 --- a/src/screens/Profile/Sections/Feed.tsx +++ b/src/screens/Profile/Sections/Feed.tsx @@ -6,14 +6,20 @@ import {useQueryClient} from '@tanstack/react-query' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {isIOS, isNative} from '#/platform/detection' -import {type FeedDescriptor} from '#/state/queries/post-feed' -import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import { + type FeedDescriptor, + RQKEY as FEED_RQKEY, +} from '#/state/queries/post-feed' import {truncateAndInvalidate} from '#/state/queries/util' import {PostFeed} from '#/view/com/posts/PostFeed' -import {EmptyState} from '#/view/com/util/EmptyState' +import { + EmptyState, + type EmptyStateButtonProps, +} from '#/view/com/util/EmptyState' import {type ListRef} from '#/view/com/util/List' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {atoms as a, ios, useTheme} from '#/alf' +import {EditBig_Stroke1_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig' import {Text} from '#/components/Typography' import {type SectionRef} from './types' @@ -25,7 +31,11 @@ interface FeedSectionProps { scrollElRef: ListRef ignoreFilterFor?: string setScrollViewTag: (tag: number | null) => void + emptyStateMessage?: string + emptyStateButton?: EmptyStateButtonProps + emptyStateIcon?: React.ComponentType | React.ReactElement } + export function ProfileFeedSection({ ref, feed, @@ -34,6 +44,9 @@ export function ProfileFeedSection({ scrollElRef, ignoreFilterFor, setScrollViewTag, + emptyStateMessage, + emptyStateButton, + emptyStateIcon, }: FeedSectionProps) { const {_} = useLingui() const queryClient = useQueryClient() @@ -44,7 +57,6 @@ export function ProfileFeedSection({ const adjustedInitialNumToRender = useInitialNumToRender({ screenHeightOffset: headerHeight, }) - const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({ animated: isNative, @@ -59,8 +71,18 @@ export function ProfileFeedSection({ })) const renderPostsEmpty = useCallback(() => { - return - }, [_]) + return ( + + + + ) + }, [_, emptyStateButton, emptyStateIcon, emptyStateMessage]) useEffect(() => { if (isIOS && isFocused && scrollElRef.current) { diff --git a/src/screens/ProfileList/AboutSection.tsx b/src/screens/ProfileList/AboutSection.tsx index 47f29b8386..24de3011a3 100644 --- a/src/screens/ProfileList/AboutSection.tsx +++ b/src/screens/ProfileList/AboutSection.tsx @@ -12,6 +12,7 @@ import {type ListRef} from '#/view/com/util/List' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {atoms as a, useBreakpoints} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {BulletList_Stroke1_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList' import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' interface SectionRef { @@ -95,7 +96,7 @@ export function AboutSection({ const renderEmptyState = useCallback(() => { return ( - + {isOwner && ( + + )} ) } - -const styles = StyleSheet.create({ - iconContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - height: 80, - width: 80, - marginLeft: 'auto', - marginRight: 'auto', - borderRadius: 80, - marginTop: 30, - }, - iconContainerBig: { - width: 100, - height: 100, - marginTop: 50, - }, - text: { - textAlign: 'center', - paddingTop: 20, - }, -}) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index aa5b22bd39..8a9e51a332 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -26,6 +26,7 @@ import {openCamera, openCropper, openPicker} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {isCancelledError} from '#/lib/strings/errors' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {isAndroid, isNative, isWeb} from '#/platform/detection' @@ -407,10 +408,10 @@ let EditableUserAvatar = ({ setRawImage(await createComposerImage(item)) editImageDialogControl.open() } - } catch (e: any) { + } catch (e) { // Don't log errors for cancelling selection to sentry on ios or android - if (!String(e).toLowerCase().includes('cancel')) { - logger.error('Failed to crop banner', {error: e}) + if (!isCancelledError(e)) { + logger.error('Failed to crop avatar', {error: e}) } } }, [ diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 3600f5c24e..65e7b5a4a2 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -12,6 +12,7 @@ import { import {compressIfNeeded} from '#/lib/media/manip' import {openCamera, openCropper, openPicker} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' +import {isCancelledError} from '#/lib/strings/errors' import {logger} from '#/logger' import {isAndroid, isNative} from '#/platform/detection' import { @@ -87,8 +88,9 @@ export function UserBanner({ setRawImage(await createComposerImage(items[0])) editImageDialogControl.open() } - } catch (e: any) { - if (!String(e).includes('Canceled')) { + } catch (e) { + // Don't log errors for cancelling selection to sentry on ios or android + if (!isCancelledError(e)) { logger.error('Failed to crop banner', {error: e}) } } diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 53dac103fe..3d6af65c5e 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -7,16 +7,13 @@ import { } from 'react-native' import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {LinearGradient} from 'expo-linear-gradient' import {PressableScale} from '#/lib/custom-animations/PressableScale' import {useHaptics} from '#/lib/haptics' import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {clamp} from '#/lib/numbers' -import {gradients} from '#/lib/styles' import {isWeb} from '#/platform/detection' -import {ios} from '#/alf' +import {ios, useBreakpoints, useTheme} from '#/alf' import {atoms as a} from '#/alf' export interface FABProps extends ComponentProps { @@ -27,13 +24,14 @@ export interface FABProps extends ComponentProps { export function FABInner({testID, icon, onPress, style, ...props}: FABProps) { const insets = useSafeAreaInsets() - const {isMobile, isTablet} = useWebMediaQueries() + const {gtMobile} = useBreakpoints() + const t = useTheme() const playHaptic = useHaptics() const fabMinimalShellTransform = useMinimalShellFabTransform() - const size = isTablet ? styles.sizeLarge : styles.sizeRegular + const size = gtMobile ? styles.sizeLarge : styles.sizeRegular - const tabletSpacing = isTablet + const tabletSpacing = gtMobile ? {right: 50, bottom: 50} : {right: 24, bottom: clamp(insets.bottom, 15, 60) + 15} @@ -43,7 +41,7 @@ export function FABInner({testID, icon, onPress, style, ...props}: FABProps) { styles.outer, size, tabletSpacing, - isMobile && fabMinimalShellTransform, + !gtMobile && fabMinimalShellTransform, ]}> - - {icon} - + {icon} ) @@ -73,8 +73,8 @@ export function FABInner({testID, icon, onPress, style, ...props}: FABProps) { const styles = StyleSheet.create({ sizeRegular: { - width: 60, - height: 60, + width: 56, + height: 56, borderRadius: 30, }, sizeLarge: { @@ -88,8 +88,4 @@ const styles = StyleSheet.create({ zIndex: 1, cursor: 'pointer', }, - inner: { - justifyContent: 'center', - alignItems: 'center', - }, }) diff --git a/src/view/icons/Logo.tsx b/src/view/icons/Logo.tsx index 2b5884b886..d7208df13c 100644 --- a/src/view/icons/Logo.tsx +++ b/src/view/icons/Logo.tsx @@ -10,9 +10,8 @@ import Svg, { } from 'react-native-svg' import {Image} from 'expo-image' -import {colors} from '#/lib/styles' import {useKawaiiMode} from '#/state/preferences/kawaii' -import {flatten} from '#/alf' +import {flatten, useTheme} from '#/alf' const ratio = 57 / 64 @@ -22,12 +21,15 @@ type Props = { } & Omit export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) { + const t = useTheme() const {fill, ...rest} = props const gradient = fill === 'sky' const styles = flatten(props.style) - const _fill = gradient ? 'url(#sky)' : fill || styles?.color || colors.blue3 + const _fill = gradient + ? 'url(#sky)' + : fill || styles?.color || t.palette.primary_500 // @ts-ignore it's fiiiiine - const size = parseInt(rest.width || 32) + const size = parseInt(rest.width || 32, 10) const isKawaii = useKawaiiMode() diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx index 8b81cee10b..127bdddd76 100644 --- a/src/view/screens/Debug.tsx +++ b/src/view/screens/Debug.tsx @@ -20,6 +20,7 @@ import {Text} from '#/view/com/util/text/Text' import * as Toast from '#/view/com/util/Toast' import {ViewHeader} from '#/view/com/util/ViewHeader' import {ViewSelector} from '#/view/com/util/ViewSelector' +import {HashtagWide_Stroke1_Corner0_Rounded as HashtagWideIcon} from '#/components/icons/Hashtag' import * as Layout from '#/components/Layout' const MAIN_VIEWS = ['Base', 'Controls', 'Error', 'Notifs'] @@ -333,7 +334,15 @@ function TypographyView() { } function EmptyStateView() { - return + const {_} = useLingui() + + return ( + + ) } function LoadingPlaceholderView() { diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index cc339bb03d..fddde55e19 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -7,17 +7,19 @@ import { type ModerationOpts, RichText as RichTextAPI, } from '@atproto/api' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useSetTitle} from '#/lib/hooks/useSetTitle' import {ComposeIcon2} from '#/lib/icons' import { type CommonNavigatorParams, type NativeStackScreenProps, + type NavigationProp, } from '#/lib/routes/types' import {combinedDisplayName} from '#/lib/strings/display-names' import {cleanError} from '#/lib/strings/errors' @@ -42,6 +44,11 @@ import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' import {atoms as a} from '#/alf' +import {Circle_And_Square_Stroke1_Corner0_Rounded_Filled as CircleAndSquareIcon} from '#/components/icons/CircleAndSquare' +import {Heart2_Stroke1_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2' +import {Image_Stroke1_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' +import {Message_Stroke1_Corner0_Rounded_Filled as MessageIcon} from '#/components/icons/Message' +import {VideoClip_Stroke1_Corner0_Rounded as VideoIcon} from '#/components/icons/VideoClip' import * as Layout from '#/components/Layout' import {ScreenHider} from '#/components/moderation/ScreenHider' import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' @@ -169,6 +176,8 @@ function ProfileScreenLoaded({ const {hasSession, currentAccount} = useSession() const setMinimalShellMode = useSetMinimalShellMode() const {openComposer} = useOpenComposer() + const navigation = useNavigation() + const requireEmailVerification = useRequireEmailVerification() const { data: labelerInfo, error: labelerError, @@ -334,6 +343,17 @@ function ProfileScreenLoaded({ scrollSectionToTop(index) } + const navToWizard = useCallback(() => { + navigation.navigate('StarterPackWizard', {}) + }, [navigation]) + const wrappedNavToWizard = requireEmailVerification(navToWizard, { + instructions: [ + + Before creating a starter pack, you must first verify your email. + , + ], + }) + // rendering // = @@ -408,6 +428,14 @@ function ProfileScreenLoaded({ scrollElRef={scrollElRef as ListRef} ignoreFilterFor={profile.did} setScrollViewTag={setScrollViewTag} + emptyStateMessage={_(msg`No posts yet`)} + emptyStateButton={{ + label: _(msg`Write a post`), + text: _(msg`Write a post`), + onPress: () => openComposer({}), + size: 'small', + color: 'primary', + }} /> ) : null} @@ -421,6 +449,8 @@ function ProfileScreenLoaded({ scrollElRef={scrollElRef as ListRef} ignoreFilterFor={profile.did} setScrollViewTag={setScrollViewTag} + emptyStateMessage={_(msg`No replies yet`)} + emptyStateIcon={MessageIcon} /> ) : null} @@ -434,6 +464,15 @@ function ProfileScreenLoaded({ scrollElRef={scrollElRef as ListRef} ignoreFilterFor={profile.did} setScrollViewTag={setScrollViewTag} + emptyStateMessage={_(msg`No media yet`)} + emptyStateButton={{ + label: _(msg`Post a photo`), + text: _(msg`Post a photo`), + onPress: () => openComposer({}), + size: 'small', + color: 'primary', + }} + emptyStateIcon={ImageIcon} /> ) : null} @@ -447,6 +486,15 @@ function ProfileScreenLoaded({ scrollElRef={scrollElRef as ListRef} ignoreFilterFor={profile.did} setScrollViewTag={setScrollViewTag} + emptyStateMessage={_(msg`No video posts yet`)} + emptyStateButton={{ + label: _(msg`Post a video`), + text: _(msg`Post a video`), + onPress: () => openComposer({}), + size: 'small', + color: 'primary', + }} + emptyStateIcon={VideoIcon} /> ) : null} @@ -460,6 +508,8 @@ function ProfileScreenLoaded({ scrollElRef={scrollElRef as ListRef} ignoreFilterFor={profile.did} setScrollViewTag={setScrollViewTag} + emptyStateMessage={_(msg`No likes yet`)} + emptyStateIcon={HeartIcon} /> ) : null} @@ -485,6 +535,17 @@ function ProfileScreenLoaded({ headerOffset={headerHeight} enabled={isFocused} setScrollViewTag={setScrollViewTag} + emptyStateMessage={_( + msg`Starter packs let you share your favorite feeds and people with your friends.`, + )} + emptyStateButton={{ + label: _(msg`Create a Starter Pack`), + text: _(msg`Create a Starter Pack`), + onPress: wrappedNavToWizard, + color: 'primary', + size: 'small', + }} + emptyStateIcon={CircleAndSquareIcon} /> ) : null} diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index f7eee5fee9..e8c972c667 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -3,12 +3,15 @@ import {View} from 'react-native' import {useNavigation} from '@react-navigation/native' import {type NavigationProp} from '#/lib/routes/types' -import {Sentry} from '#/logger/sentry/lib' import {useSetThemePrefs} from '#/state/shell' import {ListContained} from '#/view/screens/Storybook/ListContained' import {atoms as a, ThemeProvider} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Layout from '#/components/Layout' +import { + useDeviceGeolocationApi, + useRequestDeviceGeolocation, +} from '#/geolocation' import {Admonitions} from './Admonitions' import {Breakpoints} from './Breakpoints' import {Buttons} from './Buttons' @@ -45,6 +48,8 @@ function StorybookInner() { const {setColorMode, setDarkTheme} = useSetThemePrefs() const [showContainedList, setShowContainedList] = React.useState(false) const navigation = useNavigation() + const requestDeviceGeolocation = useRequestDeviceGeolocation() + const {setDeviceGeolocation} = useDeviceGeolocationApi() return ( <> @@ -97,11 +102,17 @@ function StorybookInner() { Open Shared Prefs Tester diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 0e0eb79e8d..779ebda681 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -28,11 +28,10 @@ import {useSession} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useShellLayout} from '#/state/shell/shell-layout' import {useCloseAllActiveElements} from '#/state/util' -import {Text} from '#/view/com/util/text/Text' import {UserAvatar} from '#/view/com/util/UserAvatar' import {Logo} from '#/view/icons/Logo' import {Logotype} from '#/view/icons/Logotype' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' @@ -50,6 +49,7 @@ import { Message_Stroke2_Corner0_Rounded as Message, Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, } from '#/components/icons/Message' +import {Text} from '#/components/Typography' import {useDemoMode} from '#/storage/hooks/demo-mode' import {styles} from './BottomBarStyles' @@ -396,6 +396,8 @@ function Btn({ accessibilityHint, accessibilityLabel, }: BtnProps) { + const t = useTheme() + return ( {icon} {notificationCount ? ( - - {notificationCount} + + 1 ) : hasNew ? ( diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index ad1cc48fd0..3c99eaf6f6 100644 --- a/src/view/shell/bottom-bar/BottomBarStyles.tsx +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -24,7 +24,6 @@ export const styles = StyleSheet.create({ position: 'absolute', left: '52%', top: 8, - backgroundColor: colors.blue3, paddingHorizontal: 4, paddingBottom: 1, borderRadius: 6, @@ -35,12 +34,6 @@ export const styles = StyleSheet.create({ paddingBottom: 3, borderRadius: 12, }, - notificationCountLight: { - borderColor: colors.white, - }, - notificationCountDark: { - borderColor: colors.gray8, - }, notificationCountLabel: { fontSize: 12, fontWeight: '600', diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index c884673cd5..61b32c6434 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -236,6 +236,7 @@ const NavItem: React.FC<{ hasNew?: boolean notificationCount?: string }> = ({children, href, routeName, hasNew, notificationCount}) => { + const t = useTheme() const {_} = useLingui() const {currentAccount} = useSession() const currentRoute = useNavigationState(state => { @@ -272,7 +273,11 @@ const NavItem: React.FC<{ {children({isActive})} {notificationCount ? ( - {geolocation?.isAgeBlockedGeo ? ( - + {aa.state.access === aa.Access.None ? ( + ) : ( )} + + ) } diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 4b8b47acd7..7fd5155ca3 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -8,7 +8,6 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar' import {useIntentHandler} from '#/lib/hooks/useIntentHandler' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type NavigationProp} from '#/lib/routes/types' -import {useGeolocationStatus} from '#/state/geolocation' import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' import {useCloseAllActiveElements} from '#/state/util' @@ -17,7 +16,6 @@ import {ModalsContainer} from '#/view/com/modals/Modal' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, select, useTheme} from '#/alf' import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' -import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay' import {EmailDialog} from '#/components/dialogs/EmailDialog' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' @@ -29,6 +27,9 @@ import { } from '#/components/PolicyUpdateOverlay' import {Outlet as PortalOutlet} from '#/components/Portal' import {WelcomeModal} from '#/components/WelcomeModal' +import {useAgeAssurance} from '#/ageAssurance' +import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' +import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' import {FlatNavigator, RoutesContainer} from '#/Navigation' import {Composer} from './Composer.web' import {DrawerContent} from './Drawer' @@ -139,16 +140,18 @@ function ShellInner() { export function Shell() { const t = useTheme() - const {status: geolocation} = useGeolocationStatus() + const aa = useAgeAssurance() return ( - {geolocation?.isAgeBlockedGeo ? ( - + {aa.state.access === aa.Access.None ? ( + ) : ( )} + + ) } diff --git a/web/index.html b/web/index.html index 2077530fe0..5458b57a36 100644 --- a/web/index.html +++ b/web/index.html @@ -73,11 +73,19 @@ width: 100%; } #splash { + display: flex; position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + align-items: center; + justify-content: center; + } + #splash svg { + position: relative; + top: -50px; width: 100px; - left: 50%; - top: 50%; - transform: translateX(-50%) translateY(-50%) translateY(-50px); } /** * We need these styles to prevent shifting due to scrollbar show/hide on @@ -146,7 +154,7 @@
- +
diff --git a/yarn.lock b/yarn.lock index 345b999698..6f1c16f8aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -84,15 +84,15 @@ tlds "^1.234.0" zod "^3.23.8" -"@atproto/api@^0.18.0": - version "0.18.0" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.0.tgz#d8c54ddc4521d915f0af238a4bfebd119e18197f" - integrity sha512-2GxKPhhvMocDjRU7VpNj+cvCdmCHVAmRwyfNgRLMrJtPZvrosFoi9VATX+7eKN0FZvYvy8KdLSkCcpP2owH3IA== +"@atproto/api@^0.18.4": + version "0.18.4" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.4.tgz#e6742f3b81acec2bcf63dd3787304166eb2891cb" + integrity sha512-+kSxto/GRFXRFFlGwfERrwEKnC6OqTgK34BUToer/Fv08q4WMR+GYPRabbWlnDoJWu3owcQfeYdcblQ88vi16g== dependencies: - "@atproto/common-web" "^0.4.3" - "@atproto/lexicon" "^0.5.1" - "@atproto/syntax" "^0.4.1" - "@atproto/xrpc" "^0.7.5" + "@atproto/common-web" "^0.4.6" + "@atproto/lexicon" "^0.5.2" + "@atproto/syntax" "^0.4.2" + "@atproto/xrpc" "^0.7.6" await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" @@ -192,6 +192,15 @@ uint8arrays "3.0.0" zod "^3.23.8" +"@atproto/common-web@^0.4.4", "@atproto/common-web@^0.4.6": + version "0.4.6" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.6.tgz#e32395d44d812610fd99f718b8644308b828d68b" + integrity sha512-+2mG/1oBcB/ZmYIU1ltrFMIiuy9aByKAkb2Fos/0eTdczcLBaH17k0KoxMGvhfsujN2r62XlanOAMzysa7lv1g== + dependencies: + "@atproto/lex-data" "0.0.2" + "@atproto/lex-json" "0.0.2" + zod "^3.23.8" + "@atproto/common@0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" @@ -301,6 +310,25 @@ multiformats "^9.9.0" zod "^3.23.8" +"@atproto/lex-data@0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.2.tgz#f90e7ac52dd6056199a84efc7a3c5196de7ceb63" + integrity sha512-euV2rDGi+coH8qvZOU+ieUOEbwPwff9ca6IiXIqjZJ76AvlIpj7vtAyIRCxHUW2BoU6h9yqyJgn9MKD2a7oIwg== + dependencies: + "@atproto/syntax" "0.4.2" + multiformats "^9.9.0" + tslib "^2.8.1" + uint8arrays "3.0.0" + unicode-segmenter "^0.14.0" + +"@atproto/lex-json@0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.2.tgz#c4d3b6a8e965898cbc80478ecd461ddd8ac38493" + integrity sha512-Pd72lO+l2rhOTutnf11omh9ZkoB/elbzE3HSmn2wuZlyH1mRhTYvoH8BOGokWQwbZkCE8LL3nOqMT3gHCD2l7g== + dependencies: + "@atproto/lex-data" "0.0.2" + tslib "^2.8.1" + "@atproto/lexicon-resolver@0.2.2", "@atproto/lexicon-resolver@^0.2.2": version "0.2.2" resolved "https://registry.yarnpkg.com/@atproto/lexicon-resolver/-/lexicon-resolver-0.2.2.tgz#2a91a1908f6b327c41cb5c290eb80aed5ef593c0" @@ -325,6 +353,17 @@ multiformats "^9.9.0" zod "^3.23.8" +"@atproto/lexicon@^0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.5.2.tgz#c2fb39b952644c9d88203850e0d61a26b39338ec" + integrity sha512-lRmJgMA8f5j7VB5Iu5cp188ald5FuI4FlmZ7nn6EBrk1dgOstWVrI5Ft6K3z2vjyLZRG6nzknlsw+tDP63p7bQ== + dependencies: + "@atproto/common-web" "^0.4.4" + "@atproto/syntax" "^0.4.1" + iso-datestring-validator "^2.2.2" + multiformats "^9.9.0" + zod "^3.23.8" + "@atproto/oauth-provider-api@0.3.1": version "0.3.1" resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.3.1.tgz#ade10e010d4b1c9cc8fc7afa3fa9e90d49ab05b9" @@ -516,6 +555,11 @@ resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.1.tgz#f77bc610ae0914449ff3f4731861e3da429915f5" integrity sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw== +"@atproto/syntax@0.4.2", "@atproto/syntax@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.2.tgz#a83ff62b82bf84308d78ad836c802bad6a52174a" + integrity sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA== + "@atproto/xrpc-server@^0.9.5": version "0.9.5" resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.9.5.tgz#3a036ce2db85bcac40103fd160fef3ed7c364e2b" @@ -542,6 +586,14 @@ "@atproto/lexicon" "^0.5.1" zod "^3.23.8" +"@atproto/xrpc@^0.7.6": + version "0.7.6" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.6.tgz#bc12b0e37f81fa76589691634d4fac9774fd0cb5" + integrity sha512-RvCf4j0JnKYWuz3QzsYCntJi3VuiAAybQsMIUw2wLWcHhchO9F7UaBZINLL2z0qc/cYWPv5NSwcVydMseoCZLA== + dependencies: + "@atproto/lexicon" "^0.5.2" + zod "^3.23.8" + "@aws-crypto/crc32@5.2.0": version "5.2.0" resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz#cfcc22570949c98c6689cfcbd2d693d36cdae2e1" @@ -7189,11 +7241,6 @@ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.25.0.tgz#e08ed0a9fad34c8005d1a282e57280031ac50cdc" integrity sha512-vlobHP64HTuSE68lWF1mEhwSRC5Q7gaT+a/m9S+ItuN+ruSOxe1rFnR9j0ACWQ314BPhBEVKfBQ6mHL0OWfdbQ== -"@tanstack/query-core@5.8.1": - version "5.8.1" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.8.1.tgz#5215a028370d9b2f32e83787a0ea119e2f977996" - integrity sha512-Y0enatz2zQXBAsd7XmajlCs+WaitdR7dIFkqz9Xd7HL4KV04JOigWVreYseTmNH7YFSBSC/BJ9uuNp1MAf+GfA== - "@tanstack/query-persist-client-core@5.25.0": version "5.25.0" resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.25.0.tgz#52fa634a8067d7b965854a532a33077fd4df0eff" @@ -7208,12 +7255,12 @@ dependencies: "@tanstack/query-persist-client-core" "5.25.0" -"@tanstack/react-query@^5.8.1": - version "5.8.1" - resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.8.1.tgz#22a122016e23a39acd90341954a895980ec21ade" - integrity sha512-YMagxS8iNPOLg0pK6WOjdSDlAvWKOf69udLOwQrBVmkC2SRLNLko7elo5Ro3ptlJkXvTVHidxC/h5KGi5bH1XQ== +"@tanstack/react-query@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.25.0.tgz#f4dac794cf10dd956aa56dbbdf67049a5ba2669d" + integrity sha512-u+n5R7mLO7RmeiIonpaCRVXNRWtZEef/aVZ/XGWRPa7trBIvGtzlfo0Ah7ZtnTYfrKEVwnZ/tzRCBcoiqJ/tFw== dependencies: - "@tanstack/query-core" "5.8.1" + "@tanstack/query-core" "5.25.0" "@testing-library/jest-native@^5.4.3": version "5.4.3" @@ -8579,11 +8626,6 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -at-least-node@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" - integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== - atomic-sleep@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" @@ -9368,6 +9410,11 @@ ci-info@^3.2.0, ci-info@^3.3.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== +ci-info@^3.7.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== + cjs-module-lexer@^1.0.0: version "1.2.3" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107" @@ -9770,17 +9817,6 @@ cross-fetch@^3.1.5: dependencies: node-fetch "^2.6.12" -cross-spawn@^6.0.5: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" - integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== - dependencies: - nice-try "^1.0.4" - path-key "^2.0.1" - semver "^5.5.0" - shebang-command "^1.2.0" - which "^1.2.9" - cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -11974,21 +12010,20 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-extra@^11.2.0: - version "11.3.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.0.tgz#0daced136bbaf65a555a326719af931adc7a314d" - integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew== +fs-extra@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== dependencies: graceful-fs "^4.2.0" jsonfile "^6.0.1" universalify "^2.0.0" -fs-extra@^9.0.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" - integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== +fs-extra@^11.2.0: + version "11.3.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.0.tgz#0daced136bbaf65a555a326719af931adc7a314d" + integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew== dependencies: - at-least-node "^1.0.0" graceful-fs "^4.2.0" jsonfile "^6.0.1" universalify "^2.0.0" @@ -12962,13 +12997,6 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-ci@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== - dependencies: - ci-info "^2.0.0" - is-ci@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867" @@ -14077,6 +14105,17 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +json-stable-stringify@^1.0.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz#8903cfac42ea1a0f97f35d63a4ce0518f0cc6a70" + integrity sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + isarray "^2.0.5" + jsonify "^0.0.1" + object-keys "^1.1.1" + json5@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" @@ -14098,6 +14137,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonify@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" + integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== + "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.2.1: version "3.3.5" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" @@ -15117,11 +15161,6 @@ nested-error-stacks@~2.0.1: resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz#d2cc9fc5235ddb371fc44d506234339c8e4b0a4b" integrity sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A== -nice-try@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" - integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== - no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -15680,25 +15719,25 @@ password-prompt@^1.0.4: ansi-escapes "^4.3.2" cross-spawn "^7.0.3" -patch-package@^6.5.1: - version "6.5.1" - resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-6.5.1.tgz#3e5d00c16997e6160291fee06a521c42ac99b621" - integrity sha512-I/4Zsalfhc6bphmJTlrLoOcAF87jcxko4q0qsv4bGcurbr8IskEOtdnt9iCmsQVGL1B+iUhSQqweyTLJfCF9rA== +patch-package@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.1.tgz#79d02f953f711e06d1f8949c8a13e5d3d7ba1a60" + integrity sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw== dependencies: "@yarnpkg/lockfile" "^1.1.0" chalk "^4.1.2" - cross-spawn "^6.0.5" + ci-info "^3.7.0" + cross-spawn "^7.0.3" find-yarn-workspace-root "^2.0.0" - fs-extra "^9.0.0" - is-ci "^2.0.0" + fs-extra "^10.0.0" + json-stable-stringify "^1.0.2" klaw-sync "^6.0.0" minimist "^1.2.6" open "^7.4.2" - rimraf "^2.6.3" - semver "^5.6.0" + semver "^7.5.3" slash "^2.0.0" - tmp "^0.0.33" - yaml "^1.10.2" + tmp "^0.2.4" + yaml "^2.2.2" path-exists@^3.0.0: version "3.0.0" @@ -15720,11 +15759,6 @@ path-is-inside@^1.0.2: resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w== -path-key@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" - integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw== - path-key@^3.0.0, path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" @@ -17785,11 +17819,6 @@ semver@7.6.3, semver@^7.1.3, semver@^7.6.3: resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== -semver@^5.5.0, semver@^5.6.0: - version "5.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" - integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== - semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" @@ -18008,13 +18037,6 @@ sharp@^0.33.5: "@img/sharp-win32-ia32" "0.33.5" "@img/sharp-win32-x64" "0.33.5" -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" - integrity sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg== - dependencies: - shebang-regex "^1.0.0" - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -18022,11 +18044,6 @@ shebang-command@^2.0.0: dependencies: shebang-regex "^3.0.0" -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - integrity sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ== - shebang-regex@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" @@ -18953,6 +18970,11 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" +tmp@^0.2.4: + version "0.2.5" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.5.tgz#b06bcd23f0f3c8357b426891726d16015abfd8f8" + integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -19066,7 +19088,7 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@2, tslib@^2.6.2: +tslib@2, tslib@^2.6.2, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -19348,6 +19370,11 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== +unicode-segmenter@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/unicode-segmenter/-/unicode-segmenter-0.14.0.tgz#090128182bcc710327a1b7e4af4f5834444eaa61" + integrity sha512-AH4lhPCJANUnSLEKnM4byboctePJzltF4xj8b+NbNiYeAkAXGh7px2K/4NANFp7dnr6+zB3e6HLu8Jj8SKyvYg== + unimodules-app-loader@~6.0.7: version "6.0.7" resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-6.0.7.tgz#d88db74075815bcdc088c6c6823a2b08394a1225" @@ -19947,13 +19974,6 @@ which-typed-array@^1.1.9: has-tostringtag "^1.0.0" is-typed-array "^1.1.10" -which@^1.2.9: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - dependencies: - isexe "^2.0.0" - which@^2.0.1, which@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -20142,7 +20162,7 @@ yaml@^1.10.0, yaml@^1.10.2: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== -yaml@^2.6.1: +yaml@^2.2.2, yaml@^2.6.1: version "2.8.1" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.1.tgz#1870aa02b631f7e8328b93f8bc574fac5d6c4d79" integrity sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==