Compare commits

..

1 Commits

Author SHA1 Message Date
Samuel Newman d72b21b3a3 rm unused follow button 2025-11-05 10:07:30 +02:00
353 changed files with 48220 additions and 55490 deletions
+3
View File
@@ -36,3 +36,6 @@ 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=
-1
View File
@@ -43,7 +43,6 @@ 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@v4
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v3
- 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@v4
uses: actions/checkout@v3
- 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@v4
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
+4 -4
View File
@@ -15,7 +15,7 @@ jobs:
build:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android
runs-on: Linux-x64-32core
runs-on: ubuntu-latest
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -30,7 +30,7 @@ jobs:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
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@main
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
@@ -54,7 +54,7 @@ jobs:
java-version: "17"
- name: ⚙️ Install dependencies
run: yarn install --frozen-lockfile
run: yarn install
- name: 🔤 Compile translations
run: yarn intl:build 2>&1 | tee i18n.log
+5 -5
View File
@@ -15,7 +15,7 @@ jobs:
build:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit iOS
runs-on: macos-26-xlarge
runs-on: macos-26
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -30,7 +30,7 @@ jobs:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
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@main
uses: expo/expo-github-action@v8
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 --frozen-lockfile
run: yarn install
- uses: maxim-lobanov/setup-xcode@v1
with:
@@ -61,7 +61,7 @@ jobs:
version: 1.16.2
- name: 💾 Cache Pods
uses: actions/cache@v4
uses: actions/cache@v3
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@v6
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
@@ -86,7 +86,7 @@ jobs:
run: yarn typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
uses: expo/expo-github-action@v8
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@v6
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
uses: expo/expo-github-action@v8
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 --frozen-lockfile
run: yarn install
- uses: maxim-lobanov/setup-xcode@v1
with:
@@ -200,7 +200,7 @@ jobs:
version: 1.16.2
- name: 💾 Cache Pods
uses: actions/cache@v4
uses: actions/cache@v3
id: pods-cache
with:
path: ./ios/Pods
@@ -276,13 +276,13 @@ jobs:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
@@ -297,7 +297,7 @@ jobs:
java-version: "17"
- name: ⚙️ Install dependencies
run: yarn install --frozen-lockfile
run: yarn install
- name: 🔤 Compile translations
run: yarn intl:build
+5 -5
View File
@@ -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@v4
uses: actions/checkout@v3
- name: Set up Go tooling
uses: actions/setup-go@v3
with:
go-version: "1.25"
go-version: '1.23'
- 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@v4
uses: actions/checkout@v3
- name: Set up Go tooling
uses: actions/setup-go@v3
with:
go-version: "1.25"
go-version: '1.23'
- 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
+5 -7
View File
@@ -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,12 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Install node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
@@ -42,12 +41,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Install node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
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,14 +16,13 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
- name: Install node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
@@ -47,4 +46,4 @@ jobs:
push_sources: false
create_pull_request: false
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+2 -2
View File
@@ -116,7 +116,7 @@ jobs:
ref: ${{ steps.pr-info.outputs.head-sha }}
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
@@ -140,7 +140,7 @@ jobs:
run: yarn typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
+5 -5
View File
@@ -29,7 +29,7 @@ jobs:
fetch-depth: 0
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
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@v6
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
+3 -3
View File
@@ -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@v4
uses: actions/checkout@v3
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@v6
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
+2 -2
View File
@@ -1,4 +1,4 @@
FROM golang:1.25-bookworm AS build-env
FROM golang:1.24.5-bullseye AS build-env
WORKDIR /usr/src/social-app
@@ -89,7 +89,7 @@ RUN cd bskyweb/ && \
-o /bskyweb \
./cmd/bskyweb
FROM debian:bookworm-slim
FROM debian:bullseye-slim
ENV GODEBUG=netdns=go
ENV TZ=Etc/UTC
+2 -2
View File
@@ -1,4 +1,4 @@
FROM golang:1.25-bookworm AS build-env
FROM golang:1.24.5-bullseye AS build-env
WORKDIR /usr/src/social-app
@@ -58,7 +58,7 @@ RUN cd bskyweb/ && \
-o /embedr \
./cmd/embedr
FROM debian:bookworm-slim
FROM debian:bullseye-slim
ENV GODEBUG=netdns=go
ENV TZ=Etc/UTC
+2 -8
View File
@@ -31,15 +31,9 @@ appId: xyz.blueskyweb.app
text: "Your account"
- tapOn:
id: "nextBtn"
- waitForAnimationToEnd
- tapOn:
# 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"
text: "Not Now"
optional: true
- inputText: "e2e-test"
- extendedWaitUntil:
visible:
+3 -3
View File
@@ -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"
+1 -2
View File
@@ -17,8 +17,7 @@ appId: xyz.blueskyweb.app
index: 0
- tapOn:
id: "postDropdownReportBtn"
- tapOn: "Create report for Misleading"
- tapOn: "Create report for Spam"
- tapOn: "Create report for Misleading Post"
- 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,11 +15,9 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "profileHeaderDropdownReportBtn"
- tapOn:
id: "report:category:Misleading"
- tapOn:
id: "report:option:Spam"
id: "report:option:com.atproto.moderation.defs#reasonSpam"
- assertVisible:
text: "Send report to Dev-env Moderation"
id: "report:labeler:mod-authority.test"
- tapOn:
id: "report:submit"
- assertNotVisible:
+3 -5
View File
@@ -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,11 +15,9 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:category:Misleading"
- tapOn:
id: "report:option:Spam"
id: "report:option:com.atproto.moderation.defs#reasonSpam"
- assertVisible:
text: "Send report to Dev-env Moderation"
id: "report:labeler:mod-authority.test"
- 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,28 +15,20 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:category:Misleading"
- tapOn:
id: "report:option:Other misleading content"
id: "report:option:com.atproto.moderation.defs#reasonOther"
- assertVisible:
text: Your report will be sent to Dev-env Moderation.*
id: "report:labeler:mod-authority.test"
# reason "other" defaults with details open
- assertVisible:
id: "report:details"
- tapOn:
id: "report:clearReportOption"
id: "report:clearOption"
- assertNotVisible:
id: "report:details"
- tapOn:
id: "report:clearCategory"
- assertNotVisible:
id: "report:option:Other misleading content"
- tapOn:
id: "report:category:Misleading"
- tapOn:
id: "report:option:Spam"
id: "report:option:com.atproto.moderation.defs#reasonSpam"
- assertVisible:
text: Your report will be sent to Dev-env Moderation.*
id: "report:labeler:mod-authority.test"
- 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,11 +15,9 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:category:Misleading"
- tapOn:
id: "report:option:Other misleading content"
id: "report:option:com.atproto.moderation.defs#reasonOther"
- assertVisible:
text: "Send report to Dev-env Moderation"
id: "report:labeler:mod-authority.test"
# reason "other" defaults with details open
- assertVisible:
id: "report:details"
+2 -1
View File
@@ -1,3 +1,4 @@
# flow.yaml
appId: xyz.blueskyweb.app
@@ -32,7 +33,7 @@ appId: xyz.blueskyweb.app
direction: "LEFT"
- swipe:
direction: "LEFT"
- swipe:
- swipe:
direction: "LEFT"
- swipe:
direction: "RIGHT"
+1 -1
View File
@@ -1,4 +1,4 @@
import {getLikelyType, LikelyType} from '../../src/lib/link-meta/link-meta'
import {LikelyType, getLikelyType} from '../../src/lib/link-meta/link-meta'
describe('getLikelyType', () => {
it('correctly handles non-parsed url', async () => {
+4 -4
View File
@@ -957,20 +957,20 @@ describe('parseStarterPackHttpUri', () => {
})
it('returns the at uri when the input is a valid starterpack at uri', () => {
const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack/rkey'
const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey'
expect(parseStarterPackUri(validAtUri)).toEqual({
name: 'did:plc:123',
name: 'did:123',
rkey: 'rkey',
})
})
it('returns null when the at uri has no rkey', () => {
const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack'
const validAtUri = 'at://did: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:plc:123/app.bsky.graph.list/rkey'
const validAtUri = 'at://did:123/app.bsky.graph.list/rkey'
expect(parseStarterPackUri(validAtUri)).toEqual(null)
})
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 333 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 944 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.5 KiB

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 224 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 262 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 491 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 272 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 429 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 237 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 619 B

+1 -1
View File
@@ -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.FromCache(name)
t, err = r.TemplateSet.FromFile(name)
}
if err != nil {
+1 -1
View File
@@ -223,7 +223,7 @@ func serve(cctx *cli.Context) error {
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
}
e.GET("/iframe/*", echo.WrapHandler(staticHandler))
e.GET("/iframe/youtube.html", 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 -1
View File
@@ -1,6 +1,6 @@
module github.com/bluesky-social/social-app/bskyweb
go 1.25
go 1.24.5
require (
github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a
+37 -1
View File
@@ -16,4 +16,40 @@
}
</style>
<div class="container"><div class="video" id="player"></div></div>
<script src="youtube.js"></script>
<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>
-35
View File
@@ -1,35 +0,0 @@
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();
}
+4 -12
View File
@@ -68,19 +68,11 @@
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
@@ -114,7 +106,7 @@
<div id="root">
<div id="splash">
<!-- Bluesky 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>
<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>
</div>
</div>
@@ -243,18 +243,13 @@ 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
val oldRatio = behavior.halfExpandedRatio
var newRatio = getHalfExpandedRatio(contentHeight)
behavior.halfExpandedRatio = newRatio
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
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
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
import {BottomSheet} from './src/BottomSheet'
import {
BottomSheetSnapPoint,
type BottomSheetState,
type BottomSheetViewProps,
BottomSheetState,
BottomSheetViewProps,
} from './src/BottomSheet.types'
import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent'
import {
@@ -112,21 +112,9 @@ export class BottomSheetNativeComponent extends React.Component<
onStateChange={this.onStateChange}
extraStyles={extraStyles}
onLayout={e => {
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()
}
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
this.updateLayout()
}}
/>
</Portal>
@@ -23,10 +23,6 @@ class ExpoReceiveAndroidIntentsModule : Module() {
ModuleDefinition {
Name("ExpoReceiveAndroidIntents")
OnCreate {
handleIntent(appContext.currentActivity?.intent)
}
OnNewIntent {
handleIntent(it)
}
@@ -78,12 +74,10 @@ class ExpoReceiveAndroidIntentsModule : Module() {
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
uri?.let {
when (type) {
AttachmentType.IMAGE -> handleImageIntents(listOf(it), text)
AttachmentType.VIDEO -> handleVideoIntents(listOf(it), text)
AttachmentType.IMAGE -> handleImageIntents(listOf(it))
AttachmentType.VIDEO -> handleVideoIntents(listOf(it))
}
}
}
@@ -105,20 +99,15 @@ class ExpoReceiveAndroidIntentsModule : Module() {
?.take(4)
}
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
uris?.let {
when (type) {
AttachmentType.IMAGE -> handleImageIntents(it, text)
AttachmentType.IMAGE -> handleImageIntents(it)
else -> return
}
}
}
private fun handleImageIntents(
uris: List<Uri>,
text: String?
) {
private fun handleImageIntents(uris: List<Uri>) {
var allParams = ""
uris.forEachIndexed { index, uri ->
@@ -131,22 +120,15 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
}
val encodedUris = URLEncoder.encode(allParams, "UTF-8")
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
val encoded = URLEncoder.encode(allParams, "UTF-8")
var composeIntent = "bluesky://intent/compose?imageUris=$encodedUris"
encodedText?.let { composeIntent += "&text=$it" }
composeIntent.toUri().let {
"bluesky://intent/compose?imageUris=$encoded".toUri().let {
val newIntent = Intent(Intent.ACTION_VIEW, it)
appContext.currentActivity?.startActivity(newIntent)
}
}
private fun handleVideoIntents(
uris: List<Uri>,
text: String?
) {
private fun handleVideoIntents(uris: List<Uri>) {
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
@@ -165,12 +147,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
val info = getVideoInfo(uri) ?: return
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 {
"bluesky://intent/compose?videoUri=${URLEncoder.encode(file.path, "UTF-8")}|${info["width"]}|${info["height"]}".toUri().let {
val newIntent = Intent(Intent.ACTION_VIEW, it)
appContext.currentActivity?.startActivity(newIntent)
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.111.0",
"version": "1.110.0",
"private": true,
"engines": {
"node": ">=20"
@@ -72,7 +72,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.4",
"@atproto/api": "^0.17.6",
"@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.25.0",
"@tanstack/react-query": "^5.8.1",
"@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.4.0",
"expo-image-crop-tool": "^0.1.8",
"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": "^8.0.1",
"patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"radix-ui": "^1.4.3",
+1 -22
View File
@@ -1,26 +1,5 @@
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 68086bd..78c7761 100644
index b85291e..546709d 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,67 +0,0 @@
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
@@ -1,16 +0,0 @@
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)
@@ -1,16 +1,47 @@
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..5fbb2e0 100644
index e9b330f..ec5f58c 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@@ -15,5 +15,6 @@
@@ -15,5 +15,8 @@
@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..e2e0c9f 100644
index 53bfd04..ff1b1ed 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 {
@@ -34,7 +65,7 @@ index 53bfd04..e2e0c9f 100644
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -221,4 +228,16 @@ - (void)refreshControlValueChanged
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
}
}
@@ -49,6 +80,40 @@ index 53bfd04..e2e0c9f 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
+39 -39
View File
@@ -25,10 +25,16 @@ 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'
@@ -50,7 +56,6 @@ 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'
@@ -68,9 +73,6 @@ 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'
@@ -91,8 +93,7 @@ if (isAndroid) {
/**
* Begin geolocation ASAP
*/
Geo.resolve()
prefetchAgeAssuranceConfig()
beginResolveGeolocationConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -142,7 +143,7 @@ function InnerApp() {
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AgeAssuranceV2Provider>
<AgeAssuranceProvider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
@@ -185,7 +186,7 @@ function InnerApp() {
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</AgeAssuranceProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
@@ -202,9 +203,10 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
setReady(true),
)
Promise.all([
initPersistedState(),
ensureGeolocationConfigIsResolved(),
]).then(() => setReady(true))
}, [])
if (!isReady) {
@@ -216,38 +218,36 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<Geo.Provider>
<GeolocationProvider>
<A11yProvider>
<KeyboardControllerProvider>
<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>
<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>
</KeyboardControllerProvider>
</A11yProvider>
</Geo.Provider>
</GeolocationProvider>
)
}
+36 -37
View File
@@ -14,10 +14,16 @@ 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'
@@ -38,7 +44,6 @@ 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'
@@ -56,18 +61,13 @@ 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
*/
Geo.resolve()
prefetchAgeAssuranceConfig()
beginResolveGeolocationConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -104,7 +104,7 @@ function InnerApp() {
}, [_])
// wait for session to resume
if (!isReady || !hasCheckedReferrer) return <Splash isReady />
if (!isReady || !hasCheckedReferrer) return null
return (
<Alf theme={theme}>
@@ -118,7 +118,7 @@ function InnerApp() {
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AgeAssuranceV2Provider>
<AgeAssuranceProvider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
@@ -157,7 +157,7 @@ function InnerApp() {
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</AgeAssuranceProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
@@ -174,13 +174,14 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
setReady(true),
)
Promise.all([
initPersistedState(),
ensureGeolocationConfigIsResolved(),
]).then(() => setReady(true))
}, [])
if (!isReady) {
return <Splash isReady />
return null
}
/*
@@ -188,31 +189,29 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<Geo.Provider>
<GeolocationProvider>
<A11yProvider>
<OnboardingProvider>
<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>
</A11yProvider>
</Geo.Provider>
</GeolocationProvider>
)
}
-29
View File
@@ -1,29 +0,0 @@
/*
* 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>
)
}
-3
View File
@@ -1,3 +0,0 @@
export const prefetchAgeAssuranceData = () => {}
export const setBirthdateForDid = () => {}
export const setCreatedAtForDid = () => {}
@@ -1,355 +0,0 @@
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>
</>
)
}
@@ -1,334 +0,0 @@
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>
</>
)
}
-495
View File
@@ -1,495 +0,0 @@
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>
)
}
-84
View File
@@ -1,84 +0,0 @@
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
}
-110
View File
@@ -1,110 +0,0 @@
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>
)
}
-100
View File
@@ -1,100 +0,0 @@
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])
}
-53
View File
@@ -1,53 +0,0 @@
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
}
}
-74
View File
@@ -1,74 +0,0 @@
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,
})
}
},
})
}
@@ -1,29 +0,0 @@
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],
)
}
-88
View File
@@ -1,88 +0,0 @@
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
}
+1 -2
View File
@@ -25,8 +25,7 @@ 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
+24 -58
View File
@@ -5,20 +5,18 @@ 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 {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 {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {Button} from './Button'
import {Text} from './Typography'
export function AccountList({
onSelectAccount,
@@ -46,9 +44,9 @@ export function AccountList({
<View
pointerEvents={pendingDid ? 'none' : 'auto'}
style={[
a.rounded_lg,
a.rounded_md,
a.overflow_hidden,
a.border,
{borderWidth: 1},
t.atoms.border_contrast_low,
]}>
{accounts.map(account => (
@@ -60,7 +58,7 @@ export function AccountList({
isCurrentAccount={account.did === currentAccount?.did}
isPendingAccount={account.did === pendingDid}
/>
<View style={[a.border_b, t.atoms.border_contrast_low]} />
<View style={[{borderBottomWidth: 1}, t.atoms.border_contrast_low]} />
</React.Fragment>
))}
<Button
@@ -74,25 +72,22 @@ export function AccountList({
a.flex_1,
a.flex_row,
a.align_center,
a.p_lg,
a.gap_sm,
{height: 48},
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<View
<Text
style={[
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,
a.font_semi_bold,
a.flex_1,
a.flex_row,
a.py_sm,
a.leading_tight,
t.atoms.text_contrast_medium,
{paddingLeft: 56},
]}>
<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>
<ChevronIcon size="md" style={[t.atoms.text_contrast_low]} />
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
</View>
)}
</Button>
@@ -122,8 +117,6 @@ function AccountItem({
onSelect(account)
}, [account, onSelect])
const isLoggedOut = !account.refreshJwt || isJwtExpired(account.refreshJwt)
return (
<Button
testID={`chooseAccountBtn-${account.handle}`}
@@ -141,13 +134,14 @@ function AccountItem({
a.flex_1,
a.flex_row,
a.align_center,
a.p_lg,
a.px_md,
a.gap_sm,
{height: 56},
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
]}>
<UserAvatar
avatar={profile?.avatar}
size={48}
size={36}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
live={live}
hideLiveBadge
@@ -157,7 +151,7 @@ function AccountItem({
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text
emoji
style={[a.font_medium, a.leading_tight, a.text_md]}
style={[a.font_semi_bold, a.leading_tight]}
numberOfLines={1}>
{sanitizeDisplayName(
profile?.displayName || profile?.handle || account.handle,
@@ -172,43 +166,15 @@ function AccountItem({
</View>
)}
</View>
<Text
style={[
a.leading_tight,
t.atoms.text_contrast_medium,
a.text_sm,
]}>
<Text style={[a.leading_tight, t.atoms.text_contrast_medium]}>
{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 ? (
<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>
<Check size="sm" style={[{color: t.palette.positive_500}]} />
) : (
<ChevronIcon size="md" style={[t.atoms.text_contrast_low]} />
<Chevron size="sm" style={[t.atoms.text]} />
)}
</View>
)}
-1
View File
@@ -48,7 +48,6 @@ export function AppLanguageDropdown() {
})}
variant="ghost"
color="secondary"
shape="rectangular"
style={[
a.pr_xs,
a.pl_sm,
+193
View File
@@ -0,0 +1,193 @@
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 />
</>
)
}
+44 -90
View File
@@ -39,7 +39,7 @@ export type ButtonColor =
| 'primary_subtle'
| 'negative_subtle'
export type ButtonSize = 'tiny' | 'small' | 'large'
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
export type ButtonShape = 'round' | 'square' | 'default'
export type VariantProps = {
/**
* The style variation of the button
@@ -56,11 +56,6 @@ 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
}
@@ -439,26 +434,6 @@ 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,
@@ -531,10 +506,9 @@ export const Button = React.forwardRef<View, ButtonProps>(
variant,
color,
size,
shape,
disabled: disabled || false,
}),
[state, variant, color, size, shape, disabled],
[state, variant, color, size, disabled],
)
return (
@@ -775,79 +749,59 @@ export function ButtonIcon({
position?: 'left' | 'right'
size?: SVGIconProps['size']
}) {
const {size: buttonSize, shape: buttonShape} = useButtonContext()
const {size: buttonSize} = useButtonContext()
const textStyles = useSharedButtonTextStyles()
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
>)
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
>)
/*
* 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]
/*
* 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]
/*
* 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']
/*
* 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 {
iconSize,
iconContainerSize,
}
}, [buttonSize, size])
return (
<View
style={[
a.z_20,
{
width: size === '2xs' ? 10 : iconContainerSize,
width: iconContainerSize,
height: iconContainerSize,
marginLeft: iconNegativeMargin,
marginRight: iconNegativeMargin,
},
]}>
<View
+2 -21
View File
@@ -13,7 +13,6 @@ 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>({
@@ -51,28 +50,10 @@ export function useDialogControl(): DialogOuterProps['control'] {
id,
ref: control,
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',
)
}
}
control.current.open()
},
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',
)
}
}
control.current.close(cb)
},
}),
[id, control],
+3 -4
View File
@@ -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, flatten, useBreakpoints, useTheme, web} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Context} from '#/components/Dialog/context'
import {
@@ -180,8 +180,7 @@ export function Inner({
onClick={stopPropagation}
onStartShouldSetResponder={_ => true}
onTouchEnd={stopPropagation}
// note: flatten is required for some reason -sfn
style={flatten([
style={[
a.relative,
a.rounded_md,
a.w_full,
@@ -196,7 +195,7 @@ export function Inner({
},
!reduceMotionEnabled && a.zoom_fade_in,
style,
])}>
]}>
<DismissableLayer.DismissableLayer
onInteractOutside={preventDefault}
onFocusOutside={preventDefault}
+2 -4
View File
@@ -129,6 +129,7 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
size="small"
variant="ghost"
color="secondary"
shape="square"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[
@@ -163,10 +164,7 @@ export function MenuButton() {
shape="square"
onPress={onPress}
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
]}>
style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}]}>
<ButtonIcon icon={Menu} size="lg" />
</Button>
</Slot>
+3 -5
View File
@@ -421,7 +421,6 @@ export function SimpleInlineLinkText({
label,
disableUnderline,
shouldProxy,
onPress: outerOnPress,
...rest
}: Omit<
InlineLinkProps,
@@ -429,6 +428,7 @@ export function SimpleInlineLinkText({
| 'action'
| 'disableMismatchWarning'
| 'overridePresentation'
| 'onPress'
| 'onLongPress'
| 'shareOnLongPress'
> & {
@@ -448,9 +448,7 @@ export function SimpleInlineLinkText({
href = createProxiedUrl(href)
}
const onPress = (e: GestureResponderEvent) => {
const exitEarlyIfFalse = outerOnPress?.(e)
if (exitEarlyIfFalse === false) return
const onPress = () => {
Linking.openURL(href)
}
@@ -519,7 +517,7 @@ export function WebOnlyInlineLinkText({
export function createStaticClick(
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
): {
to: string
to: BaseLinkProps['to']
onPress: Exclude<BaseLinkProps['onPress'], undefined>
} {
return {
-27
View File
@@ -4,10 +4,6 @@ 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'
@@ -133,9 +129,6 @@ let ListMaybePlaceholder = ({
hideBackButton,
sideBorders,
topBorder = false,
emptyStateIcon,
emptyStateButton,
useEmptyState = false,
}: {
isLoading: boolean
noEmpty?: boolean
@@ -150,9 +143,6 @@ 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()
@@ -190,23 +180,6 @@ 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
+1 -1
View File
@@ -170,7 +170,7 @@ export function FollowsYou({size = 'sm'}: CommonProps) {
}, [size])
return (
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}>
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_25]}>
<Text style={[a.text_xs, a.leading_tight]}>
<Trans>Follows You</Trans>
</Text>
@@ -7,6 +7,7 @@ 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'
@@ -101,6 +102,7 @@ export function VideoEmbedInnerWeb({
hasSubtitleTrack={hasSubtitleTrack}
/>
</div>
<MediaInsetBorder />
</View>
)
}
+29 -9
View File
@@ -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} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -16,9 +16,11 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props {
embed: AppBskyEmbedVideo.View
crop?: 'none' | 'square' | 'constrained'
}
export function VideoEmbed({embed}: Props) {
export function VideoEmbed({embed, crop}: Props) {
const t = useTheme()
const [key, setKey] = useState(0)
const renderError = useCallback(
@@ -38,10 +40,13 @@ export function VideoEmbed({embed}: 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}>
@@ -51,13 +56,28 @@ export function VideoEmbed({embed}: Props) {
return (
<View style={[a.pt_xs]}>
<ConstrainedImage
aspectRatio={constrained || 1}
// slightly smaller max height than images
// images use 16 / 9, for reference
minMobileAspectRatio={14 / 9}>
{contents}
</ConstrainedImage>
{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>
)}
</View>
)
}
@@ -17,7 +17,6 @@ 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,
@@ -26,7 +25,13 @@ import {
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
export function VideoEmbed({
embed,
crop,
}: {
embed: AppBskyEmbedVideo.View
crop?: 'none' | 'square' | 'constrained'
}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
const {active, setActive, sendPosition, currentActiveView} =
@@ -71,10 +76,13 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
}
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
@@ -83,11 +91,8 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
display: 'flex',
flex: 1,
cursor: 'default',
backgroundColor: t.palette.black,
backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
}}
onClick={evt => evt.stopPropagation()}>
<ErrorBoundary renderError={renderError} key={key}>
@@ -109,15 +114,28 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
<ViewportObserver
sendPosition={sendPosition}
isAnyViewActive={currentActiveView !== null}>
<ConstrainedImage
fullBleed
aspectRatio={constrained || 1}
// slightly smaller max height than images
// images use 16 / 9, for reference
minMobileAspectRatio={14 / 9}>
{contents}
<MediaInsetBorder />
</ConstrainedImage>
{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>
)}
</ViewportObserver>
</View>
)
+1 -1
View File
@@ -112,7 +112,7 @@ function MediaEmbed({
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<VideoEmbed embed={embed.view} />
<VideoEmbed embed={embed.view} crop="constrained" />
</ContentHider>
)
}
+7 -6
View File
@@ -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 QuoteIcon} from '#/components/icons/Quote'
import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost'
import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
import {Repost_Stroke2_Corner3_Rounded as Repost} from '#/components/icons/Repost'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import {Text} from '#/components/Typography'
import {
@@ -84,7 +84,7 @@ let RepostButton = ({
}),
)
}>
<PostControlButtonIcon icon={RepostIcon} />
<PostControlButtonIcon icon={Repost} />
{typeof repostCount !== 'undefined' && repostCount > 0 && (
<PostControlButtonText testID="repostCount">
{formatPostStatCount(repostCount)}
@@ -156,7 +156,7 @@ let RepostButtonDialogInner = ({
size="large"
variant="ghost"
color="primary">
<RepostIcon size="lg" fill={t.palette.primary_500} />
<Repost 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">
<QuoteIcon
<Quote
size="lg"
fill={
embeddingDisabled
@@ -204,7 +204,8 @@ let RepostButtonDialogInner = ({
label={_(msg`Cancel quote post`)}
onPress={onPressClose}
size="large"
color="secondary">
variant="outline"
color="primary">
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
@@ -11,6 +11,7 @@ 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'
@@ -23,7 +24,6 @@ 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 aa = useAgeAssurance()
const {isAgeRestricted} = useAgeAssurance()
const postUri = post.uri
const postAuthor = useProfileShadow(post.author)
@@ -91,7 +91,7 @@ let ShareMenuItems = ({
return (
<>
<Menu.Outer>
{hasSession && aa.state.access === aa.Access.Full && (
{hasSession && !isAgeRestricted && (
<Menu.Group>
<Menu.ContainerItem>
<RecentChats postUri={postUri} />
@@ -10,6 +10,7 @@ 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'
@@ -21,7 +22,6 @@ 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 aa = useAgeAssurance()
const {isAgeRestricted} = useAgeAssurance()
const postUri = post.uri
const postCid = post.cid
@@ -97,7 +97,7 @@ let ShareMenuItems = ({
<Menu.Outer>
{!hideInPWI && copyLinkItem}
{hasSession && aa.state.access === aa.Access.Full && (
{hasSession && !isAgeRestricted && (
<Menu.Item
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
+2 -31
View File
@@ -1,11 +1,5 @@
import {useMemo} from 'react'
import {
type GestureResponderEvent,
type StyleProp,
type TextStyle,
View,
type ViewStyle,
} from 'react-native'
import {type GestureResponderEvent, View} from 'react-native'
import {
moderateProfile,
type ModerationOpts,
@@ -54,15 +48,11 @@ 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}>
@@ -70,8 +60,6 @@ export function Default({
profile={profile}
moderationOpts={moderationOpts}
logContext={logContext}
position={position}
contextProfileDid={contextProfileDid}
/>
</Link>
)
@@ -81,14 +69,10 @@ export function Card({
profile,
moderationOpts,
logContext = 'ProfileCard',
position,
contextProfileDid,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
position?: number
contextProfileDid?: string
}) {
return (
<Outer>
@@ -99,8 +83,6 @@ export function Card({
profile={profile}
moderationOpts={moderationOpts}
logContext={logContext}
position={position}
contextProfileDid={contextProfileDid}
/>
</Header>
@@ -289,13 +271,9 @@ 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(
@@ -304,7 +282,7 @@ export function Name({
)
const verification = useSimpleVerificationState({profile})
return (
<View style={[a.flex_row, a.align_center, a.max_w_full, style]}>
<View style={[a.flex_row, a.align_center, a.max_w_full]}>
<Text
emoji
style={[
@@ -313,7 +291,6 @@ export function Name({
a.leading_snug,
a.self_start,
a.flex_shrink,
textStyle,
]}
numberOfLines={1}>
{name}
@@ -460,8 +437,6 @@ export type FollowButtonProps = {
colorInverted?: boolean
onFollow?: () => void
withIcon?: boolean
position?: number
contextProfileDid?: string
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
@@ -478,8 +453,6 @@ export function FollowButtonInner({
onFollow,
colorInverted,
withIcon = true,
position,
contextProfileDid,
...rest
}: FollowButtonProps) {
const {_} = useLingui()
@@ -488,8 +461,6 @@ 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 {useActorSearch} from '#/state/queries/actor-search'
import {useActorSearchPaginated} 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,
} = useActorSearch({
} = useActorSearchPaginated({
enabled: !!searchText,
query: searchText,
})
@@ -0,0 +1,85 @@
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>
)
}
@@ -0,0 +1,195 @@
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>
)
}
+274
View File
@@ -0,0 +1,274 @@
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
View File
@@ -0,0 +1 @@
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
+97
View File
@@ -0,0 +1,97 @@
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} />
}
+16
View File
@@ -0,0 +1,16 @@
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'}
}
+3 -3
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {type StyleProp, type TextStyle} from 'react-native'
import {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?: StyleProp<TextStyle>
interactiveStyle?: 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 (
+1 -1
View File
@@ -106,7 +106,7 @@ export function Trigger({children, label}: TriggerProps) {
style={[a.flex_1, a.justify_between]}
color="secondary"
size="small"
shape="rectangular">
variant="solid">
<>{children}</>
</Button>
)
+8 -18
View File
@@ -1,4 +1,4 @@
import {createContext, forwardRef, Fragment, useContext, useMemo} from 'react'
import {createContext, forwardRef, useContext, useMemo} from 'react'
import {View} from 'react-native'
import {Select as RadixSelect} from 'radix-ui'
@@ -96,7 +96,8 @@ export function Trigger({children, label}: TriggerProps) {
style={flatten([
a.flex,
a.relative,
t.atoms.bg_contrast_50,
t.atoms.bg_contrast_25,
a.rounded_sm,
a.w_full,
a.align_center,
a.gap_sm,
@@ -105,14 +106,15 @@ 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
: t.palette.contrast_50,
: hovered
? t.palette.contrast_100
: t.palette.contrast_25,
},
])}>
{children}
@@ -138,11 +140,7 @@ export function Icon({style}: IconProps) {
)
}
export function Content<T>({
items,
renderItem,
valueExtractor = defaultItemValueExtractor,
}: ContentProps<T>) {
export function Content<T>({items, renderItem}: ContentProps<T>) {
const t = useTheme()
const selectedValue = useContext(SelectedValueContext)
@@ -200,11 +198,7 @@ export function Content<T>({
<ChevronUpIcon style={[t.atoms.text]} size="xs" />
</RadixSelect.ScrollUpButton>
<RadixSelect.Viewport style={flatten([a.p_xs])}>
{items.map((item, index) => (
<Fragment key={valueExtractor(item)}>
{renderItem(item, index, selectedValue)}
</Fragment>
))}
{items.map((item, index) => renderItem(item, index, selectedValue))}
</RadixSelect.Viewport>
<RadixSelect.ScrollDownButton style={flatten(down)}>
<ChevronDownIcon style={[t.atoms.text]} size="xs" />
@@ -215,10 +209,6 @@ export function Content<T>({
)
}
function defaultItemValueExtractor(item: any) {
return item.value
}
const ItemContext = createContext<{
hovered: boolean
focused: boolean
@@ -9,7 +9,6 @@ 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
@@ -34,13 +33,7 @@ export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
}))
const renderPostsEmpty = useCallback(() => {
return (
<EmptyState
icon={HashtagWideIcon}
iconSize="2xl"
message={_(msg`This feed is empty.`)}
/>
)
return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
}, [_])
return (
@@ -21,10 +21,6 @@ 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'
@@ -51,9 +47,6 @@ 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) {
@@ -70,9 +63,6 @@ export function ProfileStarterPacks({
testID,
setScrollViewTag,
isMe,
emptyStateMessage,
emptyStateButton,
emptyStateIcon,
}: ProfileFeedgensProps) {
const t = useTheme()
const bottomBarOffset = useBottomBarOffset(100)
@@ -89,28 +79,6 @@ 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: () => {},
@@ -178,7 +146,7 @@ export function ProfileStarterPacks({
onEndReached={onEndReached}
onRefresh={onRefresh}
ListEmptyComponent={
data ? (isMe ? EmptyComponent : undefined) : FeedLoadingPlaceholder
data ? (isMe ? Empty : undefined) : FeedLoadingPlaceholder
}
ListFooterComponent={
!!data && items?.length !== 0 && isMe ? CreateAnother : undefined
-19
View File
@@ -1,19 +0,0 @@
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
}
+9 -65
View File
@@ -12,11 +12,9 @@ import {useWindowDimensions, View} from 'react-native'
import Animated, {Easing, ZoomIn} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {atoms as a, select, useTheme} from '#/alf'
import {useOnGesture} from '#/components/hooks/useOnGesture'
import {createPortalGroup, Portal as RootPortal} from '#/components/Portal'
import {Portal} from '#/components/Portal'
import {
ARROW_HALF_SIZE,
ARROW_SIZE,
@@ -25,33 +23,6 @@ import {
} from '#/components/Tooltip/const'
import {Text} from '#/components/Typography'
const TooltipPortal = createPortalGroup()
const TooltipProviderContext =
createContext<React.RefObject<View | null> | null>(null)
/**
* Provider for Tooltip component. Only needed when you need to position the tooltip relative to a container,
* such as in the composer sheet.
*
* Only really necessary on iOS but can work on Android.
*/
export function SheetCompatProvider({children}: {children: React.ReactNode}) {
const ref = useRef<View | null>(null)
return (
<GlobalGestureEventsProvider style={[a.flex_1]}>
<TooltipPortal.Provider>
<View ref={ref} collapsable={false} style={[a.flex_1]}>
<TooltipProviderContext value={ref}>
{children}
</TooltipProviderContext>
</View>
<TooltipPortal.Outlet />
</TooltipPortal.Provider>
</GlobalGestureEventsProvider>
)
}
SheetCompatProvider.displayName = 'TooltipSheetCompatProvider'
/**
* These are native specific values, not shared with web
*/
@@ -149,46 +120,22 @@ export function Outer({
export function Target({children}: {children: React.ReactNode}) {
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
const [hasLayedOut, setHasLayedOut] = useState(false)
const targetRef = useRef<View>(null)
const containerRef = useContext(TooltipProviderContext)
const keyboardIsOpen = useIsKeyboardVisible()
useEffect(() => {
if (!shouldMeasure || !hasLayedOut) return
if (!shouldMeasure) return
/*
* Once opened, measure the dimensions and position of the target
*/
if (containerRef?.current) {
targetRef.current?.measureLayout(
containerRef.current,
(x, y, width, height) => {
if (x !== undefined && y !== undefined && width && height) {
setTargetMeasurements({x, y, width, height})
}
},
)
} else {
targetRef.current?.measure((_x, _y, width, height, x, y) => {
if (x !== undefined && y !== undefined && width && height) {
setTargetMeasurements({x, y, width, height})
}
})
}
}, [
shouldMeasure,
setTargetMeasurements,
hasLayedOut,
containerRef,
keyboardIsOpen,
])
targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
if (pageX !== undefined && pageY !== undefined && width && height) {
setTargetMeasurements({x: pageX, y: pageY, width, height})
}
})
}, [shouldMeasure, setTargetMeasurements])
return (
<View
collapsable={false}
ref={targetRef}
onLayout={() => setHasLayedOut(true)}>
<View collapsable={false} ref={targetRef}>
{children}
</View>
)
@@ -203,15 +150,12 @@ export function Content({
}) {
const {position, visible, onVisibleChange} = useContext(TooltipContext)
const {targetMeasurements} = useContext(TargetContext)
const isWithinProvider = !!useContext(TooltipProviderContext)
const requestClose = useCallback(() => {
onVisibleChange(false)
}, [onVisibleChange])
if (!visible || !targetMeasurements) return null
const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal
return (
<Portal>
<Bubble

Some files were not shown because too many files have changed in this diff Show More