Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4f0a487e7 | |||
| 3dee9e75f9 | |||
| c4aef9f668 | |||
| 7735183af4 | |||
| f0c9e1ea5e | |||
| 4d1b1bb2fd | |||
| 507531e1db | |||
| 0ba4edab8d | |||
| 1eacf427b5 | |||
| d0649e9a9e | |||
| 2d056e5be6 | |||
| 7f2d46b731 | |||
| 6ab3751b5c | |||
| 8424083de9 | |||
| 54af170448 | |||
| 8b00b8f945 | |||
| 9136be68b3 | |||
| af13c70444 | |||
| 9330e21c6a | |||
| 5d220e49a0 | |||
| 31fac4a62b | |||
| 13c4ef83f1 | |||
| dfe9f9fa07 | |||
| 2919a7da7f | |||
| 2792b4cef0 | |||
| f5a5eee32c | |||
| 9823bc98e9 | |||
| f57268e9b8 | |||
| 002a63b125 | |||
| 2faf62c7f7 | |||
| 7b99d8c7c3 | |||
| c2979ffefa | |||
| bc62b3da76 | |||
| 53b3bd93a5 | |||
| 7197a064d7 | |||
| ef97a20c23 | |||
| f53d5b6f27 | |||
| 084f60c88f | |||
| 2f678a3ccd | |||
| d464cde412 | |||
| a35a81a508 | |||
| 4145878819 | |||
| fb058afa54 | |||
| 185fd39092 | |||
| 92926a2417 | |||
| 7a8ab551e3 | |||
| 1caac024ab | |||
| 24c96ac881 | |||
| c92ac4abc1 | |||
| b422765aed | |||
| b56ee74cdb | |||
| 554275957e | |||
| 4884677826 | |||
| e663e824b7 | |||
| 68a4d84450 | |||
| 9982961b8f | |||
| 1711626493 | |||
| cb2af3cd18 | |||
| 59ffaa8a81 | |||
| a275e8999e | |||
| 8d2d45508d | |||
| ddf82dcc44 | |||
| 835a716eaf | |||
| bb365856c9 | |||
| bcd0e40192 | |||
| 5208aa4a65 | |||
| 1cdb105af5 | |||
| ad91029f10 | |||
| 4791486aa7 | |||
| df00ffa532 | |||
| 70ccd82745 | |||
| 14604f735b | |||
| 9583c310b9 | |||
| d6ca9e6b41 | |||
| fd14bbfef9 | |||
| 9f98e53541 | |||
| 7bf00424c4 | |||
| ec2c580aae | |||
| 215f97bad9 | |||
| 531ff6c433 |
@@ -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=
|
||||
|
||||
@@ -43,6 +43,7 @@ module.exports = {
|
||||
suggestedTextWrappers: {
|
||||
Button: 'ButtonText',
|
||||
'ToggleButton.Button': 'ToggleButton.ButtonText',
|
||||
'SegmentedControl.Item': 'SegmentedControl.ItemText',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
build:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build and Submit Android
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: Linux-x64-32core
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,9 +31,15 @@ appId: xyz.blueskyweb.app
|
||||
text: "Your account"
|
||||
- tapOn:
|
||||
id: "nextBtn"
|
||||
- waitForAnimationToEnd
|
||||
- tapOn:
|
||||
text: "Not Now"
|
||||
optional: true
|
||||
# save password thing no longer seems to be in hierarchy
|
||||
# tap on something else instead
|
||||
point: 20%,20%
|
||||
repeat: 2
|
||||
delay: 1000
|
||||
- tapOn:
|
||||
id: "handleInput"
|
||||
- inputText: "e2e-test"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
|
||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
@@ -11,7 +11,7 @@ appId: xyz.blueskyweb.app
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Feeds ✨"
|
||||
text: "Feeds ✨"
|
||||
- tapOn:
|
||||
label: "Can go to feeds page using feeds button in tab bar"
|
||||
text: "Feeds ✨"
|
||||
@@ -50,6 +50,6 @@ appId: xyz.blueskyweb.app
|
||||
label: "Can delete posts"
|
||||
id: "postDropdownBtn"
|
||||
childOf:
|
||||
id: "feedItem-by-alice.test"
|
||||
id: "feedItem-by-alice.test"
|
||||
- tapOn: "Delete post"
|
||||
- tapOn: "Delete"
|
||||
|
||||
@@ -17,7 +17,8 @@ appId: xyz.blueskyweb.app
|
||||
index: 0
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn: "Create report for Misleading Post"
|
||||
- tapOn: "Create report for Misleading"
|
||||
- tapOn: "Create report for Spam"
|
||||
- tapOn: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
point: "50%,90%"
|
||||
|
||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
@@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "profileHeaderDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
id: "report:category:Misleading"
|
||||
- tapOn:
|
||||
id: "report:option:Spam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
text: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
|
||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
@@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
id: "report:category:Misleading"
|
||||
- tapOn:
|
||||
id: "report:option:Spam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
text: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
|
||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
@@ -15,20 +15,28 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonOther"
|
||||
id: "report:category:Misleading"
|
||||
- tapOn:
|
||||
id: "report:option:Other misleading content"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
text: Your report will be sent to Dev-env Moderation.*
|
||||
# reason "other" defaults with details open
|
||||
- assertVisible:
|
||||
id: "report:details"
|
||||
- tapOn:
|
||||
id: "report:clearOption"
|
||||
id: "report:clearReportOption"
|
||||
- assertNotVisible:
|
||||
id: "report:details"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
id: "report:clearCategory"
|
||||
- assertNotVisible:
|
||||
id: "report:option:Other misleading content"
|
||||
- tapOn:
|
||||
id: "report:category:Misleading"
|
||||
- tapOn:
|
||||
id: "report:option:Spam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
text: Your report will be sent to Dev-env Moderation.*
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
|
||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
@@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonOther"
|
||||
id: "report:category:Misleading"
|
||||
- tapOn:
|
||||
id: "report:option:Other misleading content"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
text: "Send report to Dev-env Moderation"
|
||||
# reason "other" defaults with details open
|
||||
- assertVisible:
|
||||
id: "report:details"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
# flow.yaml
|
||||
|
||||
appId: xyz.blueskyweb.app
|
||||
@@ -33,7 +32,7 @@ appId: xyz.blueskyweb.app
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
- swipe:
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
direction: "RIGHT"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 38"><path stroke="#405168" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22.333 31.667H45M22.333 6.333H45m-33.333 0A5.333 5.333 0 1 1 1 6.333a5.333 5.333 0 0 1 10.667 0Zm0 25.334a5.333 5.333 0 1 1-10.667 0 5.333 5.333 0 0 1 10.667 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 333 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 62 53"><path fill="#405168" d="M28.173.231a5.653 5.653 0 0 1 7.018 3.83l2.66 9.046a20 20 0 0 1 3.986-.397c11.026 0 19.964 8.937 19.964 19.962l-.006.516c-.274 10.787-9.104 19.448-19.958 19.448l-.514-.007c-8.332-.21-15.394-5.528-18.178-12.938l-8.805 2.59a5.654 5.654 0 0 1-7.02-3.83L.232 14.34a5.654 5.654 0 0 1 3.83-7.018L28.172.23ZM41.838 14.71c-1.17 0-2.313.111-3.42.325l3.863 13.137a5.653 5.653 0 0 1-3.83 7.019L25.07 39.126c2.593 6.732 9.122 11.51 16.768 11.51 9.92 0 17.963-8.043 17.963-17.964S51.758 14.71 41.837 14.71ZM33.271 4.624a3.653 3.653 0 0 0-4.535-2.474L4.624 9.24a3.653 3.653 0 0 0-2.475 4.535l7.09 24.113a3.654 3.654 0 0 0 4.536 2.475l8.762-2.577a20 20 0 0 1-.662-5.114c0-8.961 5.905-16.544 14.037-19.069l-2.64-8.98Zm3.204 10.899c-7.302 2.28-12.601 9.096-12.601 17.15 0 1.571.203 3.095.582 4.548l13.431-3.948a3.654 3.654 0 0 0 2.474-4.536l-3.886-13.214Z"/></svg>
|
||||
|
After Width: | Height: | Size: 944 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48"><path fill="#405168" d="M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#405168" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.333 1 9 45M37 1l-5.333 44M1 11.667h44m0 22.666H1"/></svg>
|
||||
|
After Width: | Height: | Size: 224 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 50 45"><path stroke="#405168" stroke-linejoin="round" stroke-width="2" d="M49 17c0 15.333-22 26.667-24 26.667S1 32.333 1 17C1 6.333 7.667 1 14.333 1S25 5 25 5s4-4 10.667-4S49 6.333 49 17Z"/></svg>
|
||||
|
After Width: | Height: | Size: 262 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#080B12" stroke-linecap="round" stroke-width="1.5" d="m1.417 28.645 7.586-5.676a5.33 5.33 0 0 1 6.867.809c3.98 4.286 8.594 8.182 14.88 8.182 5.794 0 9.633-2.147 13.333-5.847m-38 18.637h33.334a5.333 5.333 0 0 0 5.333-5.333V6.083A5.333 5.333 0 0 0 39.417.75H6.083A5.333 5.333 0 0 0 .75 6.083v33.334a5.333 5.333 0 0 0 5.333 5.333ZM36.75 14.083a5.333 5.333 0 1 1-10.667 0 5.333 5.333 0 0 1 10.667 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 491 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 50 50"><path stroke="#405168" stroke-linecap="square" stroke-linejoin="round" stroke-width="2" d="M9 1h32a8 8 0 0 1 8 8v21.333a8 8 0 0 1-8 8H27.667L14.333 49V38.333H9a8 8 0 0 1-8-8V9a8 8 0 0 1 8-8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 272 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 43 48"><path stroke="#405168" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.603 46.333H3.532c-1.572 0-2.816-1.358-2.472-2.891 2.033-9.046 9.421-15.775 19.543-15.775q1.367 0 2.666.16m18.667 7.84L36.603 41m0 0-5.334 5.333M36.603 41l-5.334-5.333M36.603 41l5.333 5.333m-12-36A9.333 9.333 0 1 1 20.603 1a9.333 9.333 0 0 1 9.333 9.333Z"/></svg>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M10.928 18.882a1.95 1.95 0 0 0 2.452-.25l9-9a1.953 1.953 0 0 0-2.76-2.76L12 14.493l-7.62-7.62a1.952 1.952 0 0 0-2.76 2.76l9 9 .308.25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#405168" stroke-linecap="square" stroke-linejoin="round" stroke-width="2" d="M1 23h10.667M1 23V12m0 11v11m10.667-11h22.666m-22.666 0v11m0-11V12m22.666 11H45m-10.667 0v12.222m0-12.222V12M45 23V12m0 11v12.222M34.333 45h5.334A5.333 5.333 0 0 0 45 39.667v-4.445M34.333 45v-9.778m0 9.778H11.667M34.333 1h5.334A5.333 5.333 0 0 1 45 6.333V12M34.333 1v11m0-11H11.667m22.666 11H45M34.333 35.222H45M11.667 45H6.333A5.333 5.333 0 0 1 1 39.667V34m10.667 11V34m0-33H6.333A5.333 5.333 0 0 0 1 6.333V12M11.667 1v11M1 12h10.667M1 34h10.667"/></svg>
|
||||
|
After Width: | Height: | Size: 619 B |
@@ -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 {
|
||||
|
||||
@@ -223,7 +223,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
|
||||
}
|
||||
|
||||
e.GET("/iframe/youtube.html", echo.WrapHandler(staticHandler))
|
||||
e.GET("/iframe/*", echo.WrapHandler(staticHandler))
|
||||
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Response().Before(func() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,40 +16,4 @@
|
||||
}
|
||||
</style>
|
||||
<div class="container"><div class="video" id="player"></div></div>
|
||||
<script>
|
||||
const url = new URL(window.location)
|
||||
const viewport = document.querySelector("meta[name=viewport]")
|
||||
|
||||
const tag = document.createElement("script")
|
||||
tag.src = "https://www.youtube.com/iframe_api"
|
||||
const firstScriptTag = document.getElementsByTagName('script')[0];
|
||||
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
|
||||
|
||||
let player
|
||||
function onYouTubeIframeAPIReady() {
|
||||
let videoId = url.searchParams.get('videoId')
|
||||
videoId = decodeURIComponent(videoId)
|
||||
videoId = videoId.replace(/[^a-zA-Z0-9_-]/g, "")
|
||||
if (videoId.length !== 11) throw new Error("Invalid video ID")
|
||||
|
||||
let start = url.searchParams.get('start')
|
||||
start = start.replace(/[^0-9]/g, "")
|
||||
|
||||
player = new YT.Player('player', {
|
||||
width: "1000",
|
||||
height: "1000",
|
||||
videoId,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
start,
|
||||
rel: 0,
|
||||
loop: 0,
|
||||
playsinline: 1,
|
||||
origin: url.origin
|
||||
},
|
||||
});
|
||||
}
|
||||
function onPlayerReady(event) {
|
||||
event.target.playVideo();
|
||||
}
|
||||
</script>
|
||||
<script src="youtube.js"></script>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const url = new URL(window.location)
|
||||
const viewport = document.querySelector("meta[name=viewport]")
|
||||
|
||||
const tag = document.createElement("script")
|
||||
tag.src = "https://www.youtube.com/iframe_api"
|
||||
const firstScriptTag = document.getElementsByTagName('script')[0];
|
||||
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
|
||||
|
||||
let player
|
||||
function onYouTubeIframeAPIReady() {
|
||||
let videoId = url.searchParams.get('videoId')
|
||||
videoId = decodeURIComponent(videoId)
|
||||
videoId = videoId.replace(/[^a-zA-Z0-9_-]/g, "")
|
||||
if (videoId.length !== 11) throw new Error("Invalid video ID")
|
||||
|
||||
let start = url.searchParams.get('start')
|
||||
start = start.replace(/[^0-9]/g, "")
|
||||
|
||||
player = new YT.Player('player', {
|
||||
width: "1000",
|
||||
height: "1000",
|
||||
videoId,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
start,
|
||||
rel: 0,
|
||||
loop: 0,
|
||||
playsinline: 1,
|
||||
origin: url.origin
|
||||
},
|
||||
});
|
||||
}
|
||||
function onPlayerReady(event) {
|
||||
event.target.playVideo();
|
||||
}
|
||||
@@ -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 @@
|
||||
<div id="root">
|
||||
<div id="splash">
|
||||
<!-- Bluesky SVG -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 320"><path fill="#0085ff" d="M180 142c-16.3-31.7-60.7-90.8-102-120C38.5-5.9 23.4-1 13.5 3.4 2.1 8.6 0 26.2 0 36.5c0 10.4 5.7 84.8 9.4 97.2 12.2 41 55.7 55 95.7 50.5-58.7 8.6-110.8 30-42.4 106.1 75.1 77.9 103-16.7 117.3-64.6 14.3 48 30.8 139 116 64.6 64-64.6 17.6-97.5-41.1-106.1 40 4.4 83.5-9.5 95.7-50.5 3.7-12.4 9.4-86.8 9.4-97.2 0-10.3-2-27.9-13.5-33C336.5-1 321.5-6 282 22c-41.3 29.2-85.7 88.3-102 120Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -243,13 +243,18 @@ class BottomSheetView(
|
||||
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
bottomSheet?.let {
|
||||
val behavior = BottomSheetBehavior.from(it)
|
||||
val currentState = behavior.state
|
||||
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
val oldRatio = behavior.halfExpandedRatio
|
||||
var newRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.halfExpandedRatio = newRatio
|
||||
|
||||
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {BottomSheet} from './src/BottomSheet'
|
||||
import {
|
||||
BottomSheetSnapPoint,
|
||||
BottomSheetState,
|
||||
BottomSheetViewProps,
|
||||
type BottomSheetState,
|
||||
type BottomSheetViewProps,
|
||||
} from './src/BottomSheet.types'
|
||||
import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent'
|
||||
import {
|
||||
|
||||
@@ -112,9 +112,21 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
onStateChange={this.onStateChange}
|
||||
extraStyles={extraStyles}
|
||||
onLayout={e => {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
this.updateLayout()
|
||||
if (isIOS15) {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
}
|
||||
if (Platform.OS === 'android') {
|
||||
// TEMP HACKFIX: I had to timebox this, but this is Bad.
|
||||
// On Android, if you run updateLayout() immediately,
|
||||
// it will take ages to actually run on the native side.
|
||||
// However, adding literally any delay will fix this, including
|
||||
// a console.log() - just sending the log to the CLI is enough.
|
||||
// TODO: Get to the bottom of this and fix it properly! -sfn
|
||||
setTimeout(() => this.updateLayout())
|
||||
} else {
|
||||
this.updateLayout()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
|
||||
@@ -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<Uri>) {
|
||||
private fun handleImageIntents(
|
||||
uris: List<Uri>,
|
||||
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<Uri>) {
|
||||
private fun handleVideoIntents(
|
||||
uris: List<Uri>,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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.17.6",
|
||||
"@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",
|
||||
@@ -143,7 +143,7 @@
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-crop-tool": "^0.1.8",
|
||||
"expo-image-crop-tool": "^0.4.0",
|
||||
"expo-image-manipulator": "~14.0.7",
|
||||
"expo-image-picker": "~17.0.8",
|
||||
"expo-intent-launcher": "~13.0.7",
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <RCTCustomPullToRefreshViewProtocol>
|
||||
|
||||
+- (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<RCTComponentViewProtocol> 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
|
||||
@@ -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 <RCTCustomPullToRefreshViewProtocol>
|
||||
|
||||
+- (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
|
||||
@@ -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<RCTComponentViewProtocol> RCTScrollViewCls(void)
|
||||
@@ -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() {
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
@@ -186,7 +185,7 @@ function InnerApp() {
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
@@ -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 (
|
||||
<GeolocationProvider>
|
||||
<Geo.Provider>
|
||||
<A11yProvider>
|
||||
<KeyboardControllerProvider>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<StarterPackProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</StarterPackProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
<OnboardingProvider>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<StarterPackProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</StarterPackProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</OnboardingProvider>
|
||||
</KeyboardControllerProvider>
|
||||
</A11yProvider>
|
||||
</GeolocationProvider>
|
||||
</Geo.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <Splash isReady />
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
@@ -118,7 +118,7 @@ function InnerApp() {
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
@@ -157,7 +157,7 @@ function InnerApp() {
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
@@ -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 <Splash isReady />
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -189,29 +188,31 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<GeolocationProvider>
|
||||
<Geo.Provider>
|
||||
<A11yProvider>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
<OnboardingProvider>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</OnboardingProvider>
|
||||
</A11yProvider>
|
||||
</GeolocationProvider>
|
||||
</Geo.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<View style={[a.fixed, a.inset_0, a.align_center, a.justify_center]}>
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 64 57"
|
||||
style={[a.relative, {width: size, height: size * ratio, top: -50}]}>
|
||||
<Path
|
||||
fill="#006AFF"
|
||||
d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const prefetchAgeAssuranceData = () => {}
|
||||
export const setBirthdateForDid = () => {}
|
||||
export const setCreatedAtForDid = () => {}
|
||||
@@ -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 ? (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, you can update it by{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
style={[textStyles]}
|
||||
{...createStaticClick(() => {
|
||||
birthdateControl.open()
|
||||
})}>
|
||||
clicking here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, please{' '}
|
||||
<SimpleInlineLinkText
|
||||
to={createSupportLink({code: SupportCode.AA_BIRTHDATE})}
|
||||
label={_(msg`Click here to contact our support team`)}
|
||||
style={[textStyles]}>
|
||||
contact our support team
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.px_2xl,
|
||||
{
|
||||
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.w_full,
|
||||
web({
|
||||
maxWidth: 380,
|
||||
paddingTop: gtPhone ? '8vh' : undefined,
|
||||
}),
|
||||
{
|
||||
gap: 32,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.align_start]}>
|
||||
<AgeAssuranceBadge />
|
||||
</View>
|
||||
|
||||
{hasDeclaredAge ? (
|
||||
<>
|
||||
{isAARegion ? (
|
||||
<>
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
You are accessing Bluesky from a region that legally
|
||||
requires us to verify your age before allowing you to
|
||||
access the app.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{!isBlocked && birthdateUpdateText}
|
||||
</View>
|
||||
|
||||
<AccessSection />
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, the birthdate you have saved to your
|
||||
profile makes you too young to access Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{birthdateUpdateText}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
It looks like you haven't added your birthdate. You must
|
||||
provide an accurate date of birth to use Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
onPress={() => birthdateControl.open()}>
|
||||
<ButtonText>
|
||||
<Trans>Add your birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.pt_lg, a.gap_xl]}>
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
To log out,{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to log out`)}
|
||||
{...createStaticClick(() => {
|
||||
onPressLogout()
|
||||
})}>
|
||||
click here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<BirthDateSettingsDialog control={birthdateControl} />
|
||||
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<BottomSheetOutlet />
|
||||
<PortalOutlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<AgeAssuranceInitDialog control={control} />
|
||||
<AgeAssuranceAppealDialog control={appealControl} />
|
||||
|
||||
<View style={[a.gap_xl]}>
|
||||
{isBlocked ? (
|
||||
<Admonition type="warning">
|
||||
<Trans>
|
||||
You are currently unable to access Bluesky's Age Assurance flow.
|
||||
Please{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Contact our moderation team`)}
|
||||
{...createStaticClick(() => {
|
||||
appealControl.open()
|
||||
logger.metric('ageAssurance:appealDialogOpen', {})
|
||||
})}>
|
||||
contact our moderation team
|
||||
</SimpleInlineLinkText>{' '}
|
||||
if you believe this is an error.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
) : (
|
||||
<>
|
||||
<View style={[a.gap_md]}>
|
||||
<Button
|
||||
label={_(msg`Verify now`)}
|
||||
size="large"
|
||||
color={hasInitiated ? 'secondary' : 'primary'}
|
||||
onPress={() => {
|
||||
control.open()
|
||||
logger.metric('ageAssurance:initDialogOpen', {
|
||||
hasInitiatedPreviously: hasInitiated,
|
||||
})
|
||||
}}>
|
||||
<ButtonIcon icon={ShieldIcon} />
|
||||
<ButtonText>
|
||||
{hasInitiated ? (
|
||||
<Trans>Verify again</Trans>
|
||||
) : (
|
||||
<Trans>Verify now</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{lastInitiatedAt && timeAgo && diff ? (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
||||
title={i18n.date(lastInitiatedAt, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
})}>
|
||||
{diff.value === 0 ? (
|
||||
<Trans>Last initiated just now</Trans>
|
||||
) : (
|
||||
<Trans>Last initiated {timeAgo} ago</Trans>
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Age assurance only takes a few minutes</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[a.gap_xs]}>
|
||||
{isNative && (
|
||||
<>
|
||||
<Admonition>
|
||||
<Trans>
|
||||
Is your location not accurate?{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Confirm your location`)}
|
||||
{...createStaticClick(() => {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to confirm your location.
|
||||
</SimpleInlineLinkText>{' '}
|
||||
</Trans>
|
||||
</Admonition>
|
||||
|
||||
<DeviceLocationRequestDialog
|
||||
control={locationControl}
|
||||
onLocationAcquired={props => {
|
||||
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',
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<RedirectOverlayState | null>(() => {
|
||||
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 (
|
||||
<Context.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
isOpen: state !== null,
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[state, open, close],
|
||||
)}>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function RedirectOverlay() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {isOpen} = useRedirectOverlayContext()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
return isOpen ? (
|
||||
<FullWindowOverlay>
|
||||
<View
|
||||
style={[
|
||||
a.fixed,
|
||||
a.inset_0,
|
||||
// setting a zIndex when using FullWindowOverlay on iOS
|
||||
// means the taps pass straight through to the underlying content (???)
|
||||
// so don't set it on iOS. FullWindowOverlay already does the job.
|
||||
!isIOS && {zIndex: 9999},
|
||||
t.atoms.bg,
|
||||
gtMobile ? a.p_2xl : a.p_xl,
|
||||
a.align_center,
|
||||
// @ts-ignore
|
||||
platform({
|
||||
web: {
|
||||
paddingTop: '35vh',
|
||||
},
|
||||
default: {
|
||||
paddingTop: Dimensions.get('window').height * 0.35,
|
||||
},
|
||||
}),
|
||||
]}>
|
||||
<View
|
||||
role="dialog"
|
||||
aria-role="dialog"
|
||||
aria-label={_(msg`Verifying your age assurance status`)}>
|
||||
<View style={[a.pb_3xl, {width: 300}]}>
|
||||
<Inner />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</FullWindowOverlay>
|
||||
) : 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 (
|
||||
<>
|
||||
<View style={[a.align_start, a.w_full]}>
|
||||
<AgeAssuranceBadge />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pt_lg,
|
||||
a.pb_md,
|
||||
]}>
|
||||
<SuccessIcon size="sm" fill={t.palette.positive_500} />
|
||||
<Text style={[a.text_3xl, a.font_bold]}>
|
||||
<Trans>Success</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
We've confirmed your age assurance status. You can now close this
|
||||
dialog.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.w_full, a.pt_lg]}>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[a.align_start, a.w_full]}>
|
||||
<AgeAssuranceBadge />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pt_lg,
|
||||
a.pb_md,
|
||||
]}>
|
||||
{error && <ErrorIcon size="lg" fill={t.palette.negative_500} />}
|
||||
|
||||
<Text style={[a.text_3xl, a.font_bold]}>
|
||||
{error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
|
||||
</Text>
|
||||
|
||||
{!error && <Loader size="lg" />}
|
||||
</View>
|
||||
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{error ? (
|
||||
<Trans>
|
||||
We were unable to receive the verification due to a connection
|
||||
issue. It may arrive later. If it does, your account will update
|
||||
automatically.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
We're confirming your age assurance status with our servers. This
|
||||
should only take a few seconds.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<View style={[a.w_full, a.pt_lg]}>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string>()
|
||||
export function setCreatedAtForDid({
|
||||
did,
|
||||
createdAt,
|
||||
}: {
|
||||
did: string
|
||||
createdAt: string
|
||||
}) {
|
||||
createdAtCache.set(did, createdAt)
|
||||
}
|
||||
const birthdateCache = new Map<string, string>()
|
||||
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<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
||||
configQueryKey,
|
||||
)
|
||||
}
|
||||
let configPrefetchPromise: Promise<void> | 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<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
||||
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<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||
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<AppBskyAgeassuranceGetState.OutputSchema>(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<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||
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<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||
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<OtherRequiredData> {
|
||||
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<OtherRequiredData>(
|
||||
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<OtherRequiredData>(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<OtherRequiredData>(
|
||||
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<AgeAssuranceData>({
|
||||
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 (
|
||||
<AgeAssuranceDataContext.Provider value={ctx}>
|
||||
{children}
|
||||
</AgeAssuranceDataContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -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<T>(data: T) {
|
||||
await new Promise(y => setTimeout(y, 2000)) // simulate network
|
||||
return data
|
||||
}
|
||||
@@ -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 (
|
||||
<AgeAssuranceDataProvider>
|
||||
<InnerProvider>
|
||||
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
|
||||
</InnerProvider>
|
||||
</AgeAssuranceDataProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<AgeAssuranceStateContext.Provider
|
||||
value={useMemo(() => {
|
||||
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}
|
||||
</AgeAssuranceStateContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -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<AgeAssuranceAccess | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (prevAccess !== state.access) {
|
||||
setPrevAccess(state.access)
|
||||
cb(state)
|
||||
logger.debug(`useOnAgeAssuranceAccessUpdate`, {state})
|
||||
}
|
||||
}, [cb, state, prevAccess])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -25,7 +25,8 @@ export function normalizeTextStyles(
|
||||
fontFamily: Alf['fonts']['family']
|
||||
} & Pick<Alf, 'flags'>,
|
||||
) {
|
||||
const s = flatten(styles)
|
||||
const s = flatten(styles) ?? {}
|
||||
|
||||
// should always be defined on these components
|
||||
s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale
|
||||
|
||||
|
||||
@@ -5,18 +5,20 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {isJwtExpired} from '#/lib/jwt'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
|
||||
import {Button} from '#/components/Button'
|
||||
import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {Button} from './Button'
|
||||
import {Text} from './Typography'
|
||||
|
||||
export function AccountList({
|
||||
onSelectAccount,
|
||||
@@ -44,9 +46,9 @@ export function AccountList({
|
||||
<View
|
||||
pointerEvents={pendingDid ? 'none' : 'auto'}
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.rounded_lg,
|
||||
a.overflow_hidden,
|
||||
{borderWidth: 1},
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{accounts.map(account => (
|
||||
@@ -58,7 +60,7 @@ export function AccountList({
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
isPendingAccount={account.did === pendingDid}
|
||||
/>
|
||||
<View style={[{borderBottomWidth: 1}, t.atoms.border_contrast_low]} />
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Button
|
||||
@@ -72,22 +74,25 @@ export function AccountList({
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
a.p_lg,
|
||||
a.gap_sm,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
<View
|
||||
style={[
|
||||
a.font_semi_bold,
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
{paddingLeft: 56},
|
||||
t.atoms.bg_contrast_25,
|
||||
a.rounded_full,
|
||||
{width: 48, height: 48},
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<PlusIcon style={[t.atoms.text_contrast_low]} size="md" />
|
||||
</View>
|
||||
<Text style={[a.flex_1, a.leading_tight, a.text_md, a.font_medium]}>
|
||||
{otherLabel ?? <Trans>Other account</Trans>}
|
||||
</Text>
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
<ChevronIcon size="md" style={[t.atoms.text_contrast_low]} />
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
@@ -117,6 +122,8 @@ function AccountItem({
|
||||
onSelect(account)
|
||||
}, [account, onSelect])
|
||||
|
||||
const isLoggedOut = !account.refreshJwt || isJwtExpired(account.refreshJwt)
|
||||
|
||||
return (
|
||||
<Button
|
||||
testID={`chooseAccountBtn-${account.handle}`}
|
||||
@@ -134,14 +141,13 @@ function AccountItem({
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.px_md,
|
||||
a.p_lg,
|
||||
a.gap_sm,
|
||||
{height: 56},
|
||||
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={36}
|
||||
size={48}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
hideLiveBadge
|
||||
@@ -151,7 +157,7 @@ function AccountItem({
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.font_semi_bold, a.leading_tight]}
|
||||
style={[a.font_medium, a.leading_tight, a.text_md]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
profile?.displayName || profile?.handle || account.handle,
|
||||
@@ -166,15 +172,43 @@ function AccountItem({
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[a.leading_tight, t.atoms.text_contrast_medium]}>
|
||||
<Text
|
||||
style={[
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
]}>
|
||||
{sanitizeHandle(account.handle, '@')}
|
||||
</Text>
|
||||
{isLoggedOut && (
|
||||
<Text
|
||||
style={[
|
||||
a.leading_tight,
|
||||
a.text_xs,
|
||||
a.italic,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Logged out</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isCurrentAccount ? (
|
||||
<Check size="sm" style={[{color: t.palette.positive_500}]} />
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width: 20,
|
||||
height: 20,
|
||||
backgroundColor: t.palette.positive_500,
|
||||
},
|
||||
a.rounded_full,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
]}>
|
||||
<CheckIcon size="xs" style={[{color: t.palette.white}]} />
|
||||
</View>
|
||||
) : (
|
||||
<Chevron size="sm" style={[t.atoms.text]} />
|
||||
<ChevronIcon size="md" style={[t.atoms.text_contrast_low]} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -48,6 +48,7 @@ export function AppLanguageDropdown() {
|
||||
})}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="rectangular"
|
||||
style={[
|
||||
a.pr_xs,
|
||||
a.pl_sm,
|
||||
|
||||
@@ -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{' '}
|
||||
<InlineLinkText {...links.blog}>blog post</InlineLinkText>.
|
||||
</>,
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.px_2xl,
|
||||
{
|
||||
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
web({
|
||||
maxWidth: 380,
|
||||
paddingTop: gtPhone ? '8vh' : undefined,
|
||||
}),
|
||||
]}>
|
||||
<View style={[a.align_start]}>
|
||||
<View
|
||||
style={[
|
||||
a.pl_md,
|
||||
a.pr_lg,
|
||||
a.py_sm,
|
||||
a.rounded_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
{
|
||||
backgroundColor: t.palette.primary_25,
|
||||
},
|
||||
]}>
|
||||
<Mark fill={t.palette.primary_600} width={14} />
|
||||
<Text
|
||||
style={[
|
||||
a.font_semi_bold,
|
||||
{
|
||||
color: t.palette.primary_600,
|
||||
},
|
||||
]}>
|
||||
<Trans>Announcement</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_lg, {paddingTop: 32}]}>
|
||||
{blocks.map((block, index) => (
|
||||
<Text key={index} style={[textStyles]}>
|
||||
{block}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!isWeb && (
|
||||
<>
|
||||
<View style={[a.pt_2xl]}>
|
||||
<Divider />
|
||||
</View>
|
||||
|
||||
<View style={[a.mt_xl, a.align_start]}>
|
||||
<Text style={[a.text_lg, a.font_bold, a.leading_snug, a.pb_xs]}>
|
||||
<Trans>Not in Mississippi?</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pb_md,
|
||||
]}>
|
||||
<Trans>
|
||||
Confirm your location with GPS. Your location data is not
|
||||
tracked and does not leave your device.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Confirm your location`)}
|
||||
onPress={() => geoDialog.open()}
|
||||
size="small"
|
||||
color="primary_subtle">
|
||||
<ButtonIcon icon={LocationIcon} />
|
||||
<ButtonText>
|
||||
<Trans>Confirm your location</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<DeviceLocationRequestDialog
|
||||
control={geoDialog}
|
||||
onLocationAcquired={props => {
|
||||
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',
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[{paddingTop: 48}]}>
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<BottomSheetOutlet />
|
||||
<PortalOutlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export type ButtonColor =
|
||||
| 'primary_subtle'
|
||||
| 'negative_subtle'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'large'
|
||||
export type ButtonShape = 'round' | 'square' | 'default'
|
||||
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
|
||||
export type VariantProps = {
|
||||
/**
|
||||
* The style variation of the button
|
||||
@@ -56,6 +56,11 @@ export type VariantProps = {
|
||||
size?: ButtonSize
|
||||
/**
|
||||
* The shape of the button
|
||||
*
|
||||
* - `default`: Pill shaped. Most buttons should use this shape.
|
||||
* - `round`: Circular. For icon-only buttons.
|
||||
* - `square`: Square. For icon-only buttons.
|
||||
* - `rectangular`: Rectangular. Matches previous style, use when adjacent to form fields.
|
||||
*/
|
||||
shape?: ButtonShape
|
||||
}
|
||||
@@ -434,6 +439,26 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
}
|
||||
|
||||
if (shape === 'default') {
|
||||
if (size === 'large') {
|
||||
baseStyles.push(a.rounded_full, {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
gap: 6,
|
||||
})
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.rounded_full, {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
gap: 5,
|
||||
})
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push(a.rounded_full, {
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 10,
|
||||
gap: 3,
|
||||
})
|
||||
}
|
||||
} else if (shape === 'rectangular') {
|
||||
if (size === 'large') {
|
||||
baseStyles.push({
|
||||
paddingVertical: 12,
|
||||
@@ -506,9 +531,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
variant,
|
||||
color,
|
||||
size,
|
||||
shape,
|
||||
disabled: disabled || false,
|
||||
}),
|
||||
[state, variant, color, size, disabled],
|
||||
[state, variant, color, size, shape, disabled],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -749,59 +775,79 @@ export function ButtonIcon({
|
||||
position?: 'left' | 'right'
|
||||
size?: SVGIconProps['size']
|
||||
}) {
|
||||
const {size: buttonSize} = useButtonContext()
|
||||
const {size: buttonSize, shape: buttonShape} = useButtonContext()
|
||||
const textStyles = useSharedButtonTextStyles()
|
||||
const {iconSize, iconContainerSize} = React.useMemo(() => {
|
||||
/**
|
||||
* Pre-set icon sizes for different button sizes
|
||||
*/
|
||||
const iconSizeShorthand =
|
||||
size ??
|
||||
(({
|
||||
large: 'md',
|
||||
small: 'sm',
|
||||
tiny: 'xs',
|
||||
}[buttonSize || 'small'] || 'sm') as Exclude<
|
||||
SVGIconProps['size'],
|
||||
undefined
|
||||
>)
|
||||
const {iconSize, iconContainerSize, iconNegativeMargin} =
|
||||
React.useMemo(() => {
|
||||
/**
|
||||
* Pre-set icon sizes for different button sizes
|
||||
*/
|
||||
const iconSizeShorthand =
|
||||
size ??
|
||||
(({
|
||||
large: 'md',
|
||||
small: 'sm',
|
||||
tiny: 'xs',
|
||||
}[buttonSize || 'small'] || 'sm') as Exclude<
|
||||
SVGIconProps['size'],
|
||||
undefined
|
||||
>)
|
||||
|
||||
/*
|
||||
* Copied here from icons/common.tsx so we can tweak if we need to, but
|
||||
* also so that we can calculate transforms.
|
||||
*/
|
||||
const iconSize = {
|
||||
xs: 12,
|
||||
sm: 16,
|
||||
md: 18,
|
||||
lg: 24,
|
||||
xl: 28,
|
||||
'2xl': 32,
|
||||
}[iconSizeShorthand]
|
||||
/*
|
||||
* Copied here from icons/common.tsx so we can tweak if we need to, but
|
||||
* also so that we can calculate transforms.
|
||||
*/
|
||||
const iconSize = {
|
||||
xs: 12,
|
||||
sm: 16,
|
||||
md: 18,
|
||||
lg: 24,
|
||||
xl: 28,
|
||||
'2xs': 8,
|
||||
'2xl': 32,
|
||||
'3xl': 40,
|
||||
}[iconSizeShorthand]
|
||||
|
||||
/*
|
||||
* Goal here is to match rendered text size so that different size icons
|
||||
* don't increase button size
|
||||
*/
|
||||
const iconContainerSize = {
|
||||
large: 20,
|
||||
small: 17,
|
||||
tiny: 15,
|
||||
}[buttonSize || 'small']
|
||||
/*
|
||||
* Goal here is to match rendered text size so that different size icons
|
||||
* don't increase button size
|
||||
*/
|
||||
const iconContainerSize = {
|
||||
large: 20,
|
||||
small: 17,
|
||||
tiny: 15,
|
||||
}[buttonSize || 'small']
|
||||
|
||||
return {
|
||||
iconSize,
|
||||
iconContainerSize,
|
||||
}
|
||||
}, [buttonSize, size])
|
||||
/*
|
||||
* The icon needs to be closer to the edge of the button than the text. Therefore
|
||||
* we make the gap slightly too large, and then pull in the sides using negative margins.
|
||||
*/
|
||||
let iconNegativeMargin = 0
|
||||
|
||||
if (buttonShape === 'default') {
|
||||
iconNegativeMargin = {
|
||||
large: -2,
|
||||
small: -2,
|
||||
tiny: -1,
|
||||
}[buttonSize || 'small']
|
||||
}
|
||||
|
||||
return {
|
||||
iconSize,
|
||||
iconContainerSize,
|
||||
iconNegativeMargin,
|
||||
}
|
||||
}, [buttonSize, buttonShape, size])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.z_20,
|
||||
{
|
||||
width: iconContainerSize,
|
||||
width: size === '2xs' ? 10 : iconContainerSize,
|
||||
height: iconContainerSize,
|
||||
marginLeft: iconNegativeMargin,
|
||||
marginRight: iconNegativeMargin,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type DialogControlRefProps,
|
||||
type DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
||||
|
||||
export const Context = createContext<DialogContextProps>({
|
||||
@@ -50,10 +51,28 @@ export function useDialogControl(): DialogOuterProps['control'] {
|
||||
id,
|
||||
ref: control,
|
||||
open: () => {
|
||||
control.current.open()
|
||||
if (control.current) {
|
||||
control.current.open()
|
||||
} else {
|
||||
if (IS_DEV) {
|
||||
console.warn(
|
||||
'Attemped to open a dialog control that was not attached to a dialog!\n' +
|
||||
'Please ensure that the Dialog is mounted when calling open/close',
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
close: cb => {
|
||||
control.current.close(cb)
|
||||
if (control.current) {
|
||||
control.current.close(cb)
|
||||
} else {
|
||||
if (IS_DEV) {
|
||||
console.warn(
|
||||
'Attemped to close a dialog control that was not attached to a dialog!\n' +
|
||||
'Please ensure that the Dialog is mounted when calling open/close',
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
[id, control],
|
||||
|
||||
@@ -16,7 +16,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
import {logger} from '#/logger'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {
|
||||
@@ -180,7 +180,8 @@ export function Inner({
|
||||
onClick={stopPropagation}
|
||||
onStartShouldSetResponder={_ => true}
|
||||
onTouchEnd={stopPropagation}
|
||||
style={[
|
||||
// note: flatten is required for some reason -sfn
|
||||
style={flatten([
|
||||
a.relative,
|
||||
a.rounded_md,
|
||||
a.w_full,
|
||||
@@ -195,7 +196,7 @@ export function Inner({
|
||||
},
|
||||
!reduceMotionEnabled && a.zoom_fade_in,
|
||||
style,
|
||||
]}>
|
||||
])}>
|
||||
<DismissableLayer.DismissableLayer
|
||||
onInteractOutside={preventDefault}
|
||||
onFocusOutside={preventDefault}
|
||||
|
||||
@@ -129,7 +129,6 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="square"
|
||||
onPress={onPressBack}
|
||||
hitSlop={HITSLOP_30}
|
||||
style={[
|
||||
@@ -164,7 +163,10 @@ export function MenuButton() {
|
||||
shape="square"
|
||||
onPress={onPress}
|
||||
hitSlop={HITSLOP_30}
|
||||
style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}]}>
|
||||
style={[
|
||||
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
|
||||
a.bg_transparent,
|
||||
]}>
|
||||
<ButtonIcon icon={Menu} size="lg" />
|
||||
</Button>
|
||||
</Slot>
|
||||
|
||||
@@ -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<BaseLinkProps['onPress'], undefined>,
|
||||
): {
|
||||
to: BaseLinkProps['to']
|
||||
to: string
|
||||
onPress: Exclude<BaseLinkProps['onPress'], undefined>
|
||||
} {
|
||||
return {
|
||||
|
||||
@@ -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<any> | React.ReactElement
|
||||
emptyStateButton?: EmptyStateButtonProps
|
||||
useEmptyState?: boolean
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -180,6 +190,23 @@ let ListMaybePlaceholder = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (useEmptyState) {
|
||||
return (
|
||||
<View style={[t.atoms.border_contrast_low]}>
|
||||
<EmptyState
|
||||
icon={emptyStateIcon}
|
||||
message={
|
||||
emptyMessage ??
|
||||
(emptyType === 'results'
|
||||
? _(msg`No results found`)
|
||||
: _(msg`Page not found`))
|
||||
}
|
||||
button={emptyStateButton}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!noEmpty) {
|
||||
return (
|
||||
<Error
|
||||
|
||||
@@ -170,7 +170,7 @@ export function FollowsYou({size = 'sm'}: CommonProps) {
|
||||
}, [size])
|
||||
|
||||
return (
|
||||
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_25]}>
|
||||
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}>
|
||||
<Text style={[a.text_xs, a.leading_tight]}>
|
||||
<Trans>Follows You</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -7,7 +7,6 @@ import type * as HlsTypes from 'hls.js'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import * as BandwidthEstimate from './bandwidth-estimate'
|
||||
import {Controls} from './web-controls/VideoControls'
|
||||
|
||||
@@ -102,7 +101,6 @@ export function VideoEmbedInnerWeb({
|
||||
hasSubtitleTrack={hasSubtitleTrack}
|
||||
/>
|
||||
</div>
|
||||
<MediaInsetBorder />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
@@ -16,11 +16,9 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
interface Props {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
crop?: 'none' | 'square' | 'constrained'
|
||||
}
|
||||
|
||||
export function VideoEmbed({embed, crop}: Props) {
|
||||
const t = useTheme()
|
||||
export function VideoEmbed({embed}: Props) {
|
||||
const [key, setKey] = useState(0)
|
||||
|
||||
const renderError = useCallback(
|
||||
@@ -40,13 +38,10 @@ export function VideoEmbed({embed, crop}: Props) {
|
||||
}
|
||||
|
||||
let constrained: number | undefined
|
||||
let max: number | undefined
|
||||
if (aspectRatio !== undefined) {
|
||||
const ratio = 1 / 2 // max of 1:2 ratio in feeds
|
||||
constrained = Math.max(aspectRatio, ratio)
|
||||
max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
|
||||
}
|
||||
const cropDisabled = crop === 'none'
|
||||
|
||||
const contents = (
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
@@ -56,28 +51,13 @@ export function VideoEmbed({embed, crop}: Props) {
|
||||
|
||||
return (
|
||||
<View style={[a.pt_xs]}>
|
||||
{cropDisabled ? (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.overflow_hidden,
|
||||
{aspectRatio: max ?? 1},
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
{contents}
|
||||
</View>
|
||||
) : (
|
||||
<ConstrainedImage
|
||||
fullBleed={crop === 'square'}
|
||||
aspectRatio={constrained || 1}
|
||||
// slightly smaller max height than images
|
||||
// images use 16 / 9, for reference
|
||||
minMobileAspectRatio={14 / 9}>
|
||||
{contents}
|
||||
</ConstrainedImage>
|
||||
)}
|
||||
<ConstrainedImage
|
||||
aspectRatio={constrained || 1}
|
||||
// slightly smaller max height than images
|
||||
// images use 16 / 9, for reference
|
||||
minMobileAspectRatio={14 / 9}>
|
||||
{contents}
|
||||
</ConstrainedImage>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {
|
||||
HLSUnsupportedError,
|
||||
VideoEmbedInnerWeb,
|
||||
@@ -25,13 +26,7 @@ import {
|
||||
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
export function VideoEmbed({
|
||||
embed,
|
||||
crop,
|
||||
}: {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
crop?: 'none' | 'square' | 'constrained'
|
||||
}) {
|
||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const t = useTheme()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const {active, setActive, sendPosition, currentActiveView} =
|
||||
@@ -76,13 +71,10 @@ export function VideoEmbed({
|
||||
}
|
||||
|
||||
let constrained: number | undefined
|
||||
let max: number | undefined
|
||||
if (aspectRatio !== undefined) {
|
||||
const ratio = 1 / 2 // max of 1:2 ratio in feeds
|
||||
constrained = Math.max(aspectRatio, ratio)
|
||||
max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
|
||||
}
|
||||
const cropDisabled = crop === 'none'
|
||||
|
||||
const contents = (
|
||||
<div
|
||||
@@ -91,8 +83,11 @@ export function VideoEmbed({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
cursor: 'default',
|
||||
backgroundColor: t.palette.black,
|
||||
backgroundImage: `url(${embed.thumbnail})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
onClick={evt => evt.stopPropagation()}>
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
@@ -114,28 +109,15 @@ export function VideoEmbed({
|
||||
<ViewportObserver
|
||||
sendPosition={sendPosition}
|
||||
isAnyViewActive={currentActiveView !== null}>
|
||||
{cropDisabled ? (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.overflow_hidden,
|
||||
{aspectRatio: max ?? 1},
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
{contents}
|
||||
</View>
|
||||
) : (
|
||||
<ConstrainedImage
|
||||
fullBleed={crop === 'square'}
|
||||
aspectRatio={constrained || 1}
|
||||
// slightly smaller max height than images
|
||||
// images use 16 / 9, for reference
|
||||
minMobileAspectRatio={14 / 9}>
|
||||
{contents}
|
||||
</ConstrainedImage>
|
||||
)}
|
||||
<ConstrainedImage
|
||||
fullBleed
|
||||
aspectRatio={constrained || 1}
|
||||
// slightly smaller max height than images
|
||||
// images use 16 / 9, for reference
|
||||
minMobileAspectRatio={14 / 9}>
|
||||
{contents}
|
||||
<MediaInsetBorder />
|
||||
</ConstrainedImage>
|
||||
</ViewportObserver>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -112,7 +112,7 @@ function MediaEmbed({
|
||||
<ContentHider
|
||||
modui={rest.moderation?.ui('contentMedia')}
|
||||
activeStyle={[a.mt_sm]}>
|
||||
<VideoEmbed embed={embed.view} crop="constrained" />
|
||||
<VideoEmbed embed={embed.view} />
|
||||
</ContentHider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import {useRequireAuth} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
|
||||
import {Repost_Stroke2_Corner3_Rounded as Repost} from '#/components/icons/Repost'
|
||||
import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote'
|
||||
import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost'
|
||||
import {useFormatPostStatCount} from '#/components/PostControls/util'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
@@ -84,7 +84,7 @@ let RepostButton = ({
|
||||
}),
|
||||
)
|
||||
}>
|
||||
<PostControlButtonIcon icon={Repost} />
|
||||
<PostControlButtonIcon icon={RepostIcon} />
|
||||
{typeof repostCount !== 'undefined' && repostCount > 0 && (
|
||||
<PostControlButtonText testID="repostCount">
|
||||
{formatPostStatCount(repostCount)}
|
||||
@@ -156,7 +156,7 @@ let RepostButtonDialogInner = ({
|
||||
size="large"
|
||||
variant="ghost"
|
||||
color="primary">
|
||||
<Repost size="lg" fill={t.palette.primary_500} />
|
||||
<RepostIcon size="lg" fill={t.palette.primary_500} />
|
||||
<Text style={[a.font_semi_bold, a.text_xl]}>
|
||||
{isReposted ? (
|
||||
<Trans>Remove repost</Trans>
|
||||
@@ -178,7 +178,7 @@ let RepostButtonDialogInner = ({
|
||||
size="large"
|
||||
variant="ghost"
|
||||
color="primary">
|
||||
<Quote
|
||||
<QuoteIcon
|
||||
size="lg"
|
||||
fill={
|
||||
embeddingDisabled
|
||||
@@ -204,8 +204,7 @@ let RepostButtonDialogInner = ({
|
||||
label={_(msg`Cancel quote post`)}
|
||||
onPress={onPressClose}
|
||||
size="large"
|
||||
variant="outline"
|
||||
color="primary">
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
@@ -24,6 +23,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useDevMode} from '#/storage/hooks/dev-mode'
|
||||
import {RecentChats} from './RecentChats'
|
||||
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
|
||||
@@ -37,7 +37,7 @@ let ShareMenuItems = ({
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
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 (
|
||||
<>
|
||||
<Menu.Outer>
|
||||
{hasSession && !isAgeRestricted && (
|
||||
{hasSession && aa.state.access === aa.Access.Full && (
|
||||
<Menu.Group>
|
||||
<Menu.ContainerItem>
|
||||
<RecentChats postUri={postUri} />
|
||||
|
||||
@@ -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 = ({
|
||||
<Menu.Outer>
|
||||
{!hideInPWI && copyLinkItem}
|
||||
|
||||
{hasSession && !isAgeRestricted && (
|
||||
{hasSession && aa.state.access === aa.Access.Full && (
|
||||
<Menu.Item
|
||||
testID="postDropdownSendViaDMBtn"
|
||||
label={_(msg`Send via direct message`)}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import {useMemo} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
@@ -48,11 +54,15 @@ export function Default({
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
testID,
|
||||
position,
|
||||
contextProfileDid,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
testID?: string
|
||||
position?: number
|
||||
contextProfileDid?: string
|
||||
}) {
|
||||
return (
|
||||
<Link testID={testID} profile={profile}>
|
||||
@@ -60,6 +70,8 @@ export function Default({
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext={logContext}
|
||||
position={position}
|
||||
contextProfileDid={contextProfileDid}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
@@ -69,10 +81,14 @@ export function Card({
|
||||
profile,
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
position,
|
||||
contextProfileDid,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
position?: number
|
||||
contextProfileDid?: string
|
||||
}) {
|
||||
return (
|
||||
<Outer>
|
||||
@@ -83,6 +99,8 @@ export function Card({
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext={logContext}
|
||||
position={position}
|
||||
contextProfileDid={contextProfileDid}
|
||||
/>
|
||||
</Header>
|
||||
|
||||
@@ -271,9 +289,13 @@ function InlineNameAndHandle({
|
||||
export function Name({
|
||||
profile,
|
||||
moderationOpts,
|
||||
style,
|
||||
textStyle,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
style?: StyleProp<ViewStyle>
|
||||
textStyle?: StyleProp<TextStyle>
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const name = sanitizeDisplayName(
|
||||
@@ -282,7 +304,7 @@ export function Name({
|
||||
)
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.max_w_full]}>
|
||||
<View style={[a.flex_row, a.align_center, a.max_w_full, style]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
@@ -291,6 +313,7 @@ export function Name({
|
||||
a.leading_snug,
|
||||
a.self_start,
|
||||
a.flex_shrink,
|
||||
textStyle,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
@@ -437,6 +460,8 @@ export type FollowButtonProps = {
|
||||
colorInverted?: boolean
|
||||
onFollow?: () => void
|
||||
withIcon?: boolean
|
||||
position?: number
|
||||
contextProfileDid?: string
|
||||
} & Partial<ButtonProps>
|
||||
|
||||
export function FollowButton(props: FollowButtonProps) {
|
||||
@@ -453,6 +478,8 @@ export function FollowButtonInner({
|
||||
onFollow,
|
||||
colorInverted,
|
||||
withIcon = true,
|
||||
position,
|
||||
contextProfileDid,
|
||||
...rest
|
||||
}: FollowButtonProps) {
|
||||
const {_} = useLingui()
|
||||
@@ -461,6 +488,8 @@ export function FollowButtonInner({
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
logContext,
|
||||
position,
|
||||
contextProfileDid,
|
||||
)
|
||||
const isRound = Boolean(rest.shape && rest.shape === 'round')
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, useButtonContext} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import * as LabelingServiceCard from '#/components/LabelingServiceCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type ReportDialogProps} from './types'
|
||||
|
||||
export function SelectLabelerView({
|
||||
...props
|
||||
}: ReportDialogProps & {
|
||||
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||
onSelectLabeler: (v: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
return (
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
|
||||
<Text style={[a.text_2xl, a.font_semi_bold]}>
|
||||
<Trans>Select moderator</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>To whom would you like to send this report?</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.gap_sm]}>
|
||||
{props.labelers.map(labeler => {
|
||||
return (
|
||||
<Button
|
||||
key={labeler.creator.did}
|
||||
label={_(msg`Send report to ${labeler.creator.displayName}`)}
|
||||
onPress={() => props.onSelectLabeler(labeler.creator.did)}>
|
||||
<LabelerButton labeler={labeler} />
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function LabelerButton({
|
||||
labeler,
|
||||
}: {
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {hovered, pressed} = useButtonContext()
|
||||
const interacted = hovered || pressed
|
||||
|
||||
return (
|
||||
<LabelingServiceCard.Outer
|
||||
style={[
|
||||
a.p_md,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
interacted && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<LabelingServiceCard.Avatar avatar={labeler.creator.avatar} />
|
||||
<LabelingServiceCard.Content>
|
||||
<LabelingServiceCard.Title
|
||||
value={getLabelingServiceTitle({
|
||||
displayName: labeler.creator.displayName,
|
||||
handle: labeler.creator.handle,
|
||||
})}
|
||||
/>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.text_sm, a.font_semi_bold]}>
|
||||
@{labeler.creator.handle}
|
||||
</Text>
|
||||
</LabelingServiceCard.Content>
|
||||
</LabelingServiceCard.Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
type ReportOption,
|
||||
useReportOptions,
|
||||
} from '#/lib/moderation/useReportOptions'
|
||||
import {Link} from '#/components/Link'
|
||||
import {DMCA_LINK} from '#/components/ReportDialog/const'
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
ButtonText,
|
||||
useButtonContext,
|
||||
} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {
|
||||
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft,
|
||||
ChevronRight_Stroke2_Corner0_Rounded as ChevronRight,
|
||||
} from '#/components/icons/Chevron'
|
||||
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type ReportDialogProps} from './types'
|
||||
|
||||
export function SelectReportOptionView(props: {
|
||||
params: ReportDialogProps['params']
|
||||
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||
onSelectReportOption: (reportOption: ReportOption) => void
|
||||
goBack: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const allReportOptions = useReportOptions()
|
||||
const reportOptions = allReportOptions[props.params.type]
|
||||
|
||||
const i18n = React.useMemo(() => {
|
||||
let title = _(msg`Report this content`)
|
||||
let description = _(msg`Why should this content be reviewed?`)
|
||||
|
||||
if (props.params.type === 'account') {
|
||||
title = _(msg`Report this user`)
|
||||
description = _(msg`Why should this user be reviewed?`)
|
||||
} else if (props.params.type === 'post') {
|
||||
title = _(msg`Report this post`)
|
||||
description = _(msg`Why should this post be reviewed?`)
|
||||
} else if (props.params.type === 'list') {
|
||||
title = _(msg`Report this list`)
|
||||
description = _(msg`Why should this list be reviewed?`)
|
||||
} else if (props.params.type === 'feedgen') {
|
||||
title = _(msg`Report this feed`)
|
||||
description = _(msg`Why should this feed be reviewed?`)
|
||||
} else if (props.params.type === 'starterpack') {
|
||||
title = _(msg`Report this starter pack`)
|
||||
description = _(msg`Why should this starter pack be reviewed?`)
|
||||
} else if (props.params.type === 'convoMessage') {
|
||||
title = _(msg`Report this message`)
|
||||
description = _(msg`Why should this message be reviewed?`)
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
}
|
||||
}, [_, props.params.type])
|
||||
|
||||
return (
|
||||
<View style={[a.gap_lg]}>
|
||||
{props.labelers?.length > 1 ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
label={_(msg`Go back to previous step`)}
|
||||
onPress={props.goBack}>
|
||||
<ButtonIcon icon={ChevronLeft} />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
|
||||
<Text style={[a.text_2xl, a.font_semi_bold]}>{i18n.title}</Text>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
{i18n.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.gap_sm]}>
|
||||
{reportOptions.map(reportOption => {
|
||||
return (
|
||||
<Button
|
||||
key={reportOption.reason}
|
||||
testID={reportOption.reason}
|
||||
label={_(msg`Create report for ${reportOption.title}`)}
|
||||
onPress={() => props.onSelectReportOption(reportOption)}>
|
||||
<ReportOptionButton
|
||||
title={reportOption.title}
|
||||
description={reportOption.description}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
|
||||
{(props.params.type === 'post' || props.params.type === 'account') && (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.p_md,
|
||||
a.pl_lg,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_900,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.text_inverted,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
<Trans>Need to report a copyright violation?</Trans>
|
||||
</Text>
|
||||
<Link
|
||||
to={DMCA_LINK}
|
||||
label={_(msg`View details for reporting a copyright violation`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>View details</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon position="right" icon={SquareArrowTopRight} />
|
||||
</Link>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportOptionButton({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {hovered, pressed} = useButtonContext()
|
||||
const interacted = hovered || pressed
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.p_md,
|
||||
a.rounded_md,
|
||||
{paddingRight: 70},
|
||||
t.atoms.bg_contrast_25,
|
||||
interacted && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_semi_bold, t.atoms.text_contrast_medium]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.justify_center,
|
||||
a.pr_md,
|
||||
{left: 'auto'},
|
||||
]}>
|
||||
<ChevronRight size="md" fill={t.atoms.text_contrast_low.color} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {type ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as SendIcon} from '#/components/icons/PaperPlane'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type ReportDialogProps} from './types'
|
||||
|
||||
export function SubmitView({
|
||||
params,
|
||||
labelers,
|
||||
selectedLabeler,
|
||||
selectedReportOption,
|
||||
goBack,
|
||||
onSubmitComplete,
|
||||
}: ReportDialogProps & {
|
||||
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||
selectedLabeler: string
|
||||
selectedReportOption: ReportOption
|
||||
goBack: () => void
|
||||
onSubmitComplete: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const [details, setDetails] = React.useState<string>('')
|
||||
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
||||
const [selectedServices, setSelectedServices] = React.useState<string[]>([
|
||||
selectedLabeler,
|
||||
])
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
const submit = React.useCallback(async () => {
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
const $type =
|
||||
params.type === 'account'
|
||||
? 'com.atproto.admin.defs#repoRef'
|
||||
: 'com.atproto.repo.strongRef'
|
||||
const report = {
|
||||
reasonType: selectedReportOption.reason,
|
||||
subject: {
|
||||
$type,
|
||||
...params,
|
||||
},
|
||||
reason: details,
|
||||
}
|
||||
const results = await Promise.all(
|
||||
selectedServices.map(did => {
|
||||
return agent
|
||||
.createModerationReport(report, {
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
'atproto-proxy': `${did}#atproto_labeler`,
|
||||
},
|
||||
})
|
||||
.then(
|
||||
_ => true,
|
||||
_ => false,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
setSubmitting(false)
|
||||
|
||||
if (results.includes(true)) {
|
||||
Toast.show(_(msg`Thank you. Your report has been sent.`))
|
||||
onSubmitComplete()
|
||||
} else {
|
||||
setError(
|
||||
_(
|
||||
msg`There was an issue sending your report. Please check your internet connection.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}, [
|
||||
_,
|
||||
params,
|
||||
details,
|
||||
selectedReportOption,
|
||||
selectedServices,
|
||||
onSubmitComplete,
|
||||
setError,
|
||||
agent,
|
||||
])
|
||||
|
||||
return (
|
||||
<View style={[a.gap_2xl]}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
label={_(msg`Go back to previous step`)}
|
||||
onPress={goBack}>
|
||||
<ButtonIcon icon={ChevronLeft} />
|
||||
</Button>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.p_md,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.text_md, a.font_semi_bold]}>
|
||||
{selectedReportOption.title}
|
||||
</Text>
|
||||
<Text style={[a.leading_tight, {maxWidth: 400}]}>
|
||||
{selectedReportOption.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} />
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_md]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Select the moderation service(s) to report to</Trans>
|
||||
</Text>
|
||||
|
||||
<Toggle.Group
|
||||
label="Select mod services"
|
||||
values={selectedServices}
|
||||
onChange={setSelectedServices}>
|
||||
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
|
||||
{labelers.map(labeler => {
|
||||
const title = getLabelingServiceTitle({
|
||||
displayName: labeler.creator.displayName,
|
||||
handle: labeler.creator.handle,
|
||||
})
|
||||
return (
|
||||
<Toggle.Item
|
||||
key={labeler.creator.did}
|
||||
name={labeler.creator.did}
|
||||
label={title}>
|
||||
<LabelerToggle title={title} />
|
||||
</Toggle.Item>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
</View>
|
||||
<View style={[a.gap_md]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Optionally provide additional information below:</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.relative, a.w_full]}>
|
||||
<Dialog.Input
|
||||
multiline
|
||||
value={details}
|
||||
onChangeText={setDetails}
|
||||
label="Text field"
|
||||
style={{paddingRight: 60}}
|
||||
numberOfLines={6}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.pr_md,
|
||||
a.pb_sm,
|
||||
{
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
]}>
|
||||
<CharProgress count={details?.length || 0} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}>
|
||||
{!selectedServices.length ||
|
||||
(error && (
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{error ? (
|
||||
error
|
||||
) : (
|
||||
<Trans>You must select at least one labeler for a report</Trans>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Button
|
||||
testID="sendReportBtn"
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="negative"
|
||||
label={_(msg`Send report`)}
|
||||
onPress={submit}
|
||||
disabled={!selectedServices.length}>
|
||||
<ButtonText>
|
||||
<Trans>Send report</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={submitting ? Loader : SendIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
{/* Maybe fix this later -h */}
|
||||
{isAndroid ? <View style={{height: 300}} /> : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function LabelerToggle({title}: {title: string}) {
|
||||
const t = useTheme()
|
||||
const ctx = Toggle.useItemContext()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_md,
|
||||
a.p_md,
|
||||
a.pr_lg,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
ctx.selected && [t.atoms.bg_contrast_50],
|
||||
]}>
|
||||
<Toggle.Checkbox />
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.z_10,
|
||||
]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
native({marginTop: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
ctx.selected && t.atoms.text,
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
|
||||
@@ -1,97 +0,0 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type ScrollView} from 'react-native-gesture-handler'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {type ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {type AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {SelectLabelerView} from './SelectLabelerView'
|
||||
import {SelectReportOptionView} from './SelectReportOptionView'
|
||||
import {SubmitView} from './SubmitView'
|
||||
import {type ReportDialogProps} from './types'
|
||||
|
||||
export function ReportDialog(props: ReportDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
<ReportDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportDialogInner(props: ReportDialogProps) {
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
isLoading: isLabelerLoading,
|
||||
data: labelers,
|
||||
error,
|
||||
} = useMyLabelersQuery({excludeNonConfigurableLabelers: true})
|
||||
const isLoading = useDelayedLoading(500, isLabelerLoading)
|
||||
|
||||
const ref = React.useRef<ScrollView>(null)
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
|
||||
{isLoading ? (
|
||||
<View style={[a.align_center, {height: 100}]}>
|
||||
<Loader size="xl" />
|
||||
{/* Here to capture focus for a hot sec to prevent flash */}
|
||||
<Pressable accessible={false} />
|
||||
</View>
|
||||
) : error || !labelers ? (
|
||||
<View>
|
||||
<Text style={[a.text_md]}>
|
||||
<Trans>Something went wrong, please try again.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ReportDialogLoaded labelers={labelers} {...props} />
|
||||
)}
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportDialogLoaded(
|
||||
props: ReportDialogProps & {
|
||||
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||
},
|
||||
) {
|
||||
const [selectedLabeler, setSelectedLabeler] = React.useState<
|
||||
string | undefined
|
||||
>(props.labelers.length === 1 ? props.labelers[0].creator.did : undefined)
|
||||
const [selectedReportOption, setSelectedReportOption] = React.useState<
|
||||
ReportOption | undefined
|
||||
>()
|
||||
|
||||
if (selectedReportOption && selectedLabeler) {
|
||||
return (
|
||||
<SubmitView
|
||||
{...props}
|
||||
selectedLabeler={selectedLabeler}
|
||||
selectedReportOption={selectedReportOption}
|
||||
goBack={() => setSelectedReportOption(undefined)}
|
||||
onSubmitComplete={() => props.control.close()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (selectedLabeler) {
|
||||
return (
|
||||
<SelectReportOptionView
|
||||
{...props}
|
||||
goBack={() => setSelectedLabeler(undefined)}
|
||||
onSelectReportOption={setSelectedReportOption}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <SelectLabelerView {...props} onSelectLabeler={setSelectedLabeler} />
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
|
||||
export type ReportDialogProps = {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
params:
|
||||
| {
|
||||
type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other'
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
| {
|
||||
type: 'account'
|
||||
did: string
|
||||
}
|
||||
| {type: 'convoMessage'}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {type TextStyle} from 'react-native'
|
||||
import {type StyleProp, type TextStyle} from 'react-native'
|
||||
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
||||
|
||||
import {toShortUrl} from '#/lib/strings/url-helpers'
|
||||
@@ -21,7 +21,7 @@ export type RichTextProps = TextStyleProp &
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
interactiveStyle?: TextStyle
|
||||
interactiveStyle?: StyleProp<TextStyle>
|
||||
emojiMultiplier?: number
|
||||
shouldProxyLinks?: boolean
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function RichText({
|
||||
|
||||
if (!facets?.length) {
|
||||
if (isOnlyEmoji(text)) {
|
||||
const flattenedStyle = flatten(style)
|
||||
const flattenedStyle = flatten(style) ?? {}
|
||||
const fontSize =
|
||||
(flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
|
||||
return (
|
||||
|
||||
@@ -106,7 +106,7 @@ export function Trigger({children, label}: TriggerProps) {
|
||||
style={[a.flex_1, a.justify_between]}
|
||||
color="secondary"
|
||||
size="small"
|
||||
variant="solid">
|
||||
shape="rectangular">
|
||||
<>{children}</>
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {createContext, forwardRef, useContext, useMemo} from 'react'
|
||||
import {createContext, forwardRef, Fragment, useContext, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Select as RadixSelect} from 'radix-ui'
|
||||
|
||||
@@ -96,8 +96,7 @@ export function Trigger({children, label}: TriggerProps) {
|
||||
style={flatten([
|
||||
a.flex,
|
||||
a.relative,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_50,
|
||||
a.w_full,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
@@ -106,15 +105,14 @@ export function Trigger({children, label}: TriggerProps) {
|
||||
a.px_md,
|
||||
a.pointer,
|
||||
{
|
||||
borderRadius: 10,
|
||||
maxWidth: 400,
|
||||
outline: 0,
|
||||
borderWidth: 2,
|
||||
borderStyle: 'solid',
|
||||
borderColor: focused
|
||||
? t.palette.primary_500
|
||||
: hovered
|
||||
? t.palette.contrast_100
|
||||
: t.palette.contrast_25,
|
||||
: t.palette.contrast_50,
|
||||
},
|
||||
])}>
|
||||
{children}
|
||||
@@ -140,7 +138,11 @@ export function Icon({style}: IconProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Content<T>({items, renderItem}: ContentProps<T>) {
|
||||
export function Content<T>({
|
||||
items,
|
||||
renderItem,
|
||||
valueExtractor = defaultItemValueExtractor,
|
||||
}: ContentProps<T>) {
|
||||
const t = useTheme()
|
||||
const selectedValue = useContext(SelectedValueContext)
|
||||
|
||||
@@ -198,7 +200,11 @@ export function Content<T>({items, renderItem}: ContentProps<T>) {
|
||||
<ChevronUpIcon style={[t.atoms.text]} size="xs" />
|
||||
</RadixSelect.ScrollUpButton>
|
||||
<RadixSelect.Viewport style={flatten([a.p_xs])}>
|
||||
{items.map((item, index) => renderItem(item, index, selectedValue))}
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={valueExtractor(item)}>
|
||||
{renderItem(item, index, selectedValue)}
|
||||
</Fragment>
|
||||
))}
|
||||
</RadixSelect.Viewport>
|
||||
<RadixSelect.ScrollDownButton style={flatten(down)}>
|
||||
<ChevronDownIcon style={[t.atoms.text]} size="xs" />
|
||||
@@ -209,6 +215,10 @@ export function Content<T>({items, renderItem}: ContentProps<T>) {
|
||||
)
|
||||
}
|
||||
|
||||
function defaultItemValueExtractor(item: any) {
|
||||
return item.value
|
||||
}
|
||||
|
||||
const ItemContext = createContext<{
|
||||
hovered: boolean
|
||||
focused: boolean
|
||||
|
||||
@@ -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<SectionRef, ProfilesListProps>(
|
||||
}))
|
||||
|
||||
const renderPostsEmpty = useCallback(() => {
|
||||
return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
|
||||
return (
|
||||
<EmptyState
|
||||
icon={HashtagWideIcon}
|
||||
iconSize="2xl"
|
||||
message={_(msg`This feed is empty.`)}
|
||||
/>
|
||||
)
|
||||
}, [_])
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<any> | 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 (
|
||||
<View style={[a.px_lg, a.align_center, a.justify_center]}>
|
||||
<EmptyState
|
||||
icon={emptyStateIcon}
|
||||
iconSize="3xl"
|
||||
message={
|
||||
emptyStateMessage ??
|
||||
_(
|
||||
'Starter packs let you share your favorite feeds and people with your friends.',
|
||||
)
|
||||
}
|
||||
button={emptyStateButton}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return <Empty />
|
||||
}, [_, 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
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export function SheetCompatProvider({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
export function Outer({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
export function Target({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
export function Content() {
|
||||
return null
|
||||
}
|
||||
|
||||
export function TextBubble() {
|
||||
return null
|
||||
}
|
||||