Compare commits

...

80 Commits

Author SHA1 Message Date
Eric Bailey f4f0a487e7 Update copy 2025-12-04 16:27:22 -06:00
Eric Bailey 3dee9e75f9 Add derived flags, add back disabled adult content for users under 18 2025-12-04 16:18:00 -06:00
Eric Bailey c4aef9f668 Age Assurance V2 (#9479)
* Age Assurance V2

* Tighten up test

* Add todos for sdk migration

* Align RQ versions

* Use useEffect for side effect

* Improve effects, memoize

* Standarize on birthdate

* Copy feedback

* Copilot

* Add support link

* Reove double ..

* Cleanup

* Remove redirect dialog

* Cleanup todos, add comments

* Update splash in main template too

* Mock some stuff

* Exhaustive checks

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Exhaustive checks

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Small fix to bday handling

* Add comment

* onboarding style tweak

sneaking this in sorry!

* rm unreachable breaks

* Put useIntentHandler back on web

* Remove misleading success set

* Align on birthdate

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-12-04 15:20:00 -06:00
Jim Calabro 7735183af4 Merge pull request #9475 from bluesky-social/jc/cache
Use FromCache Rather Than FromFile on Template Render
2025-12-03 16:47:46 -05:00
pfrazee f0c9e1ea5e Nightly source-language update 2025-12-03 02:41:44 +00:00
Samuel Newman 4d1b1bb2fd Catch crop cancelled errors (#9451) 2025-12-02 10:06:12 +02:00
pfrazee 507531e1db Nightly source-language update 2025-12-02 02:41:53 +00:00
Samuel Newman 0ba4edab8d add dot separated time style (#9455) 2025-12-01 17:52:19 +02:00
pfrazee 1eacf427b5 Nightly source-language update 2025-11-28 02:39:29 +00:00
Anastasiya Uraleva d0649e9a9e [APP-1403] Profile empty states (#8969)
* update: Empty state component

* update type error fixes

* add empty state icon

* update translations on labels

* update: lint error for empty state

* remove unused prompt

* update type errors

* fix lint errors and update profile followers and profile follows

* updated starterpack

* fix lint errors

* address feedback

* update icons to be a react element

* update icons

* update viewbox for icons

* optimize: icons and update profile lists

* optimize: icons and update profile lists

* lint error fix

* update iconSize from the rebase
2025-11-27 03:22:03 -08:00
pfrazee 2d056e5be6 Nightly source-language update 2025-11-27 02:40:10 +00:00
Samuel Newman 7f2d46b731 use contentFit prop (#9452) 2025-11-26 09:06:12 -08:00
bnewbold 6ab3751b5c update golang to v1.25 (and some containers do debian bookworm) (#9442)
* bskyweb: bump go to v1.25

* update docker images to go v1.25 and debian bookworm

* bump CI to go v1.25
2025-11-25 10:35:50 -08:00
pfrazee 8424083de9 Nightly source-language update 2025-11-23 02:47:44 +00:00
Samuel Newman 54af170448 Use new blue everywhere (#9435) 2025-11-23 00:20:37 +02:00
Samuel Newman 8b00b8f945 Split React Native patches into individual patches (#9429) 2025-11-22 23:49:57 +02:00
tomsqrd 9136be68b3 Reading the optional String extras from attachment share intents (#9396)
and adding them to the compose intents for images and videos if they exist.

Co-authored-by: Tom Quinders <tom.quinders@check24.de>
2025-11-22 03:03:30 -08:00
Samuel Newman af13c70444 bump version to v1.111 (#9431) 2025-11-21 22:56:49 +02:00
Samuel Newman 9330e21c6a patch in upstream expo updates fix (#9428) 2025-11-21 07:15:23 -08:00
Samuel Newman 5d220e49a0 [v1.110.1 release prep] Translations (#9426)
* `yarn intl:pull`

* `yarn intl:extract:all`
2025-11-20 10:23:36 -08:00
Samuel Newman 31fac4a62b Use macos-26-xlarge runner, update actions to resolve caching issues (#9411)
* use xlarge runner for macos build

* try and fix yarn cache

* update actions/cache for pods step

* use expo github action main rather than v8

* update all actions to the same

* use yarn cache where missing
2025-11-20 10:07:38 -08:00
Samuel Newman 13c4ef83f1 Add minimal header to profiles (#8936) 2025-11-20 18:31:41 +02:00
Samuel Newman dfe9f9fa07 use slightly more rounded repost icon (#9424) 2025-11-20 18:00:14 +02:00
Samuel Newman 2919a7da7f Search lists cleanup, pagninate actor search (#9009) 2025-11-20 17:59:20 +02:00
Samuel Newman 2792b4cef0 prevent webview from showing during device attest (#9425) 2025-11-20 17:54:02 +02:00
Samuel Newman f5a5eee32c handle share intents when app first launches (#9410) 2025-11-20 17:52:33 +02:00
Samuel Newman 9823bc98e9 fix android 2fa input not being paste-able (#9407) 2025-11-20 17:49:58 +02:00
Samuel Newman f57268e9b8 Use useConfirmEmail hook in deep link verification flow (#9413)
* use useConfirmEmail hook in deep link verification flow

* more straightforward logic
2025-11-20 07:48:13 -08:00
Alex Benzer 002a63b125 Post view client event (#9408)
* Adds post:view client event tracking in feeds

* Add post:view event on the post page itself

* Don't send post:view to statsig for now

* convert to non reactive callback to reduce rerenders

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-11-20 07:41:56 -08:00
Samuel Newman 2faf62c7f7 Update version to v1.110.1 (#9416) 2025-11-19 13:21:56 -08:00
Eric Bailey 7b99d8c7c3 Update Android build runner (#9404) 2025-11-17 16:36:03 -06:00
Samuel Newman c2979ffefa re-extract languages (#9402) 2025-11-17 11:00:06 -08:00
Samuel Newman bc62b3da76 Add metrics for composer threadgate (#9401)
* add metrics for composer threadgate

* add metrics to the in-thread version

* fix type error
2025-11-17 10:51:01 -08:00
Samuel Newman 53b3bd93a5 [1.110.0 release prep] Translations (#9400)
* `yarn intl:pull`

* `yarn intl:extract:all`
2025-11-17 08:37:07 -08:00
Samuel Newman 7197a064d7 [v1.110] Release prep (#9399)
* yml formatting whatever

* fix date input color

* fix up report dialog tests

* dumb fix to bypass save password popup

* remove tooltip in e2e

* Update index.e2e.tsx

* skip gate in e2e
2025-11-17 08:01:02 -08:00
Samuel Newman ef97a20c23 Fix password autofill on iOS (#9397)
* fix password autofill on iOS

* update autocomplete props, rm textContentType
2025-11-17 06:27:00 -08:00
Samuel Newman f53d5b6f27 add haptics to segmented control (#9398) 2025-11-17 05:23:28 -08:00
Alex Benzer 084f60c88f Profile follow client events (#9385)
* Add parameters to profile:follow

Track who was followed, whose profile generated the follow, and the position of the person who was followed in the list

* Add profileCard:seen event

* Don't send "profileCard:seen" event to Statsig

* Clean up

* prevent overzealous clearing

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-11-17 05:04:11 -08:00
Alex Benzer 2f678a3ccd Adds a "follow back" button to follow notifications (#9359)
* Adds a "follow back" button to follow notifications

* get shadowcache logic working, strip out manual optimistic update

* whoops, don't just stick any old profile in there

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-11-17 05:00:26 -08:00
Samuel Newman d464cde412 add transparency prop to tab bar (#9336) 2025-11-17 04:59:56 -08:00
surfdude29 a35a81a508 Change text on threadgate button from 'Anybody' to 'Anyone' (#9390) 2025-11-17 03:27:43 -08:00
surfdude29 4145878819 remove accessibilityHint and tweak labels (#9391) 2025-11-17 03:26:39 -08:00
Alex Benzer fb058afa54 Add client event for clicks on desktop feeds (#9386) 2025-11-17 03:23:56 -08:00
pfrazee 185fd39092 Nightly source-language update 2025-11-15 02:38:20 +00:00
Samuel Newman 92926a2417 SegmentedControl component (#8606)
* new segmented control

* fix type error

* convert server input, use CSS for web

* add segmented control to storybook

* use segmented control in embed dialog

* add to suggested text wrappers

* update change handle dialog

* update styles since button changes

* fix atom

* style updates to segmented control, add size prop

* update state in layout effect rather than in render

* set type = 'radio' as default

* prevent expansion in server dialog on iOS

* use non reactive callback in needsUpdate effect
2025-11-14 11:43:28 -08:00
Samuel Newman 7a8ab551e3 update crop tool to latest, adjust to API changes (#9389) 2025-11-14 11:05:31 -08:00
Samuel Newman 1caac024ab Fix video letterboxing issue (#9339)
* give video a black background on web

* Video crop on web tweaks (#9371)

* Remove video embed crop option to reduce confusion

* Improve default thumb and border on web

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-11-14 10:29:49 -08:00
Samuel Newman 24c96ac881 Image saving - fix extension, increase download timeout (#9388) 2025-11-14 17:46:39 +02:00
Samuel Newman c92ac4abc1 flatten may return undefined (#9387) 2025-11-14 17:46:20 +02:00
Samuel Newman b422765aed [Threadgate] Tweak threadgate buttons (#9173)
* tweak in-post threadgate button

* tweak composer threadgate button

* reduce date length slightly

* pressed styles

* make date length depend on breakpoint

* add chevron to label btn

* add tiny chevron, special-case button icon width

* [Threadgate] Add hint (#9350)

* get tooltip working on web

* add compatibility layer for working in iOS sheets

* add timeout to profile tooltip now that it appears instantly

* rm debug code

* Update ThreadgateBtn.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* remeasure when keyboard changes

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix types

* [Threadgate] Refresh dialog (#9342)

* wip new ui

* update threadgate dialog with new designs

* restore nobody option

* relayout android sheet when ratio changes

* fix ratio changing case in bottom sheet

* timebox reached, use setTimeout

* update panel styles

* missing imports

* extract out Panel

* tweak layout animation

* fix icon color

* use same color mechamism for icon as text

* restore the header

* refreshed toggle styles (#9343)

* [Threadgate] Persist settings (#9341)

* add persist toggle to threadgate dialog

* move state back down

* sort out spacing

* wire up query

* @surfdude29 tweaks

* use tiny chevron in WhoCanReply

* wait for prefetch before opening

* move Panel into the Toggle namespace

* default -> pref

* use medium date length

* rm hover state from web selects, fix border radius

* fix key issue in Selects

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2025-11-14 06:21:37 -08:00
Samuel Newman b56ee74cdb Tweak bg on follows you pill (#9384)
* tweak bg on follows you pill

* same for profile
2025-11-13 12:01:49 -08:00
Samuel Newman 554275957e update button spacing (#9383) 2025-11-13 09:24:27 -08:00
pfrazee 4884677826 Nightly source-language update 2025-11-13 02:41:07 +00:00
Samuel Newman e663e824b7 Add rectangular shape to button (#9382) 2025-11-13 00:35:34 +02:00
Samuel Newman 68a4d84450 tweak settings divider color (#9381) 2025-11-12 04:09:57 -08:00
pfrazee 9982961b8f Nightly source-language update 2025-11-12 02:40:28 +00:00
Eric Bailey 1711626493 Migrate remaining old report reasons, remove old unused file (#9378) 2025-11-11 14:13:11 -06:00
Samuel Newman cb2af3cd18 only round buttons if size is set (#9377) 2025-11-11 10:26:49 -06:00
Samuel Newman 59ffaa8a81 fix rounded notifs (#9374) 2025-11-11 03:18:43 -08:00
Samuel Newman a275e8999e restore flatten in emoji reaction picker (#9375) 2025-11-11 03:18:24 -08:00
pfrazee 8d2d45508d Nightly source-language update 2025-11-11 02:41:06 +00:00
Samuel Newman ddf82dcc44 Delete EmptyStateWithButton.tsx (#9348) 2025-11-10 18:02:52 -06:00
Samuel Newman 835a716eaf Try and guard against play/pause function being called after player is destroyed (#9364)
* try and guard play/pause against unmount

* add try/catch to avoid fatal crashes
2025-11-10 18:02:13 -06:00
Samuel Newman bb365856c9 fix react query error (#9365) 2025-11-10 18:02:01 -06:00
Samuel Newman bcd0e40192 Make the buttons round again (#9367)
* make the base button round

* some miscalleaneous tweaks
2025-11-10 18:01:41 -06:00
Samuel Newman 5208aa4a65 rm unused follow button (#9332) 2025-11-10 16:37:01 -06:00
surfdude29 1cdb105af5 tweak accessibility labels (#9347) 2025-11-10 16:31:08 -06:00
Samuel Newman ad91029f10 Select account redesign (#8905)
* new designs for select account

* tweak web styles

* add logged out indicator

* unify helper function

* move error, try catch
2025-11-10 06:02:53 -08:00
Alex Benzer 4791486aa7 Fixes a ts error caused by WelcomeModal styles on logged-out homepage (#9362)
* Fixes a ts error caused by welcomemodal styles on logged-out homepage

* use flatten util instead

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-11-10 03:18:31 -08:00
Samuel Newman df00ffa532 use feed module header bottom border on web (#9363) 2025-11-10 03:16:45 -08:00
pfrazee 70ccd82745 Nightly source-language update 2025-11-10 02:42:45 +00:00
Caidan 14604f735b fix: feed title line heights (#9346) 2025-11-09 18:24:12 +02:00
pfrazee 9583c310b9 Nightly source-language update 2025-11-07 02:40:28 +00:00
hailey d6ca9e6b41 [Reporting] Add new reporting categories and reasons (#9079)
* start adding new report reasons

* cleanup

* use updated types

* cleanup open/closed logic

* other fix

* state fix

* rename type

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/index.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/moderation/ReportDialog/utils/useReportOptions.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add mapping

* Replace consts and maps using exported values

* Map to deprecated reason if necessary, send some reports only to Bluesky

* Use only new report reason types

* Cleanup

* Bump package, integrate new reasons

* Adjust sorting, return type, integrate feedback

* Just remove sort

* Finish 'other' handling

* Update descriptions

* Just invert filter logic, no functional change but feels more correct

* Clarify handling

* fix deep imports

* fix type error

* [Reporting] Integrate new `ReportDialog` into DMs (#9213)

* Update types, fork copy for convo vs message

* Integrate in remaining spots

* Replace DoneStep with separate dialog that opens after reporting

* Handle profile error state by just showing a success message

* Delete old dms ReportDialog

* Chats only go to bsky

* Only open block or delete dialog if submitted successfully

* make close button always large

* prevent expansion of blockordelete dialog

* Delete old report dialog (#9214)e

* Skip labeler selection for Bluesky-only cases

* Rename AfterReportDialog

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-11-06 15:40:13 -06:00
David Buchanan fd14bbfef9 move yt embed script to its own source file (#9337) 2025-11-06 10:15:49 -08:00
Samuel Newman 9f98e53541 Revert "push in post-post toast (#9252)" (#9344)
This reverts commit 54a388d132.
2025-11-06 09:40:31 -08:00
Eric Bailey 7bf00424c4 Clean up getPostThreadV2 settings/params in prep for future work (#9179)
* Deprecate prioritizeFollowedUsers

* Bump API package
2025-11-06 10:42:08 -06:00
Samuel Newman ec2c580aae Fix Android crash by try/catch dialog open (#9335) 2025-11-06 17:58:14 +02:00
pfrazee 215f97bad9 Nightly source-language update 2025-11-06 02:41:01 +00:00
Samuel Newman 531ff6c433 restore flatten to a place that is needed (#9334) 2025-11-05 02:07:01 -08:00
354 changed files with 55494 additions and 48309 deletions
-3
View File
@@ -36,6 +36,3 @@ EXPO_PUBLIC_BITDRIFT_API_KEY=
# bapp-config web worker URL
BAPP_CONFIG_DEV_URL=
# Dev-only passthrough value for bapp-config web worker
BAPP_CONFIG_DEV_BYPASS_SECRET=
+1
View File
@@ -43,6 +43,7 @@ module.exports = {
suggestedTextWrappers: {
Button: 'ButtonText',
'ToggleButton.Button': 'ToggleButton.ButtonText',
'SegmentedControl.Item': 'SegmentedControl.ItemText',
},
},
],
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -3,9 +3,9 @@ on:
workflow_dispatch:
pull_request:
paths:
- 'bskylink/**'
- 'Dockerfile.bskylink'
- '.github/workflows/build-and-push-link-aws.yaml'
- "bskylink/**"
- "Dockerfile.bskylink"
- ".github/workflows/build-and-push-link-aws.yaml"
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -3,9 +3,9 @@ on:
workflow_dispatch:
pull_request:
paths:
- 'bskyogcard/**'
- 'Dockerfile.bskyogcard'
- '.github/workflows/build-and-push-ogcard-aws.yaml'
- "bskyogcard/**"
- "Dockerfile.bskyogcard"
- ".github/workflows/build-and-push-ogcard-aws.yaml"
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
+4 -4
View File
@@ -15,7 +15,7 @@ jobs:
build:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android
runs-on: ubuntu-latest
runs-on: Linux-x64-32core
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -30,7 +30,7 @@ jobs:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
@@ -39,7 +39,7 @@ jobs:
uses: dcarbone/install-jq-action@v2
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
uses: expo/expo-github-action@main
with:
expo-version: latest
eas-version: latest
@@ -54,7 +54,7 @@ jobs:
java-version: "17"
- name: ⚙️ Install dependencies
run: yarn install
run: yarn install --frozen-lockfile
- name: 🔤 Compile translations
run: yarn intl:build 2>&1 | tee i18n.log
+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
runs-on: macos-26-xlarge
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -30,7 +30,7 @@ jobs:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
@@ -39,7 +39,7 @@ jobs:
uses: dcarbone/install-jq-action@v2
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
uses: expo/expo-github-action@main
with:
expo-version: latest
eas-version: latest
@@ -49,7 +49,7 @@ jobs:
run: yarn global add eas-cli-local-build-plugin
- name: ⚙️ Install dependencies
run: yarn install
run: yarn install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@v1
with:
@@ -61,7 +61,7 @@ jobs:
version: 1.16.2
- name: 💾 Cache Pods
uses: actions/cache@v3
uses: actions/cache@v4
id: pods-cache
with:
path: ./ios/Pods
@@ -55,7 +55,7 @@ jobs:
run: git fetch origin main:main --depth 100
- name: 🔧 Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
@@ -86,7 +86,7 @@ jobs:
run: yarn typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
uses: expo/expo-github-action@main
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
expo-version: latest
@@ -172,13 +172,13 @@ jobs:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
uses: expo/expo-github-action@main
with:
expo-version: latest
eas-version: latest
@@ -188,7 +188,7 @@ jobs:
run: yarn global add eas-cli-local-build-plugin
- name: ⚙️ Install dependencies
run: yarn install
run: yarn install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@v1
with:
@@ -200,7 +200,7 @@ jobs:
version: 1.16.2
- name: 💾 Cache Pods
uses: actions/cache@v3
uses: actions/cache@v4
id: pods-cache
with:
path: ./ios/Pods
@@ -276,13 +276,13 @@ jobs:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
uses: expo/expo-github-action@main
with:
expo-version: latest
eas-version: latest
@@ -297,7 +297,7 @@ jobs:
java-version: "17"
- name: ⚙️ Install dependencies
run: yarn install
run: yarn install --frozen-lockfile
- name: 🔤 Compile translations
run: yarn intl:build
+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@v3
uses: actions/checkout@v4
- name: Set up Go tooling
uses: actions/setup-go@v3
with:
go-version: '1.23'
go-version: "1.25"
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Check
@@ -32,11 +32,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Go tooling
uses: actions/setup-go@v3
with:
go-version: '1.23'
go-version: "1.25"
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Lint
+7 -5
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,11 +15,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
@@ -41,11 +42,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
@@ -1,7 +1,7 @@
name: Nightly Update Source Languages
on:
schedule:
- cron: '0 2 * * *' # run at 2 AM UTC
- cron: "0 2 * * *" # run at 2 AM UTC
workflow_dispatch:
jobs:
@@ -16,13 +16,14 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
- name: Install node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
@@ -46,4 +47,4 @@ jobs:
push_sources: false
create_pull_request: false
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+2 -2
View File
@@ -116,7 +116,7 @@ jobs:
ref: ${{ steps.pr-info.outputs.head-sha }}
- name: 🔧 Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
@@ -140,7 +140,7 @@ jobs:
run: yarn typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
uses: expo/expo-github-action@main
with:
expo-version: latest
eas-version: latest
+5 -5
View File
@@ -29,7 +29,7 @@ jobs:
fetch-depth: 0
- name: 🔧 Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
@@ -82,9 +82,9 @@ jobs:
id: get-diff
uses: NejcZdovc/bundle-size-diff@v1
with:
base_path: 'stats-base.json'
pr_path: '../stats-new.json'
excluded_assets: '(.+).chunk.js|(.+).js.map|(.+).json|(.+).png'
base_path: "stats-base.json"
pr_path: "../stats-new.json"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png"
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
@@ -110,7 +110,7 @@ jobs:
if: github.event_name == 'pull_request'
- name: 🔧 Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
+3 -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@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -20,7 +20,7 @@ jobs:
run: git fetch origin ${{ github.base_ref }} --depth=1
- name: Install node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
+2 -2
View File
@@ -1,4 +1,4 @@
FROM golang:1.24.5-bullseye AS build-env
FROM golang:1.25-bookworm AS build-env
WORKDIR /usr/src/social-app
@@ -89,7 +89,7 @@ RUN cd bskyweb/ && \
-o /bskyweb \
./cmd/bskyweb
FROM debian:bullseye-slim
FROM debian:bookworm-slim
ENV GODEBUG=netdns=go
ENV TZ=Etc/UTC
+2 -2
View File
@@ -1,4 +1,4 @@
FROM golang:1.24.5-bullseye AS build-env
FROM golang:1.25-bookworm AS build-env
WORKDIR /usr/src/social-app
@@ -58,7 +58,7 @@ RUN cd bskyweb/ && \
-o /embedr \
./cmd/embedr
FROM debian:bullseye-slim
FROM debian:bookworm-slim
ENV GODEBUG=netdns=go
ENV TZ=Etc/UTC
+8 -2
View File
@@ -31,9 +31,15 @@ appId: xyz.blueskyweb.app
text: "Your account"
- tapOn:
id: "nextBtn"
- waitForAnimationToEnd
- tapOn:
text: "Not Now"
optional: true
# save password thing no longer seems to be in hierarchy
# tap on something else instead
point: 20%,20%
repeat: 2
delay: 1000
- tapOn:
id: "handleInput"
- inputText: "e2e-test"
- extendedWaitUntil:
visible:
+3 -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"
+2 -1
View File
@@ -17,7 +17,8 @@ appId: xyz.blueskyweb.app
index: 0
- tapOn:
id: "postDropdownReportBtn"
- tapOn: "Create report for Misleading Post"
- tapOn: "Create report for Misleading"
- tapOn: "Create report for Spam"
- tapOn: "Send report to Dev-env Moderation"
- tapOn:
point: "50%,90%"
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
- runScript:
file: ../../setupServer.js
env:
SERVER_PATH: ?users&follows&posts&feeds
SERVER_PATH: ?users&follows&posts&feeds
- runFlow:
file: ../../setupApp.yml
- tapOn:
@@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "profileHeaderDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonSpam"
id: "report:category:Misleading"
- tapOn:
id: "report:option:Spam"
- assertVisible:
id: "report:labeler:mod-authority.test"
text: "Send report to Dev-env Moderation"
- tapOn:
id: "report:submit"
- assertNotVisible:
+5 -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:
@@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonSpam"
id: "report:category:Misleading"
- tapOn:
id: "report:option:Spam"
- assertVisible:
id: "report:labeler:mod-authority.test"
text: "Send report to Dev-env Moderation"
- tapOn:
id: "report:submit"
- assertNotVisible:
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
- runScript:
file: ../../setupServer.js
env:
SERVER_PATH: ?users&follows&posts&feeds
SERVER_PATH: ?users&follows&posts&feeds
- runFlow:
file: ../../setupApp.yml
- tapOn:
@@ -15,20 +15,28 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonOther"
id: "report:category:Misleading"
- tapOn:
id: "report:option:Other misleading content"
- assertVisible:
id: "report:labeler:mod-authority.test"
text: Your report will be sent to Dev-env Moderation.*
# reason "other" defaults with details open
- assertVisible:
id: "report:details"
- tapOn:
id: "report:clearOption"
id: "report:clearReportOption"
- assertNotVisible:
id: "report:details"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonSpam"
id: "report:clearCategory"
- assertNotVisible:
id: "report:option:Other misleading content"
- tapOn:
id: "report:category:Misleading"
- tapOn:
id: "report:option:Spam"
- assertVisible:
id: "report:labeler:mod-authority.test"
text: Your report will be sent to Dev-env Moderation.*
- tapOn:
id: "report:submit"
- assertNotVisible:
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
- runScript:
file: ../../setupServer.js
env:
SERVER_PATH: ?users&follows&posts&feeds
SERVER_PATH: ?users&follows&posts&feeds
- runFlow:
file: ../../setupApp.yml
- tapOn:
@@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonOther"
id: "report:category:Misleading"
- tapOn:
id: "report:option:Other misleading content"
- assertVisible:
id: "report:labeler:mod-authority.test"
text: "Send report to Dev-env Moderation"
# reason "other" defaults with details open
- assertVisible:
id: "report:details"
+1 -2
View File
@@ -1,4 +1,3 @@
# flow.yaml
appId: xyz.blueskyweb.app
@@ -33,7 +32,7 @@ appId: xyz.blueskyweb.app
direction: "LEFT"
- swipe:
direction: "LEFT"
- swipe:
- swipe:
direction: "LEFT"
- swipe:
direction: "RIGHT"
+1 -1
View File
@@ -1,4 +1,4 @@
import {LikelyType, getLikelyType} from '../../src/lib/link-meta/link-meta'
import {getLikelyType, LikelyType} from '../../src/lib/link-meta/link-meta'
describe('getLikelyType', () => {
it('correctly handles non-parsed url', async () => {
+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:123/app.bsky.graph.starterpack/rkey'
const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack/rkey'
expect(parseStarterPackUri(validAtUri)).toEqual({
name: 'did:123',
name: 'did:plc:123',
rkey: 'rkey',
})
})
it('returns null when the at uri has no rkey', () => {
const validAtUri = 'at://did:123/app.bsky.graph.starterpack'
const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack'
expect(parseStarterPackUri(validAtUri)).toEqual(null)
})
it('returns null when the collection is not app.bsky.graph.starterpack', () => {
const validAtUri = 'at://did:123/app.bsky.graph.list/rkey'
const validAtUri = 'at://did:plc:123/app.bsky.graph.list/rkey'
expect(parseStarterPackUri(validAtUri)).toEqual(null)
})
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 38"><path stroke="#405168" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22.333 31.667H45M22.333 6.333H45m-33.333 0A5.333 5.333 0 1 1 1 6.333a5.333 5.333 0 0 1 10.667 0Zm0 25.334a5.333 5.333 0 1 1-10.667 0 5.333 5.333 0 0 1 10.667 0Z"/></svg>

After

Width:  |  Height:  |  Size: 333 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 62 53"><path fill="#405168" d="M28.173.231a5.653 5.653 0 0 1 7.018 3.83l2.66 9.046a20 20 0 0 1 3.986-.397c11.026 0 19.964 8.937 19.964 19.962l-.006.516c-.274 10.787-9.104 19.448-19.958 19.448l-.514-.007c-8.332-.21-15.394-5.528-18.178-12.938l-8.805 2.59a5.654 5.654 0 0 1-7.02-3.83L.232 14.34a5.654 5.654 0 0 1 3.83-7.018L28.172.23ZM41.838 14.71c-1.17 0-2.313.111-3.42.325l3.863 13.137a5.653 5.653 0 0 1-3.83 7.019L25.07 39.126c2.593 6.732 9.122 11.51 16.768 11.51 9.92 0 17.963-8.043 17.963-17.964S51.758 14.71 41.837 14.71ZM33.271 4.624a3.653 3.653 0 0 0-4.535-2.474L4.624 9.24a3.653 3.653 0 0 0-2.475 4.535l7.09 24.113a3.654 3.654 0 0 0 4.536 2.475l8.762-2.577a20 20 0 0 1-.662-5.114c0-8.961 5.905-16.544 14.037-19.069l-2.64-8.98Zm3.204 10.899c-7.302 2.28-12.601 9.096-12.601 17.15 0 1.571.203 3.095.582 4.548l13.431-3.948a3.654 3.654 0 0 0 2.474-4.536l-3.886-13.214Z"/></svg>

After

Width:  |  Height:  |  Size: 944 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48"><path fill="#405168" d="M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z"/></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#405168" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.333 1 9 45M37 1l-5.333 44M1 11.667h44m0 22.666H1"/></svg>

After

Width:  |  Height:  |  Size: 224 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 50 45"><path stroke="#405168" stroke-linejoin="round" stroke-width="2" d="M49 17c0 15.333-22 26.667-24 26.667S1 32.333 1 17C1 6.333 7.667 1 14.333 1S25 5 25 5s4-4 10.667-4S49 6.333 49 17Z"/></svg>

After

Width:  |  Height:  |  Size: 262 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#080B12" stroke-linecap="round" stroke-width="1.5" d="m1.417 28.645 7.586-5.676a5.33 5.33 0 0 1 6.867.809c3.98 4.286 8.594 8.182 14.88 8.182 5.794 0 9.633-2.147 13.333-5.847m-38 18.637h33.334a5.333 5.333 0 0 0 5.333-5.333V6.083A5.333 5.333 0 0 0 39.417.75H6.083A5.333 5.333 0 0 0 .75 6.083v33.334a5.333 5.333 0 0 0 5.333 5.333ZM36.75 14.083a5.333 5.333 0 1 1-10.667 0 5.333 5.333 0 0 1 10.667 0Z"/></svg>

After

Width:  |  Height:  |  Size: 491 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 50 50"><path stroke="#405168" stroke-linecap="square" stroke-linejoin="round" stroke-width="2" d="M9 1h32a8 8 0 0 1 8 8v21.333a8 8 0 0 1-8 8H27.667L14.333 49V38.333H9a8 8 0 0 1-8-8V9a8 8 0 0 1 8-8Z"/></svg>

After

Width:  |  Height:  |  Size: 272 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 43 48"><path stroke="#405168" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.603 46.333H3.532c-1.572 0-2.816-1.358-2.472-2.891 2.033-9.046 9.421-15.775 19.543-15.775q1.367 0 2.666.16m18.667 7.84L36.603 41m0 0-5.334 5.333M36.603 41l-5.334-5.333M36.603 41l5.333 5.333m-12-36A9.333 9.333 0 1 1 20.603 1a9.333 9.333 0 0 1 9.333 9.333Z"/></svg>

After

Width:  |  Height:  |  Size: 429 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M10.928 18.882a1.95 1.95 0 0 0 2.452-.25l9-9a1.953 1.953 0 0 0-2.76-2.76L12 14.493l-7.62-7.62a1.952 1.952 0 0 0-2.76 2.76l9 9 .308.25Z"/></svg>

After

Width:  |  Height:  |  Size: 237 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#405168" stroke-linecap="square" stroke-linejoin="round" stroke-width="2" d="M1 23h10.667M1 23V12m0 11v11m10.667-11h22.666m-22.666 0v11m0-11V12m22.666 11H45m-10.667 0v12.222m0-12.222V12M45 23V12m0 11v12.222M34.333 45h5.334A5.333 5.333 0 0 0 45 39.667v-4.445M34.333 45v-9.778m0 9.778H11.667M34.333 1h5.334A5.333 5.333 0 0 1 45 6.333V12M34.333 1v11m0-11H11.667m22.666 11H45M34.333 35.222H45M11.667 45H6.333A5.333 5.333 0 0 1 1 39.667V34m10.667 11V34m0-33H6.333A5.333 5.333 0 0 0 1 6.333V12M11.667 1v11M1 12h10.667M1 34h10.667"/></svg>

After

Width:  |  Height:  |  Size: 619 B

+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.FromFile(name)
t, err = r.TemplateSet.FromCache(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/youtube.html", echo.WrapHandler(staticHandler))
e.GET("/iframe/*", echo.WrapHandler(staticHandler))
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Before(func() {
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/bluesky-social/social-app/bskyweb
go 1.24.5
go 1.25
require (
github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a
+1 -37
View File
@@ -16,40 +16,4 @@
}
</style>
<div class="container"><div class="video" id="player"></div></div>
<script>
const url = new URL(window.location)
const viewport = document.querySelector("meta[name=viewport]")
const tag = document.createElement("script")
tag.src = "https://www.youtube.com/iframe_api"
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
let player
function onYouTubeIframeAPIReady() {
let videoId = url.searchParams.get('videoId')
videoId = decodeURIComponent(videoId)
videoId = videoId.replace(/[^a-zA-Z0-9_-]/g, "")
if (videoId.length !== 11) throw new Error("Invalid video ID")
let start = url.searchParams.get('start')
start = start.replace(/[^0-9]/g, "")
player = new YT.Player('player', {
width: "1000",
height: "1000",
videoId,
playerVars: {
autoplay: 1,
start,
rel: 0,
loop: 0,
playsinline: 1,
origin: url.origin
},
});
}
function onPlayerReady(event) {
event.target.playVideo();
}
</script>
<script src="youtube.js"></script>
+35
View File
@@ -0,0 +1,35 @@
const url = new URL(window.location)
const viewport = document.querySelector("meta[name=viewport]")
const tag = document.createElement("script")
tag.src = "https://www.youtube.com/iframe_api"
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
let player
function onYouTubeIframeAPIReady() {
let videoId = url.searchParams.get('videoId')
videoId = decodeURIComponent(videoId)
videoId = videoId.replace(/[^a-zA-Z0-9_-]/g, "")
if (videoId.length !== 11) throw new Error("Invalid video ID")
let start = url.searchParams.get('start')
start = start.replace(/[^0-9]/g, "")
player = new YT.Player('player', {
width: "1000",
height: "1000",
videoId,
playerVars: {
autoplay: 1,
start,
rel: 0,
loop: 0,
playsinline: 1,
origin: url.origin
},
});
}
function onPlayerReady(event) {
event.target.playVideo();
}
+12 -4
View File
@@ -68,11 +68,19 @@
width: 100%;
}
#splash {
display: flex;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
align-items: center;
justify-content: center;
}
#splash svg {
position: relative;
top: -50px;
width: 100px;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%) translateY(-50px);
}
/**
* We need these styles to prevent shifting due to scrollbar show/hide on
@@ -106,7 +114,7 @@
<div id="root">
<div id="splash">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 320"><path fill="#0085ff" d="M180 142c-16.3-31.7-60.7-90.8-102-120C38.5-5.9 23.4-1 13.5 3.4 2.1 8.6 0 26.2 0 36.5c0 10.4 5.7 84.8 9.4 97.2 12.2 41 55.7 55 95.7 50.5-58.7 8.6-110.8 30-42.4 106.1 75.1 77.9 103-16.7 117.3-64.6 14.3 48 30.8 139 116 64.6 64-64.6 17.6-97.5-41.1-106.1 40 4.4 83.5-9.5 95.7-50.5 3.7-12.4 9.4-86.8 9.4-97.2 0-10.3-2-27.9-13.5-33C336.5-1 321.5-6 282 22c-41.3 29.2-85.7 88.3-102 120Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
</div>
</div>
@@ -243,13 +243,18 @@ class BottomSheetView(
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let {
val behavior = BottomSheetBehavior.from(it)
val currentState = behavior.state
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
val oldRatio = behavior.halfExpandedRatio
var newRatio = getHalfExpandedRatio(contentHeight)
behavior.halfExpandedRatio = newRatio
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
import {BottomSheet} from './src/BottomSheet'
import {
BottomSheetSnapPoint,
BottomSheetState,
BottomSheetViewProps,
type BottomSheetState,
type BottomSheetViewProps,
} from './src/BottomSheet.types'
import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent'
import {
@@ -112,9 +112,21 @@ export class BottomSheetNativeComponent extends React.Component<
onStateChange={this.onStateChange}
extraStyles={extraStyles}
onLayout={e => {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
this.updateLayout()
if (isIOS15) {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
}
if (Platform.OS === 'android') {
// TEMP HACKFIX: I had to timebox this, but this is Bad.
// On Android, if you run updateLayout() immediately,
// it will take ages to actually run on the native side.
// However, adding literally any delay will fix this, including
// a console.log() - just sending the log to the CLI is enough.
// TODO: Get to the bottom of this and fix it properly! -sfn
setTimeout(() => this.updateLayout())
} else {
this.updateLayout()
}
}}
/>
</Portal>
@@ -23,6 +23,10 @@ class ExpoReceiveAndroidIntentsModule : Module() {
ModuleDefinition {
Name("ExpoReceiveAndroidIntents")
OnCreate {
handleIntent(appContext.currentActivity?.intent)
}
OnNewIntent {
handleIntent(it)
}
@@ -74,10 +78,12 @@ class ExpoReceiveAndroidIntentsModule : Module() {
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
uri?.let {
when (type) {
AttachmentType.IMAGE -> handleImageIntents(listOf(it))
AttachmentType.VIDEO -> handleVideoIntents(listOf(it))
AttachmentType.IMAGE -> handleImageIntents(listOf(it), text)
AttachmentType.VIDEO -> handleVideoIntents(listOf(it), text)
}
}
}
@@ -99,15 +105,20 @@ class ExpoReceiveAndroidIntentsModule : Module() {
?.take(4)
}
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
uris?.let {
when (type) {
AttachmentType.IMAGE -> handleImageIntents(it)
AttachmentType.IMAGE -> handleImageIntents(it, text)
else -> return
}
}
}
private fun handleImageIntents(uris: List<Uri>) {
private fun handleImageIntents(
uris: List<Uri>,
text: String?
) {
var allParams = ""
uris.forEachIndexed { index, uri ->
@@ -120,15 +131,22 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
}
val encoded = URLEncoder.encode(allParams, "UTF-8")
val encodedUris = URLEncoder.encode(allParams, "UTF-8")
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
"bluesky://intent/compose?imageUris=$encoded".toUri().let {
var composeIntent = "bluesky://intent/compose?imageUris=$encodedUris"
encodedText?.let { composeIntent += "&text=$it" }
composeIntent.toUri().let {
val newIntent = Intent(Intent.ACTION_VIEW, it)
appContext.currentActivity?.startActivity(newIntent)
}
}
private fun handleVideoIntents(uris: List<Uri>) {
private fun handleVideoIntents(
uris: List<Uri>,
text: String?
) {
val uri = uris[0]
// If there is no extension for the file, substringAfterLast returns the original string - not
// null, so we check for that below
@@ -147,7 +165,12 @@ class ExpoReceiveAndroidIntentsModule : Module() {
val info = getVideoInfo(uri) ?: return
"bluesky://intent/compose?videoUri=${URLEncoder.encode(file.path, "UTF-8")}|${info["width"]}|${info["height"]}".toUri().let {
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
var composeIntent = "bluesky://intent/compose?videoUri=${URLEncoder.encode(file.path, "UTF-8")}|${info["width"]}|${info["height"]}"
encodedText?.let { composeIntent += "&text=$it" }
composeIntent.toUri().let {
val newIntent = Intent(Intent.ACTION_VIEW, it)
appContext.currentActivity?.startActivity(newIntent)
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.110.0",
"version": "1.111.0",
"private": true,
"engines": {
"node": ">=20"
@@ -72,7 +72,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.17.6",
"@atproto/api": "^0.18.4",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.5",
@@ -103,7 +103,7 @@
"@react-navigation/native-stack": "^7.3.13",
"@sentry/react-native": "~6.20.0",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "^5.8.1",
"@tanstack/react-query": "5.25.0",
"@tanstack/react-query-persist-client": "^5.25.0",
"@tiptap/core": "^2.9.1",
"@tiptap/extension-document": "^2.9.1",
@@ -143,7 +143,7 @@
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-crop-tool": "^0.1.8",
"expo-image-crop-tool": "^0.4.0",
"expo-image-manipulator": "~14.0.7",
"expo-image-picker": "~17.0.8",
"expo-intent-launcher": "~13.0.7",
@@ -176,7 +176,7 @@
"multiformats": "9.9.0",
"nanoid": "^5.0.5",
"normalize-url": "^8.0.0",
"patch-package": "^6.5.1",
"patch-package": "^8.0.1",
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"radix-ui": "^1.4.3",
+22 -1
View File
@@ -1,5 +1,26 @@
diff --git a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
index e9a8e3d..3c684e0 100644
--- a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
+++ b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
@@ -296,14 +296,13 @@ class LoaderTask(
) {
try {
val embeddedLoader = EmbeddedLoader(context, configuration, logger, database, directory)
- val result = embeddedLoader.load { updateResponse ->
+ embeddedLoader.load { _ ->
Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true)
}
- launcher.launch(database)
} catch (e: Exception) {
logger.error("Unexpected error copying embedded update", e, UpdatesErrorCode.Unknown)
- launcher.launch(database)
}
+ launcher.launch(database)
} else {
launcher.launch(database)
}
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
index b85291e..546709d 100644
index 68086bd..78c7761 100644
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
+++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
@@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update {
@@ -1,47 +1,16 @@
diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
index 914a249..0deac55 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
@@ -19,6 +19,8 @@ NS_ASSUME_NONNULL_BEGIN
*/
@interface RCTPullToRefreshViewComponentView : RCTViewComponentView <RCTCustomPullToRefreshViewProtocol>
+- (void)beginRefreshingProgrammatically;
+
@end
NS_ASSUME_NONNULL_END
diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
index d029337..0f63ea3 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
@@ -1038,6 +1038,11 @@ - (void)_adjustForMaintainVisibleContentPosition
}
}
++ (BOOL)shouldBeRecycled
+{
+ return NO;
+}
+
@end
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
index e9b330f..ec5f58c 100644
index e9b330f..5fbb2e0 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@@ -15,5 +15,8 @@
@@ -15,5 +15,6 @@
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
+@property (nonatomic, copy) UIColor *customTintColor;
+
+- (void)forwarderBeginRefreshing;
@end
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
index 53bfd04..ff1b1ed 100644
index 53bfd04..e2e0c9f 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -23,6 +23,7 @@ @implementation RCTRefreshControl {
@@ -65,7 +34,7 @@ index 53bfd04..ff1b1ed 100644
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
@@ -221,4 +228,16 @@ - (void)refreshControlValueChanged
}
}
@@ -80,40 +49,6 @@ index 53bfd04..ff1b1ed 100644
+ [super setTintColor:tintColor];
+ }
+}
+
+// This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
+// libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
+// function.
+- (void)forwarderBeginRefreshing
+{
+ _refreshingProgrammatically = NO;
+
+ [self sizeToFit];
+
+ if (!self.scrollView) {
+ return;
+ }
+
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
+
+ [UIView animateWithDuration:0.3
+ delay:0
+ options:UIViewAnimationOptionBeginFromCurrentState
+ animations:^(void) {
+ // Whenever we call this method, the scrollview will always be at a position of
+ // -130 or less. Scrolling back to -65 simulates the default behavior of RCTRefreshControl
+ [scrollView setContentOffset:CGPointMake(0, -65)];
+ }
+ completion:^(__unused BOOL finished) {
+ [super beginRefreshing];
+ [self setCurrentRefreshingState:super.refreshing];
+
+ if (self->_onRefresh) {
+ self->_onRefresh(nil);
+ }
+ }
+ ];
+}
+
@end
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -0,0 +1,67 @@
diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
index 914a249..0deac55 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
@@ -19,6 +19,8 @@ NS_ASSUME_NONNULL_BEGIN
*/
@interface RCTPullToRefreshViewComponentView : RCTViewComponentView <RCTCustomPullToRefreshViewProtocol>
+- (void)beginRefreshingProgrammatically;
+
@end
NS_ASSUME_NONNULL_END
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
index 5fbb2e0..ec5f58c 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@@ -17,4 +17,6 @@
@property (nonatomic, weak) UIScrollView *scrollView;
@property (nonatomic, copy) UIColor *customTintColor;
+- (void)forwarderBeginRefreshing;
+
@end
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
index e2e0c9f..ff1b1ed 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -240,4 +240,38 @@ - (void)setTintColor:(UIColor *)tintColor
}
}
+// This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
+// libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
+// function.
+- (void)forwarderBeginRefreshing
+{
+ _refreshingProgrammatically = NO;
+
+ [self sizeToFit];
+
+ if (!self.scrollView) {
+ return;
+ }
+
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
+
+ [UIView animateWithDuration:0.3
+ delay:0
+ options:UIViewAnimationOptionBeginFromCurrentState
+ animations:^(void) {
+ // Whenever we call this method, the scrollview will always be at a position of
+ // -130 or less. Scrolling back to -65 simulates the default behavior of RCTRefreshControl
+ [scrollView setContentOffset:CGPointMake(0, -65)];
+ }
+ completion:^(__unused BOOL finished) {
+ [super beginRefreshing];
+ [self setCurrentRefreshingState:super.refreshing];
+
+ if (self->_onRefresh) {
+ self->_onRefresh(nil);
+ }
+ }
+ ];
+}
+
@end
@@ -0,0 +1,16 @@
diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
index d029337..0f63ea3 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
@@ -1038,6 +1038,11 @@ - (void)_adjustForMaintainVisibleContentPosition
}
}
++ (BOOL)shouldBeRecycled
+{
+ return NO;
+}
+
@end
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
+39 -39
View File
@@ -25,16 +25,10 @@ import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
import {
beginResolveGeolocationConfig,
ensureGeolocationConfigIsResolved,
Provider as GeolocationProvider,
} from '#/state/geolocation'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
@@ -56,6 +50,7 @@ import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
@@ -73,6 +68,9 @@ import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdate
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -93,7 +91,8 @@ if (isAndroid) {
/**
* Begin geolocation ASAP
*/
beginResolveGeolocationConfig()
Geo.resolve()
prefetchAgeAssuranceConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -143,7 +142,7 @@ function InnerApp() {
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AgeAssuranceProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
@@ -186,7 +185,7 @@ function InnerApp() {
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceProvider>
</AgeAssuranceV2Provider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
@@ -203,10 +202,9 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
Promise.all([
initPersistedState(),
ensureGeolocationConfigIsResolved(),
]).then(() => setReady(true))
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
setReady(true),
)
}, [])
if (!isReady) {
@@ -218,36 +216,38 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<GeolocationProvider>
<Geo.Provider>
<A11yProvider>
<KeyboardControllerProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
<OnboardingProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
</GeolocationProvider>
</Geo.Provider>
)
}
+37 -36
View File
@@ -14,16 +14,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
import {
beginResolveGeolocationConfig,
ensureGeolocationConfigIsResolved,
Provider as GeolocationProvider,
} from '#/state/geolocation'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
@@ -44,6 +38,7 @@ import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
@@ -61,13 +56,18 @@ import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
/**
* Begin geolocation ASAP
*/
beginResolveGeolocationConfig()
Geo.resolve()
prefetchAgeAssuranceConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -104,7 +104,7 @@ function InnerApp() {
}, [_])
// wait for session to resume
if (!isReady || !hasCheckedReferrer) return null
if (!isReady || !hasCheckedReferrer) return <Splash isReady />
return (
<Alf theme={theme}>
@@ -118,7 +118,7 @@ function InnerApp() {
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AgeAssuranceProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
@@ -157,7 +157,7 @@ function InnerApp() {
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceProvider>
</AgeAssuranceV2Provider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
@@ -174,14 +174,13 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
Promise.all([
initPersistedState(),
ensureGeolocationConfigIsResolved(),
]).then(() => setReady(true))
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
setReady(true),
)
}, [])
if (!isReady) {
return null
return <Splash isReady />
}
/*
@@ -189,29 +188,31 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<GeolocationProvider>
<Geo.Provider>
<A11yProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
<OnboardingProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</OnboardingProvider>
</A11yProvider>
</GeolocationProvider>
</Geo.Provider>
)
}
+29
View File
@@ -0,0 +1,29 @@
/*
* This is a reimplementation of what exists in our HTML template files
* already. Once the React tree mounts, this is what gets rendered first, until
* the app is ready to go.
*/
import {View} from 'react-native'
import Svg, {Path} from 'react-native-svg'
import {atoms as a} from '#/alf'
const size = 100
const ratio = 57 / 64
export function Splash() {
return (
<View style={[a.fixed, a.inset_0, a.align_center, a.justify_center]}>
<Svg
fill="none"
viewBox="0 0 64 57"
style={[a.relative, {width: size, height: size * ratio, top: -50}]}>
<Path
fill="#006AFF"
d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"
/>
</Svg>
</View>
)
}
+3
View File
@@ -0,0 +1,3 @@
export const prefetchAgeAssuranceData = () => {}
export const setBirthdateForDid = () => {}
export const setCreatedAtForDid = () => {}
@@ -0,0 +1,355 @@
import {useCallback, useEffect} from 'react'
import {ScrollView, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
SupportCode,
useCreateSupportLink,
} from '#/lib/hooks/useCreateSupportLink'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {isNative} from '#/platform/detection'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
import {useSessionApi} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import * as Dialog from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
import {Full as Logo} from '#/components/icons/Logo'
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {Outlet as PortalOutlet} from '#/components/Portal'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {
isLegacyBirthdateBug,
useAgeAssuranceRegionConfig,
} from '#/ageAssurance/util'
import {useDeviceGeolocationApi} from '#/geolocation'
const textStyles = [a.text_md, a.leading_snug]
export function NoAccessScreen() {
const t = useTheme()
const {_} = useLingui()
const {gtPhone} = useBreakpoints()
const insets = useSafeAreaInsets()
const birthdateControl = useDialogControl()
const {data} = useAgeAssuranceDataContext()
const region = useAgeAssuranceRegionConfig()
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
const {logoutCurrentAccount} = useSessionApi()
const createSupportLink = useCreateSupportLink()
const aa = useAgeAssurance()
const isBlocked = aa.state.status === aa.Status.Blocked
const isAARegion = !!region
const hasDeclaredAge = data?.declaredAge !== undefined
const canUpdateBirthday =
isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
useEffect(() => {
// just counting overall hits here
logger.metric(`blockedGeoOverlay:shown`, {})
}, [])
const onPressLogout = useCallback(() => {
if (isWeb) {
// We're switching accounts, which remounts the entire app.
// On mobile, this gets us Home, but on the web we also need reset the URL.
// We can't change the URL via a navigate() call because the navigator
// itself is about to unmount, and it calls pushState() too late.
// So we change the URL ourselves. The navigator will pick it up on remount.
history.pushState(null, '', '/')
}
logoutCurrentAccount('AgeAssuranceNoAccessScreen')
}, [logoutCurrentAccount])
const birthdateUpdateText = canUpdateBirthday ? (
<Text style={[textStyles]}>
<Trans>
If you believe your birthdate is incorrect, you can update it by{' '}
<SimpleInlineLinkText
label={_(msg`Click here to update your birthdate`)}
style={[textStyles]}
{...createStaticClick(() => {
birthdateControl.open()
})}>
clicking here
</SimpleInlineLinkText>
.
</Trans>
</Text>
) : (
<Text style={[textStyles]}>
<Trans>
If you believe your birthdate is incorrect, please{' '}
<SimpleInlineLinkText
to={createSupportLink({code: SupportCode.AA_BIRTHDATE})}
label={_(msg`Click here to contact our support team`)}
style={[textStyles]}>
contact our support team
</SimpleInlineLinkText>
.
</Trans>
</Text>
)
return (
<>
<ScrollView
contentContainerStyle={[
a.px_2xl,
{
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
paddingBottom: 100,
},
]}>
<View
style={[
a.mx_auto,
a.w_full,
web({
maxWidth: 380,
paddingTop: gtPhone ? '8vh' : undefined,
}),
{
gap: 32,
},
]}>
<View style={[a.align_start]}>
<AgeAssuranceBadge />
</View>
{hasDeclaredAge ? (
<>
{isAARegion ? (
<>
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>
You are accessing Bluesky from a region that legally
requires us to verify your age before allowing you to
access the app.
</Trans>
</Text>
{!isBlocked && birthdateUpdateText}
</View>
<AccessSection />
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>
Unfortunately, the birthdate you have saved to your
profile makes you too young to access Bluesky.
</Trans>
</Text>
{birthdateUpdateText}
</View>
)}
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>
It looks like you haven't added your birthdate. You must
provide an accurate date of birth to use Bluesky.
</Trans>
</Text>
<Button
color="primary"
size="large"
label={_(msg`Click here to update your birthdate`)}
onPress={() => birthdateControl.open()}>
<ButtonText>
<Trans>Add your birthdate</Trans>
</ButtonText>
</Button>
</View>
)}
<View style={[a.pt_lg, a.gap_xl]}>
<Logo width={120} textFill={t.atoms.text.color} />
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
<Trans>
To log out,{' '}
<SimpleInlineLinkText
label={_(msg`Click here to log out`)}
{...createStaticClick(() => {
onPressLogout()
})}>
click here
</SimpleInlineLinkText>
.
</Trans>
</Text>
</View>
</View>
</ScrollView>
<BirthDateSettingsDialog control={birthdateControl} />
{/*
* While this blocking overlay is up, other dialogs in the shell
* are not mounted, so it _should_ be safe to use these here
* without fear of other modals showing up.
*/}
<BottomSheetOutlet />
<PortalOutlet />
</>
)
}
function AccessSection() {
const t = useTheme()
const {_, i18n} = useLingui()
const control = useDialogControl()
const appealControl = Dialog.useDialogControl()
const locationControl = Dialog.useDialogControl()
const getTimeAgo = useGetTimeAgo()
const {setDeviceGeolocation} = useDeviceGeolocationApi()
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
const aa = useAgeAssurance()
const {status, lastInitiatedAt} = aa.state
const isBlocked = status === aa.Status.Blocked
const hasInitiated = !!lastInitiatedAt
const timeAgo = lastInitiatedAt
? getTimeAgo(lastInitiatedAt, new Date())
: null
const diff = lastInitiatedAt
? dateDiff(lastInitiatedAt, new Date(), 'down')
: null
return (
<>
<AgeAssuranceInitDialog control={control} />
<AgeAssuranceAppealDialog control={appealControl} />
<View style={[a.gap_xl]}>
{isBlocked ? (
<Admonition type="warning">
<Trans>
You are currently unable to access Bluesky's Age Assurance flow.
Please{' '}
<SimpleInlineLinkText
label={_(msg`Contact our moderation team`)}
{...createStaticClick(() => {
appealControl.open()
logger.metric('ageAssurance:appealDialogOpen', {})
})}>
contact our moderation team
</SimpleInlineLinkText>{' '}
if you believe this is an error.
</Trans>
</Admonition>
) : (
<>
<View style={[a.gap_md]}>
<Button
label={_(msg`Verify now`)}
size="large"
color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => {
control.open()
logger.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}}>
<ButtonIcon icon={ShieldIcon} />
<ButtonText>
{hasInitiated ? (
<Trans>Verify again</Trans>
) : (
<Trans>Verify now</Trans>
)}
</ButtonText>
</Button>
{lastInitiatedAt && timeAgo && diff ? (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
title={i18n.date(lastInitiatedAt, {
dateStyle: 'medium',
timeStyle: 'medium',
})}>
{diff.value === 0 ? (
<Trans>Last initiated just now</Trans>
) : (
<Trans>Last initiated {timeAgo} ago</Trans>
)}
</Text>
) : (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
<Trans>Age assurance only takes a few minutes</Trans>
</Text>
)}
</View>
</>
)}
<View style={[a.gap_xs]}>
{isNative && (
<>
<Admonition>
<Trans>
Is your location not accurate?{' '}
<SimpleInlineLinkText
label={_(msg`Confirm your location`)}
{...createStaticClick(() => {
locationControl.open()
})}>
Tap here to confirm your location.
</SimpleInlineLinkText>{' '}
</Trans>
</Admonition>
<DeviceLocationRequestDialog
control={locationControl}
onLocationAcquired={props => {
const access = computeAgeAssuranceRegionAccess(
props.geolocation,
)
if (access !== aa.Access.Full) {
props.disableDialogAction()
props.setDialogError(
_(
msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`,
),
)
} else {
props.closeDialog(() => {
// set this after close!
setDeviceGeolocation(props.geolocation)
Toast.show(_(msg`Thanks! You're all set.`), {
type: 'success',
})
})
}
}}
/>
</>
)}
</View>
</View>
</>
)
}
@@ -0,0 +1,334 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {Dimensions, View} from 'react-native'
import * as Linking from 'expo-linking'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {retry} from '#/lib/async/retry'
import {wait} from '#/lib/async/wait'
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import {isWeb} from '#/platform/detection'
import {isIOS} from '#/platform/detection'
import {useAgent, useSession} from '#/state/session'
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {Button, ButtonText} from '#/components/Button'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {refetchAgeAssuranceServerState} from '#/ageAssurance'
import {logger} from '#/ageAssurance'
export type RedirectOverlayState = {
result: 'success' | 'unknown'
actorDid: string
}
/**
* Validate and parse the query parameters returned from the age assurance
* redirect. If not valid, returns `undefined` and the dialog will not open.
*/
export function parseRedirectOverlayState(
state: {
result?: string
actorDid?: string
} = {},
): RedirectOverlayState | undefined {
let result: RedirectOverlayState['result'] = 'unknown'
const actorDid = state.actorDid
switch (state.result) {
case 'success':
result = 'success'
break
case 'unknown':
default:
result = 'unknown'
break
}
if (actorDid) {
return {
result,
actorDid,
}
}
}
const Context = createContext<{
isOpen: boolean
open: (state: RedirectOverlayState) => void
close: () => void
}>({
isOpen: false,
open: () => {},
close: () => {},
})
export function useRedirectOverlayContext() {
return useContext(Context)
}
export function Provider({children}: {children?: React.ReactNode}) {
const {currentAccount} = useSession()
const incomingUrl = Linking.useLinkingURL()
const [state, setState] = useState<RedirectOverlayState | null>(() => {
if (!incomingUrl) return null
const url = parseLinkingUrl(incomingUrl)
if (url.pathname !== '/intent/age-assurance') return null
const params = url.searchParams
const state = parseRedirectOverlayState({
result: params.get('result') ?? undefined,
actorDid: params.get('actorDid') ?? undefined,
})
if (isWeb) {
// Clear the URL parameters so they don't re-trigger
history.pushState(null, '', '/')
}
/*
* If we don't have an account or the account doesn't match, do
* nothing. By the time the user switches to their other account, AA
* state should be ready for them.
*/
if (state && currentAccount && state.actorDid === currentAccount.did) {
return state
}
return null
})
const open = useCallback((state: RedirectOverlayState) => {
setState(state)
}, [])
const close = useCallback(() => {
setState(null)
}, [])
return (
<Context.Provider
value={useMemo(
() => ({
isOpen: state !== null,
open,
close,
}),
[state, open, close],
)}>
{children}
</Context.Provider>
)
}
export function RedirectOverlay() {
const t = useTheme()
const {_} = useLingui()
const {isOpen} = useRedirectOverlayContext()
const {gtMobile} = useBreakpoints()
return isOpen ? (
<FullWindowOverlay>
<View
style={[
a.fixed,
a.inset_0,
// setting a zIndex when using FullWindowOverlay on iOS
// means the taps pass straight through to the underlying content (???)
// so don't set it on iOS. FullWindowOverlay already does the job.
!isIOS && {zIndex: 9999},
t.atoms.bg,
gtMobile ? a.p_2xl : a.p_xl,
a.align_center,
// @ts-ignore
platform({
web: {
paddingTop: '35vh',
},
default: {
paddingTop: Dimensions.get('window').height * 0.35,
},
}),
]}>
<View
role="dialog"
aria-role="dialog"
aria-label={_(msg`Verifying your age assurance status`)}>
<View style={[a.pb_3xl, {width: 300}]}>
<Inner />
</View>
</View>
</View>
</FullWindowOverlay>
) : null
}
function Inner() {
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
const polling = useRef(false)
const unmounted = useRef(false)
const [error, setError] = useState(false)
const [success, setSuccess] = useState(false)
const {close} = useRedirectOverlayContext()
useEffect(() => {
if (polling.current) return
polling.current = true
logger.metric('ageAssurance:redirectDialogOpen', {})
wait(
3e3,
retry(
5,
() => true,
async () => {
if (!agent.session) return
if (unmounted.current) return
const data = await refetchAgeAssuranceServerState({agent})
if (data?.state.status !== 'assured') {
throw new Error(
`Polling for age assurance state did not receive assured status`,
)
}
return data
},
1e3,
),
)
.then(async data => {
if (!data) return
if (!agent.session) return
if (unmounted.current) return
setSuccess(true)
logger.metric('ageAssurance:redirectDialogSuccess', {})
})
.catch(() => {
if (unmounted.current) return
setError(true)
logger.metric('ageAssurance:redirectDialogFail', {})
})
return () => {
unmounted.current = true
}
}, [agent])
if (success) {
return (
<>
<View style={[a.align_start, a.w_full]}>
<AgeAssuranceBadge />
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_sm,
a.pt_lg,
a.pb_md,
]}>
<SuccessIcon size="sm" fill={t.palette.positive_500} />
<Text style={[a.text_3xl, a.font_bold]}>
<Trans>Success</Trans>
</Text>
</View>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
We've confirmed your age assurance status. You can now close this
dialog.
</Trans>
</Text>
<View style={[a.w_full, a.pt_lg]}>
<Button
label={_(msg`Close`)}
size="large"
variant="solid"
color="secondary"
onPress={() => close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
</View>
</>
)
}
return (
<>
<View style={[a.align_start, a.w_full]}>
<AgeAssuranceBadge />
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_sm,
a.pt_lg,
a.pb_md,
]}>
{error && <ErrorIcon size="lg" fill={t.palette.negative_500} />}
<Text style={[a.text_3xl, a.font_bold]}>
{error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
</Text>
{!error && <Loader size="lg" />}
</View>
<Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
{error ? (
<Trans>
We were unable to receive the verification due to a connection
issue. It may arrive later. If it does, your account will update
automatically.
</Trans>
) : (
<Trans>
We're confirming your age assurance status with our servers. This
should only take a few seconds.
</Trans>
)}
</Text>
{error && (
<View style={[a.w_full, a.pt_lg]}>
<Button
label={_(msg`Close`)}
size="large"
variant="solid"
color="secondary"
onPress={() => close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
)}
</View>
</>
)
}
+495
View File
@@ -0,0 +1,495 @@
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import {
type AppBskyAgeassuranceDefs,
type AppBskyAgeassuranceGetConfig,
type AppBskyAgeassuranceGetState,
AtpAgent,
getAgeAssuranceRegionConfig,
} from '@atproto/api'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
import {persistQueryClient} from '@tanstack/react-query-persist-client'
import debounce from 'lodash.debounce'
import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {getAge} from '#/lib/strings/time'
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
import {useAgent, useSession} from '#/state/session'
import * as debug from '#/ageAssurance/debug'
import {logger} from '#/ageAssurance/logger'
import {isLegacyBirthdateBug} from '#/ageAssurance/util'
import {IS_DEV} from '#/env'
import {device} from '#/storage'
/**
* Special query client for age assurance data so we can prefetch on app
* load without interfering with other queries.
*/
const qc = new QueryClient({
defaultOptions: {
queries: {
/**
* We clear this manually, so disable automatic garbage collection.
* @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
*/
gcTime: Infinity,
},
},
})
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'age-assurance-query-client',
})
const [, cacheHydrationPromise] = persistQueryClient({
queryClient: qc,
persister,
})
function getDidFromAgentSession(agent: AtpAgent) {
const sessionManager = agent.sessionManager
if (!sessionManager || !sessionManager.did) return
return sessionManager.did
}
/*
* Optimistic data
*/
const createdAtCache = new Map<string, string>()
export function setCreatedAtForDid({
did,
createdAt,
}: {
did: string
createdAt: string
}) {
createdAtCache.set(did, createdAt)
}
const birthdateCache = new Map<string, string>()
export function setBirthdateForDid({
did,
birthdate,
}: {
did: string
birthdate: string
}) {
birthdateCache.set(did, birthdate)
}
/*
* Config
*/
export const configQueryKey = ['config']
export async function getConfig() {
if (debug.enabled) return debug.resolve(debug.config)
const agent = new AtpAgent({
service: PUBLIC_BSKY_SERVICE,
})
const res = await agent.app.bsky.ageassurance.getConfig()
return res.data
}
export function getConfigFromCache():
| AppBskyAgeassuranceGetConfig.OutputSchema
| undefined {
return qc.getQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
configQueryKey,
)
}
let configPrefetchPromise: Promise<void> | undefined
export async function prefetchConfig() {
if (configPrefetchPromise) {
logger.debug(`prefetchAgeAssuranceConfig: already in progress`)
return
}
configPrefetchPromise = new Promise(async resolve => {
await cacheHydrationPromise
const cached = getConfigFromCache()
if (cached) {
logger.debug(`prefetchAgeAssuranceConfig: using cache`)
resolve()
} else {
try {
logger.debug(`prefetchAgeAssuranceConfig: resolving...`)
const res = await networkRetry(3, () => getConfig())
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
configQueryKey,
res,
)
} catch (e: any) {
logger.warn(`prefetchAgeAssuranceConfig: failed`, {
safeMessage: e.message,
})
} finally {
resolve()
}
}
})
}
export function useConfigQuery() {
return useQuery(
{
/**
* Will re-fetch when stale, at most every hour (or 5s in dev for easier
* testing).
*
* @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
*/
staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
initialData: getConfigFromCache(),
initialDataUpdatedAt: () =>
qc.getQueryState(configQueryKey)?.dataUpdatedAt,
queryKey: configQueryKey,
async queryFn() {
logger.debug(`useConfigQuery: fetching config`)
return getConfig()
},
},
qc,
)
}
/*
* Server state
*/
export function createServerStateQueryKey({did}: {did: string}) {
return ['serverState', did]
}
export async function getServerState({agent}: {agent: AtpAgent}) {
if (debug.enabled && debug.serverState)
return debug.resolve(debug.serverState)
const geolocation = device.get(['mergedGeolocation'])
if (!geolocation || !geolocation.countryCode) {
logger.error(`getServerState: missing geolocation countryCode`)
return
}
const {data} = await agent.app.bsky.ageassurance.getState({
countryCode: geolocation.countryCode,
regionCode: geolocation.regionCode,
})
const did = getDidFromAgentSession(agent)
if (data && did && createdAtCache.has(did)) {
/*
* If account was just created, just use the local cache if available. On
* subsequent reloads, the server should have the correct value.
*/
data.metadata.accountCreatedAt = createdAtCache.get(did)
}
return data ?? null
}
export function getServerStateFromCache({
did,
}: {
did: string
}): AppBskyAgeassuranceGetState.OutputSchema | undefined {
return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
createServerStateQueryKey({did}),
)
}
export async function prefetchServerState({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
await cacheHydrationPromise
const qk = createServerStateQueryKey({did})
const cached = getServerStateFromCache({did})
if (cached) {
logger.debug(`prefetchServerState: using cache`)
return
}
try {
logger.debug(`prefetchServerState: resolving...`)
const res = await networkRetry(3, () => getServerState({agent}))
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
} catch (e: any) {
logger.warn(`prefetchServerState: failed`, {
safeMessage: e.message,
})
}
}
export async function refetchServerState({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
logger.debug(`refetchServerState: fetching...`)
const res = await networkRetry(3, () => getServerState({agent}))
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
createServerStateQueryKey({did}),
res,
)
return res
}
export function usePatchServerState() {
const {currentAccount} = useSession()
return useCallback(
async (next: AppBskyAgeassuranceDefs.State) => {
if (!currentAccount) return
const did = currentAccount.did
const prev = getServerStateFromCache({did})
const merged: AppBskyAgeassuranceGetState.OutputSchema = {
metadata: {},
...(prev || {}),
state: next,
}
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
createServerStateQueryKey({did}),
merged,
)
},
[currentAccount],
)
}
export function useServerStateQuery() {
const agent = useAgent()
const did = getDidFromAgentSession(agent)
const query = useQuery(
{
enabled: !!did,
initialData: () => {
if (!did) return
return getServerStateFromCache({did})
},
queryKey: createServerStateQueryKey({did: did!}),
async queryFn() {
return getServerState({agent})
},
},
qc,
)
const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch])
const isAssured = query.data?.state?.status === 'assured'
/**
* `refetchOnWindowFocus` doesn't seem to want to work for this custom query
* client, so we manually subscribe to focus changes.
*/
useEffect(() => {
return focusManager.subscribe(() => {
// logged out
if (!did) return
const isFocused = focusManager.isFocused()
if (!isFocused) return
const config = getConfigFromCache()
const geolocation = device.get(['mergedGeolocation'])
const isAArequired = Boolean(
config &&
geolocation &&
!!getAgeAssuranceRegionConfig(config, {
countryCode: geolocation?.countryCode ?? '',
regionCode: geolocation?.regionCode,
}),
)
// only refetch when needed
if (isAssured || !isAArequired) return
refetch()
})
}, [did, refetch, isAssured])
return query
}
/*
* Other required data
*/
export type OtherRequiredData = {
birthdate: string | undefined
}
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
return ['otherRequiredData', did]
}
export async function getOtherRequiredData({
agent,
}: {
agent: AtpAgent
}): Promise<OtherRequiredData> {
if (debug.enabled) return debug.resolve(debug.otherRequiredData)
const [prefs] = await Promise.all([agent.getPreferences()])
const data: OtherRequiredData = {
birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined,
}
const did = getDidFromAgentSession(agent)
if (data && did && birthdateCache.has(did)) {
/*
* If birthdate was just set, use the local cache value. On subsequent
* reloads, the server should have the correct value.
*/
data.birthdate = birthdateCache.get(did)
}
/**
* If the user is under the minimum age, and the birthdate is not due to
* the legacy bug, snooze further birthdate updates for this user.
*/
if (data.birthdate && !isLegacyBirthdateBug(data.birthdate)) {
snoozeBirthdateUpdateAllowedForDid(did!)
}
return data
}
export function getOtherRequiredDataFromCache({
did,
}: {
did: string
}): OtherRequiredData | undefined {
return qc.getQueryData<OtherRequiredData>(
createOtherRequiredDataQueryKey({did}),
)
}
export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
await cacheHydrationPromise
const qk = createOtherRequiredDataQueryKey({did})
const cached = getOtherRequiredDataFromCache({did})
if (cached) {
logger.debug(`prefetchOtherRequiredData: using cache`)
return
}
try {
logger.debug(`prefetchOtherRequiredData: resolving...`)
const res = await networkRetry(3, () => getOtherRequiredData({agent}))
qc.setQueryData<OtherRequiredData>(qk, res)
} catch (e: any) {
logger.warn(`prefetchOtherRequiredData: failed`, {
safeMessage: e.message,
})
}
}
export function usePatchOtherRequiredData() {
const {currentAccount} = useSession()
return useCallback(
async (next: OtherRequiredData) => {
if (!currentAccount) return
const did = currentAccount.did
const prev = getOtherRequiredDataFromCache({did})
const merged: OtherRequiredData = {
...(prev || {}),
...next,
}
qc.setQueryData<OtherRequiredData>(
createOtherRequiredDataQueryKey({did}),
merged,
)
},
[currentAccount],
)
}
export function useOtherRequiredDataQuery() {
const agent = useAgent()
const did = getDidFromAgentSession(agent)
return useQuery(
{
enabled: !!did,
initialData: () => {
if (!did) return
return getOtherRequiredDataFromCache({did})
},
queryKey: createOtherRequiredDataQueryKey({did: did!}),
async queryFn() {
return getOtherRequiredData({agent})
},
},
qc,
)
}
/**
* Helper to prefetch all age assurance data.
*/
export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
return Promise.allSettled([
// config fetch initiated at the top of the App.platform.tsx files, awaited here
configPrefetchPromise,
prefetchServerState({agent}),
prefetchOtherRequiredData({agent}),
])
}
export function clearAgeAssuranceDataForDid({did}: {did: string}) {
logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
qc.removeQueries({
queryKey: createOtherRequiredDataQueryKey({did}),
exact: true,
})
}
export function clearAgeAssuranceData() {
logger.debug(`clearAgeAssuranceData`)
qc.clear()
}
/*
* Context
*/
export type AgeAssuranceData = {
config: AppBskyAgeassuranceDefs.Config | undefined
state: AppBskyAgeassuranceDefs.State | undefined
data:
| {
accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
declaredAge: number | undefined
birthdate: string | undefined
}
| undefined
}
export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
config: undefined,
state: undefined,
data: {
accountCreatedAt: undefined,
declaredAge: undefined,
birthdate: undefined,
},
})
export function useAgeAssuranceDataContext() {
return useContext(AgeAssuranceDataContext)
}
export function AgeAssuranceDataProvider({
children,
}: {
children: React.ReactNode
}) {
const {data: config} = useConfigQuery()
const serverState = useServerStateQuery()
const {state, metadata} = serverState.data || {}
const {data} = useOtherRequiredDataQuery()
const ctx = useMemo(
() => ({
config,
state,
data: {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: data?.birthdate
? getAge(new Date(data.birthdate))
: undefined,
birthdate: data?.birthdate,
},
}),
[config, state, data, metadata],
)
return (
<AgeAssuranceDataContext.Provider value={ctx}>
{children}
</AgeAssuranceDataContext.Provider>
)
}
+84
View File
@@ -0,0 +1,84 @@
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
type AppBskyAgeassuranceGetState,
} from '@atproto/api'
import {type OtherRequiredData} from '#/ageAssurance/data'
import {IS_DEV} from '#/env'
import {type Geolocation} from '#/geolocation'
export const enabled = IS_DEV && false
export const geolocation: Geolocation | undefined = enabled
? {
countryCode: 'AA',
regionCode: undefined,
}
: undefined
export const deviceGeolocation: Geolocation | undefined = enabled
? {
countryCode: 'AA',
regionCode: undefined,
}
: undefined
export const config: AppBskyAgeassuranceDefs.Config = {
regions: [
{
countryCode: 'AA',
regionCode: undefined,
rules: [
{
$type: ids.IfAccountNewerThan,
date: '2025-12-01T00:00:00Z',
access: 'none',
},
{
$type: ids.IfAssuredOverAge,
age: 18,
access: 'full',
},
{
$type: ids.IfAssuredOverAge,
age: 16,
access: 'safe',
},
{
$type: ids.IfDeclaredUnderAge,
age: 16,
access: 'none',
},
{
$type: ids.Default,
access: 'safe',
},
],
},
],
}
export const otherRequiredData: OtherRequiredData = {
birthdate: new Date(2000, 1, 1).toISOString(),
}
const serverStateEnabled = false
export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
serverStateEnabled
? {
state: {
lastInitiatedAt: new Date(2023, 5, 1).toISOString(),
status: 'assured',
access: 'safe',
},
metadata: {
accountCreatedAt: new Date(2023, 11, 1).toISOString(),
},
}
: undefined
export async function resolve<T>(data: T) {
await new Promise(y => setTimeout(y, 2000)) // simulate network
return data
}
+110
View File
@@ -0,0 +1,110 @@
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
import {AgeAssuranceDataProvider} from '#/ageAssurance/data'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
useAgeAssuranceState,
useOnAgeAssuranceAccessUpdate,
} from '#/ageAssurance/state'
import {
AgeAssuranceAccess,
type AgeAssuranceState,
AgeAssuranceStatus,
} from '#/ageAssurance/types'
import {isUserUnderAdultAge} from '#/ageAssurance/util'
export {
prefetchConfig as prefetchAgeAssuranceConfig,
prefetchAgeAssuranceData,
refetchServerState as refetchAgeAssuranceServerState,
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
usePatchServerState as usePatchAgeAssuranceServerState,
} from '#/ageAssurance/data'
export {logger} from '#/ageAssurance/logger'
const AgeAssuranceStateContext = createContext<{
Access: typeof AgeAssuranceAccess
Status: typeof AgeAssuranceStatus
state: AgeAssuranceState
flags: {
adultContentDisabled: boolean
chatDisabled: boolean
}
}>({
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
state: {
lastInitiatedAt: undefined,
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Full,
},
flags: {
adultContentDisabled: false,
chatDisabled: false,
},
})
/**
* THE MAIN AGE ASSURANCE CONTEXT HOOK
*
* Prefer this to using any of the lower-level data-provider hooks.
*/
export function useAgeAssurance() {
return useContext(AgeAssuranceStateContext)
}
export function Provider({children}: {children: React.ReactNode}) {
return (
<AgeAssuranceDataProvider>
<InnerProvider>
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
</InnerProvider>
</AgeAssuranceDataProvider>
)
}
function InnerProvider({children}: {children: React.ReactNode}) {
const state = useAgeAssuranceState()
const {data} = useAgeAssuranceDataContext()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
const handleAccessUpdate = useCallback(
(s: AgeAssuranceState) => {
getAndRegisterPushToken({
isAgeRestricted: s.access !== AgeAssuranceAccess.Full,
})
},
[getAndRegisterPushToken],
)
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
useEffect(() => {
logger.debug(`useAgeAssuranceState`, {state})
}, [state])
return (
<AgeAssuranceStateContext.Provider
value={useMemo(() => {
const chatDisabled = state.access !== AgeAssuranceAccess.Full
const isUnderage = data?.birthdate
? isUserUnderAdultAge(data.birthdate)
: true
const adultContentDisabled =
state.access !== AgeAssuranceAccess.Full || isUnderage
return {
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
state,
flags: {
adultContentDisabled,
chatDisabled,
},
}
}, [state, data])}>
{children}
</AgeAssuranceStateContext.Provider>
)
}
+100
View File
@@ -0,0 +1,100 @@
import {useEffect, useMemo, useState} from 'react'
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
import {useSession} from '#/state/session'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
AgeAssuranceAccess,
type AgeAssuranceState,
AgeAssuranceStatus,
parseAccessFromString,
parseStatusFromString,
} from '#/ageAssurance/types'
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
import {useGeolocation} from '#/geolocation'
export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession()
const geolocation = useGeolocation()
const {config, state, data} = useAgeAssuranceDataContext()
return useMemo(() => {
/**
* This is where we control logged-out moderation prefs. It's all
* downstream of AA now.
*/
if (!hasSession)
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Safe,
}
// should never happen, but need to guard
if (!config) {
logger.warn('useAgeAssuranceState: missing config')
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Unknown,
}
}
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
const isAARequired = region.countryCode !== '*'
const isTerminalState =
state?.status === 'assured' || state?.status === 'blocked'
/*
* If we are in a terminal state and AA is required for this region,
* we can trust the server state completely and avoid recomputing.
*/
if (isTerminalState && isAARequired) {
return {
lastInitiatedAt: state.lastInitiatedAt,
status: parseStatusFromString(state.status),
access: parseAccessFromString(state.access),
}
}
/*
* Otherwise, we need to compute the access based on the latest data. For
* accounts with an accurate birthdate, our default fallback rules should
* ensure correct access.
*/
const result = computeAgeAssuranceRegionAccess(region, data)
const computed = {
lastInitiatedAt: state?.lastInitiatedAt,
// prefer server state
status: state?.status
? parseStatusFromString(state?.status)
: AgeAssuranceStatus.Unknown,
// prefer server state
access: result
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full,
}
logger.debug('debug useAgeAssuranceState', {
region,
state,
data,
computed,
})
return computed
}, [hasSession, geolocation, config, state, data])
}
export function useOnAgeAssuranceAccessUpdate(
cb: (state: AgeAssuranceState) => void,
) {
const state = useAgeAssuranceState()
// start with null to ensure callback is called on first render
const [prevAccess, setPrevAccess] = useState<AgeAssuranceAccess | null>(null)
useEffect(() => {
if (prevAccess !== state.access) {
setPrevAccess(state.access)
cb(state)
logger.debug(`useOnAgeAssuranceAccessUpdate`, {state})
}
}, [cb, state, prevAccess])
}
+53
View File
@@ -0,0 +1,53 @@
import {logger} from '#/ageAssurance/logger'
export enum AgeAssuranceAccess {
Unknown = 'unknown',
None = 'none',
Safe = 'safe',
Full = 'full',
}
export enum AgeAssuranceStatus {
Unknown = 'unknown',
Pending = 'pending',
Assured = 'assured',
Blocked = 'blocked',
}
export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
access: AgeAssuranceAccess
}
export function parseStatusFromString(raw: string) {
switch (raw) {
case 'unknown':
return AgeAssuranceStatus.Unknown
case 'pending':
return AgeAssuranceStatus.Pending
case 'assured':
return AgeAssuranceStatus.Assured
case 'blocked':
return AgeAssuranceStatus.Blocked
default:
logger.error(`parseStatusFromString: unknown status value: ${raw}`)
return AgeAssuranceStatus.Unknown
}
}
export function parseAccessFromString(raw: string) {
switch (raw) {
case 'unknown':
return AgeAssuranceAccess.Unknown
case 'none':
return AgeAssuranceAccess.None
case 'safe':
return AgeAssuranceAccess.Safe
case 'full':
return AgeAssuranceAccess.Full
default:
logger.error(`parseAccessFromString: unknown access value: ${raw}`)
return AgeAssuranceAccess.Full
}
}
+74
View File
@@ -0,0 +1,74 @@
import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api'
import {useMutation} from '@tanstack/react-query'
import {wait} from '#/lib/async/wait'
import {
DEV_ENV_APPVIEW,
PUBLIC_APPVIEW,
PUBLIC_APPVIEW_DID,
} from '#/lib/constants'
import {isNetworkError} from '#/lib/hooks/useCleanError'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
import {BLUESKY_PROXY_DID} from '#/env'
import {useGeolocation} from '#/geolocation'
const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID
const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW
export function useBeginAgeAssurance() {
const agent = useAgent()
const geolocation = useGeolocation()
const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState()
return useMutation({
async mutationFn(
props: Omit<
AppBskyAgeassuranceBegin.InputSchema,
'countryCode' | 'regionCode'
>,
) {
const countryCode = geolocation?.countryCode
const regionCode = geolocation?.regionCode
if (!countryCode) {
throw new Error(`Geolocation not available, cannot init age assurance.`)
}
const {
data: {token},
} = await agent.com.atproto.server.getServiceAuth({
aud: BLUESKY_PROXY_DID,
lxm: `app.bsky.ageassurance.begin`,
})
const appView = new AtpAgent({service: APPVIEW})
appView.sessionManager.session = {...agent.session!}
appView.sessionManager.session.accessJwt = token
appView.sessionManager.session.refreshJwt = ''
/*
* 2s wait is good actually. Email sending takes a hot sec and this helps
* ensure the email is ready for the user once they open their inbox.
*/
const {data} = await wait(
2e3,
appView.app.bsky.ageassurance.begin({
...props,
countryCode: countryCode.toUpperCase(),
regionCode: regionCode ? regionCode.toUpperCase() : undefined,
}),
)
// Just keeps this in sync, not necessarily used right now
patchAgeAssuranceStateResponse(data)
},
onError(e) {
if (!isNetworkError(e)) {
logger.error(`useBeginAgeAssurance failed`, {
safeMessage: e,
})
}
},
})
}
@@ -0,0 +1,29 @@
import {useCallback} from 'react'
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
import {type Geolocation} from '#/geolocation'
export function useComputeAgeAssuranceRegionAccess() {
const {config, data} = useAgeAssuranceDataContext()
return useCallback(
(geolocation: Geolocation) => {
if (!config) {
logger.warn('useComputeAgeAssuranceRegionAccess: missing config')
return AgeAssuranceAccess.Unknown
}
const region = getAgeAssuranceRegionConfigWithFallback(
config,
geolocation,
)
const result = computeAgeAssuranceRegionAccess(region, data)
return result
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full
},
[config, data],
)
}
+88
View File
@@ -0,0 +1,88 @@
import {useMemo} from 'react'
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
getAgeAssuranceRegionConfig,
} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
const DEFAULT_MIN_AGE = 13
/**
* Get age assurance region config based on geolocation, with fallback to
* app defaults if no region config is found.
*
* See {@link getAgeAssuranceRegionConfig} for the generic option, which can
* return undefined if the geolocation does not match any AA region.
*/
export function getAgeAssuranceRegionConfigWithFallback(
config: AppBskyAgeassuranceDefs.Config,
geolocation: Geolocation,
): AppBskyAgeassuranceDefs.ConfigRegion {
const region = getAgeAssuranceRegionConfig(config, {
countryCode: geolocation.countryCode ?? '',
regionCode: geolocation.regionCode,
})
return (
region || {
countryCode: '*',
regionCode: undefined,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: DEFAULT_MIN_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}
)
}
/**
* Hook to get the age assurance region config based on current geolocation.
* Does not fall-back to our app defaults. If no config is found, returns
* undefined, which indicates no regional age assurance rules apply.
*/
export function useAgeAssuranceRegionConfig() {
const geolocation = useGeolocation()
const {config} = useAgeAssuranceDataContext()
return useMemo(() => {
if (!config) return
// use generic helper, we want to potentially return undefined
return getAgeAssuranceRegionConfig(config, {
countryCode: geolocation.countryCode ?? '',
regionCode: geolocation.regionCode,
})
}, [config, geolocation])
}
/**
* Some users may have erroneously set their birth date to the current date
* if one wasn't set on their account. We previously didn't do validation on
* the bday dialog, and it defaulted to the current date. This bug _has_ been
* seen in production, so we need to check for it where possible.
*/
export function isLegacyBirthdateBug(birthDate: string) {
return ['2025', '2024', '2023'].includes((birthDate || '').slice(0, 4))
}
/**
* Returns whether the user is under the minimum age required to use the app.
* This applies to all regions.
*/
export function isUserUnderMinimumAge(birthDate: string) {
return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE
}
export function isUserUnderAdultAge(birthDate: string) {
return getAge(new Date(birthDate)) < 18
}
+2 -1
View File
@@ -25,7 +25,8 @@ export function normalizeTextStyles(
fontFamily: Alf['fonts']['family']
} & Pick<Alf, 'flags'>,
) {
const s = flatten(styles)
const s = flatten(styles) ?? {}
// should always be defined on these components
s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale
+58 -24
View File
@@ -5,18 +5,20 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {isJwtExpired} from '#/lib/jwt'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfilesQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
import {Button} from '#/components/Button'
import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {Button} from './Button'
import {Text} from './Typography'
export function AccountList({
onSelectAccount,
@@ -44,9 +46,9 @@ export function AccountList({
<View
pointerEvents={pendingDid ? 'none' : 'auto'}
style={[
a.rounded_md,
a.rounded_lg,
a.overflow_hidden,
{borderWidth: 1},
a.border,
t.atoms.border_contrast_low,
]}>
{accounts.map(account => (
@@ -58,7 +60,7 @@ export function AccountList({
isCurrentAccount={account.did === currentAccount?.did}
isPendingAccount={account.did === pendingDid}
/>
<View style={[{borderBottomWidth: 1}, t.atoms.border_contrast_low]} />
<View style={[a.border_b, t.atoms.border_contrast_low]} />
</React.Fragment>
))}
<Button
@@ -72,22 +74,25 @@ export function AccountList({
a.flex_1,
a.flex_row,
a.align_center,
{height: 48},
a.p_lg,
a.gap_sm,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<Text
<View
style={[
a.font_semi_bold,
a.flex_1,
a.flex_row,
a.py_sm,
a.leading_tight,
t.atoms.text_contrast_medium,
{paddingLeft: 56},
t.atoms.bg_contrast_25,
a.rounded_full,
{width: 48, height: 48},
a.justify_center,
a.align_center,
(hovered || pressed) && t.atoms.bg_contrast_50,
]}>
<PlusIcon style={[t.atoms.text_contrast_low]} size="md" />
</View>
<Text style={[a.flex_1, a.leading_tight, a.text_md, a.font_medium]}>
{otherLabel ?? <Trans>Other account</Trans>}
</Text>
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
<ChevronIcon size="md" style={[t.atoms.text_contrast_low]} />
</View>
)}
</Button>
@@ -117,6 +122,8 @@ function AccountItem({
onSelect(account)
}, [account, onSelect])
const isLoggedOut = !account.refreshJwt || isJwtExpired(account.refreshJwt)
return (
<Button
testID={`chooseAccountBtn-${account.handle}`}
@@ -134,14 +141,13 @@ function AccountItem({
a.flex_1,
a.flex_row,
a.align_center,
a.px_md,
a.p_lg,
a.gap_sm,
{height: 56},
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
]}>
<UserAvatar
avatar={profile?.avatar}
size={36}
size={48}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
live={live}
hideLiveBadge
@@ -151,7 +157,7 @@ function AccountItem({
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text
emoji
style={[a.font_semi_bold, a.leading_tight]}
style={[a.font_medium, a.leading_tight, a.text_md]}
numberOfLines={1}>
{sanitizeDisplayName(
profile?.displayName || profile?.handle || account.handle,
@@ -166,15 +172,43 @@ function AccountItem({
</View>
)}
</View>
<Text style={[a.leading_tight, t.atoms.text_contrast_medium]}>
<Text
style={[
a.leading_tight,
t.atoms.text_contrast_medium,
a.text_sm,
]}>
{sanitizeHandle(account.handle, '@')}
</Text>
{isLoggedOut && (
<Text
style={[
a.leading_tight,
a.text_xs,
a.italic,
t.atoms.text_contrast_medium,
]}>
<Trans>Logged out</Trans>
</Text>
)}
</View>
{isCurrentAccount ? (
<Check size="sm" style={[{color: t.palette.positive_500}]} />
<View
style={[
{
width: 20,
height: 20,
backgroundColor: t.palette.positive_500,
},
a.rounded_full,
a.justify_center,
a.align_center,
]}>
<CheckIcon size="xs" style={[{color: t.palette.white}]} />
</View>
) : (
<Chevron size="sm" style={[t.atoms.text]} />
<ChevronIcon size="md" style={[t.atoms.text_contrast_low]} />
)}
</View>
)}
+1
View File
@@ -48,6 +48,7 @@ export function AppLanguageDropdown() {
})}
variant="ghost"
color="secondary"
shape="rectangular"
style={[
a.pr_xs,
a.pl_sm,
-193
View File
@@ -1,193 +0,0 @@
import {useEffect} from 'react'
import {ScrollView, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useDeviceGeolocationApi} from '#/state/geolocation'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
import {Divider} from '#/components/Divider'
import {Full as Logo, Mark} from '#/components/icons/Logo'
import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation'
import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link'
import {Outlet as PortalOutlet} from '#/components/Portal'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
export function BlockedGeoOverlay() {
const t = useTheme()
const {_} = useLingui()
const {gtPhone} = useBreakpoints()
const insets = useSafeAreaInsets()
const geoDialog = Dialog.useDialogControl()
const {setDeviceGeolocation} = useDeviceGeolocationApi()
useEffect(() => {
// just counting overall hits here
logger.metric(`blockedGeoOverlay:shown`, {})
}, [])
const textStyles = [a.text_md, a.leading_normal]
const links = {
blog: {
to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`,
label: _(msg`Read our blog post`),
overridePresentation: false,
disableMismatchWarning: true,
style: textStyles,
},
}
const blocks = [
_(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`),
_(
msg`A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies.`,
),
_(
msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`,
),
_(
msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`,
),
<>
To learn more, read our{' '}
<InlineLinkText {...links.blog}>blog post</InlineLinkText>.
</>,
]
return (
<>
<ScrollView
contentContainerStyle={[
a.px_2xl,
{
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
paddingBottom: 100,
},
]}>
<View
style={[
a.mx_auto,
web({
maxWidth: 380,
paddingTop: gtPhone ? '8vh' : undefined,
}),
]}>
<View style={[a.align_start]}>
<View
style={[
a.pl_md,
a.pr_lg,
a.py_sm,
a.rounded_full,
a.flex_row,
a.align_center,
a.gap_xs,
{
backgroundColor: t.palette.primary_25,
},
]}>
<Mark fill={t.palette.primary_600} width={14} />
<Text
style={[
a.font_semi_bold,
{
color: t.palette.primary_600,
},
]}>
<Trans>Announcement</Trans>
</Text>
</View>
</View>
<View style={[a.gap_lg, {paddingTop: 32}]}>
{blocks.map((block, index) => (
<Text key={index} style={[textStyles]}>
{block}
</Text>
))}
</View>
{!isWeb && (
<>
<View style={[a.pt_2xl]}>
<Divider />
</View>
<View style={[a.mt_xl, a.align_start]}>
<Text style={[a.text_lg, a.font_bold, a.leading_snug, a.pb_xs]}>
<Trans>Not in Mississippi?</Trans>
</Text>
<Text
style={[
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
a.pb_md,
]}>
<Trans>
Confirm your location with GPS. Your location data is not
tracked and does not leave your device.
</Trans>
</Text>
<Button
label={_(msg`Confirm your location`)}
onPress={() => geoDialog.open()}
size="small"
color="primary_subtle">
<ButtonIcon icon={LocationIcon} />
<ButtonText>
<Trans>Confirm your location</Trans>
</ButtonText>
</Button>
</View>
<DeviceLocationRequestDialog
control={geoDialog}
onLocationAcquired={props => {
if (props.geolocationStatus.isAgeBlockedGeo) {
props.disableDialogAction()
props.setDialogError(
_(
msg`We're sorry, but based on your device's location, you are currently located in a region where we cannot provide access at this time.`,
),
)
} else {
props.closeDialog(() => {
// set this after close!
setDeviceGeolocation({
countryCode: props.geolocationStatus.countryCode,
regionCode: props.geolocationStatus.regionCode,
})
Toast.show(_(msg`Thanks! You're all set.`), {
type: 'success',
})
})
}
}}
/>
</>
)}
<View style={[{paddingTop: 48}]}>
<Logo width={120} textFill={t.atoms.text.color} />
</View>
</View>
</ScrollView>
{/*
* While this blocking overlay is up, other dialogs in the shell
* are not mounted, so it _should_ be safe to use these here
* without fear of other modals showing up.
*/}
<BottomSheetOutlet />
<PortalOutlet />
</>
)
}
+90 -44
View File
@@ -39,7 +39,7 @@ export type ButtonColor =
| 'primary_subtle'
| 'negative_subtle'
export type ButtonSize = 'tiny' | 'small' | 'large'
export type ButtonShape = 'round' | 'square' | 'default'
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
export type VariantProps = {
/**
* The style variation of the button
@@ -56,6 +56,11 @@ export type VariantProps = {
size?: ButtonSize
/**
* The shape of the button
*
* - `default`: Pill shaped. Most buttons should use this shape.
* - `round`: Circular. For icon-only buttons.
* - `square`: Square. For icon-only buttons.
* - `rectangular`: Rectangular. Matches previous style, use when adjacent to form fields.
*/
shape?: ButtonShape
}
@@ -434,6 +439,26 @@ export const Button = React.forwardRef<View, ButtonProps>(
}
if (shape === 'default') {
if (size === 'large') {
baseStyles.push(a.rounded_full, {
paddingVertical: 12,
paddingHorizontal: 24,
gap: 6,
})
} else if (size === 'small') {
baseStyles.push(a.rounded_full, {
paddingVertical: 8,
paddingHorizontal: 14,
gap: 5,
})
} else if (size === 'tiny') {
baseStyles.push(a.rounded_full, {
paddingVertical: 5,
paddingHorizontal: 10,
gap: 3,
})
}
} else if (shape === 'rectangular') {
if (size === 'large') {
baseStyles.push({
paddingVertical: 12,
@@ -506,9 +531,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
variant,
color,
size,
shape,
disabled: disabled || false,
}),
[state, variant, color, size, disabled],
[state, variant, color, size, shape, disabled],
)
return (
@@ -749,59 +775,79 @@ export function ButtonIcon({
position?: 'left' | 'right'
size?: SVGIconProps['size']
}) {
const {size: buttonSize} = useButtonContext()
const {size: buttonSize, shape: buttonShape} = useButtonContext()
const textStyles = useSharedButtonTextStyles()
const {iconSize, iconContainerSize} = React.useMemo(() => {
/**
* Pre-set icon sizes for different button sizes
*/
const iconSizeShorthand =
size ??
(({
large: 'md',
small: 'sm',
tiny: 'xs',
}[buttonSize || 'small'] || 'sm') as Exclude<
SVGIconProps['size'],
undefined
>)
const {iconSize, iconContainerSize, iconNegativeMargin} =
React.useMemo(() => {
/**
* Pre-set icon sizes for different button sizes
*/
const iconSizeShorthand =
size ??
(({
large: 'md',
small: 'sm',
tiny: 'xs',
}[buttonSize || 'small'] || 'sm') as Exclude<
SVGIconProps['size'],
undefined
>)
/*
* Copied here from icons/common.tsx so we can tweak if we need to, but
* also so that we can calculate transforms.
*/
const iconSize = {
xs: 12,
sm: 16,
md: 18,
lg: 24,
xl: 28,
'2xl': 32,
}[iconSizeShorthand]
/*
* Copied here from icons/common.tsx so we can tweak if we need to, but
* also so that we can calculate transforms.
*/
const iconSize = {
xs: 12,
sm: 16,
md: 18,
lg: 24,
xl: 28,
'2xs': 8,
'2xl': 32,
'3xl': 40,
}[iconSizeShorthand]
/*
* Goal here is to match rendered text size so that different size icons
* don't increase button size
*/
const iconContainerSize = {
large: 20,
small: 17,
tiny: 15,
}[buttonSize || 'small']
/*
* Goal here is to match rendered text size so that different size icons
* don't increase button size
*/
const iconContainerSize = {
large: 20,
small: 17,
tiny: 15,
}[buttonSize || 'small']
return {
iconSize,
iconContainerSize,
}
}, [buttonSize, size])
/*
* The icon needs to be closer to the edge of the button than the text. Therefore
* we make the gap slightly too large, and then pull in the sides using negative margins.
*/
let iconNegativeMargin = 0
if (buttonShape === 'default') {
iconNegativeMargin = {
large: -2,
small: -2,
tiny: -1,
}[buttonSize || 'small']
}
return {
iconSize,
iconContainerSize,
iconNegativeMargin,
}
}, [buttonSize, buttonShape, size])
return (
<View
style={[
a.z_20,
{
width: iconContainerSize,
width: size === '2xs' ? 10 : iconContainerSize,
height: iconContainerSize,
marginLeft: iconNegativeMargin,
marginRight: iconNegativeMargin,
},
]}>
<View
+21 -2
View File
@@ -13,6 +13,7 @@ import {
type DialogControlRefProps,
type DialogOuterProps,
} from '#/components/Dialog/types'
import {IS_DEV} from '#/env'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
export const Context = createContext<DialogContextProps>({
@@ -50,10 +51,28 @@ export function useDialogControl(): DialogOuterProps['control'] {
id,
ref: control,
open: () => {
control.current.open()
if (control.current) {
control.current.open()
} else {
if (IS_DEV) {
console.warn(
'Attemped to open a dialog control that was not attached to a dialog!\n' +
'Please ensure that the Dialog is mounted when calling open/close',
)
}
}
},
close: cb => {
control.current.close(cb)
if (control.current) {
control.current.close(cb)
} else {
if (IS_DEV) {
console.warn(
'Attemped to close a dialog control that was not attached to a dialog!\n' +
'Please ensure that the Dialog is mounted when calling open/close',
)
}
}
},
}),
[id, control],
+4 -3
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, useBreakpoints, useTheme, web} from '#/alf'
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Context} from '#/components/Dialog/context'
import {
@@ -180,7 +180,8 @@ export function Inner({
onClick={stopPropagation}
onStartShouldSetResponder={_ => true}
onTouchEnd={stopPropagation}
style={[
// note: flatten is required for some reason -sfn
style={flatten([
a.relative,
a.rounded_md,
a.w_full,
@@ -195,7 +196,7 @@ export function Inner({
},
!reduceMotionEnabled && a.zoom_fade_in,
style,
]}>
])}>
<DismissableLayer.DismissableLayer
onInteractOutside={preventDefault}
onFocusOutside={preventDefault}
+4 -2
View File
@@ -129,7 +129,6 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
size="small"
variant="ghost"
color="secondary"
shape="square"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[
@@ -164,7 +163,10 @@ export function MenuButton() {
shape="square"
onPress={onPress}
hitSlop={HITSLOP_30}
style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}]}>
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
]}>
<ButtonIcon icon={Menu} size="lg" />
</Button>
</Slot>
+5 -3
View File
@@ -421,6 +421,7 @@ export function SimpleInlineLinkText({
label,
disableUnderline,
shouldProxy,
onPress: outerOnPress,
...rest
}: Omit<
InlineLinkProps,
@@ -428,7 +429,6 @@ export function SimpleInlineLinkText({
| 'action'
| 'disableMismatchWarning'
| 'overridePresentation'
| 'onPress'
| 'onLongPress'
| 'shareOnLongPress'
> & {
@@ -448,7 +448,9 @@ export function SimpleInlineLinkText({
href = createProxiedUrl(href)
}
const onPress = () => {
const onPress = (e: GestureResponderEvent) => {
const exitEarlyIfFalse = outerOnPress?.(e)
if (exitEarlyIfFalse === false) return
Linking.openURL(href)
}
@@ -517,7 +519,7 @@ export function WebOnlyInlineLinkText({
export function createStaticClick(
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
): {
to: BaseLinkProps['to']
to: string
onPress: Exclude<BaseLinkProps['onPress'], undefined>
} {
return {
+27
View File
@@ -4,6 +4,10 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {
EmptyState,
type EmptyStateButtonProps,
} from '#/view/com/util/EmptyState'
import {CenteredView} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -129,6 +133,9 @@ let ListMaybePlaceholder = ({
hideBackButton,
sideBorders,
topBorder = false,
emptyStateIcon,
emptyStateButton,
useEmptyState = false,
}: {
isLoading: boolean
noEmpty?: boolean
@@ -143,6 +150,9 @@ let ListMaybePlaceholder = ({
hideBackButton?: boolean
sideBorders?: boolean
topBorder?: boolean
emptyStateIcon?: React.ComponentType<any> | React.ReactElement
emptyStateButton?: EmptyStateButtonProps
useEmptyState?: boolean
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
@@ -180,6 +190,23 @@ let ListMaybePlaceholder = ({
)
}
if (useEmptyState) {
return (
<View style={[t.atoms.border_contrast_low]}>
<EmptyState
icon={emptyStateIcon}
message={
emptyMessage ??
(emptyType === 'results'
? _(msg`No results found`)
: _(msg`Page not found`))
}
button={emptyStateButton}
/>
</View>
)
}
if (!noEmpty) {
return (
<Error
+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_25]}>
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}>
<Text style={[a.text_xs, a.leading_tight]}>
<Trans>Follows You</Trans>
</Text>
@@ -7,7 +7,6 @@ import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as BandwidthEstimate from './bandwidth-estimate'
import {Controls} from './web-controls/VideoControls'
@@ -102,7 +101,6 @@ export function VideoEmbedInnerWeb({
hasSubtitleTrack={hasSubtitleTrack}
/>
</div>
<MediaInsetBorder />
</View>
)
}
+9 -29
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, useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -16,11 +16,9 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props {
embed: AppBskyEmbedVideo.View
crop?: 'none' | 'square' | 'constrained'
}
export function VideoEmbed({embed, crop}: Props) {
const t = useTheme()
export function VideoEmbed({embed}: Props) {
const [key, setKey] = useState(0)
const renderError = useCallback(
@@ -40,13 +38,10 @@ export function VideoEmbed({embed, crop}: Props) {
}
let constrained: number | undefined
let max: number | undefined
if (aspectRatio !== undefined) {
const ratio = 1 / 2 // max of 1:2 ratio in feeds
constrained = Math.max(aspectRatio, ratio)
max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
}
const cropDisabled = crop === 'none'
const contents = (
<ErrorBoundary renderError={renderError} key={key}>
@@ -56,28 +51,13 @@ export function VideoEmbed({embed, crop}: Props) {
return (
<View style={[a.pt_xs]}>
{cropDisabled ? (
<View
style={[
a.w_full,
a.overflow_hidden,
{aspectRatio: max ?? 1},
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
]}>
{contents}
</View>
) : (
<ConstrainedImage
fullBleed={crop === 'square'}
aspectRatio={constrained || 1}
// slightly smaller max height than images
// images use 16 / 9, for reference
minMobileAspectRatio={14 / 9}>
{contents}
</ConstrainedImage>
)}
<ConstrainedImage
aspectRatio={constrained || 1}
// slightly smaller max height than images
// images use 16 / 9, for reference
minMobileAspectRatio={14 / 9}>
{contents}
</ConstrainedImage>
</View>
)
}
@@ -17,6 +17,7 @@ import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
import {atoms as a, useTheme} from '#/alf'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {
HLSUnsupportedError,
VideoEmbedInnerWeb,
@@ -25,13 +26,7 @@ import {
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({
embed,
crop,
}: {
embed: AppBskyEmbedVideo.View
crop?: 'none' | 'square' | 'constrained'
}) {
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
const {active, setActive, sendPosition, currentActiveView} =
@@ -76,13 +71,10 @@ export function VideoEmbed({
}
let constrained: number | undefined
let max: number | undefined
if (aspectRatio !== undefined) {
const ratio = 1 / 2 // max of 1:2 ratio in feeds
constrained = Math.max(aspectRatio, ratio)
max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
}
const cropDisabled = crop === 'none'
const contents = (
<div
@@ -91,8 +83,11 @@ export function VideoEmbed({
display: 'flex',
flex: 1,
cursor: 'default',
backgroundColor: t.palette.black,
backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'cover',
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
onClick={evt => evt.stopPropagation()}>
<ErrorBoundary renderError={renderError} key={key}>
@@ -114,28 +109,15 @@ export function VideoEmbed({
<ViewportObserver
sendPosition={sendPosition}
isAnyViewActive={currentActiveView !== null}>
{cropDisabled ? (
<View
style={[
a.w_full,
a.overflow_hidden,
{aspectRatio: max ?? 1},
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
]}>
{contents}
</View>
) : (
<ConstrainedImage
fullBleed={crop === 'square'}
aspectRatio={constrained || 1}
// slightly smaller max height than images
// images use 16 / 9, for reference
minMobileAspectRatio={14 / 9}>
{contents}
</ConstrainedImage>
)}
<ConstrainedImage
fullBleed
aspectRatio={constrained || 1}
// slightly smaller max height than images
// images use 16 / 9, for reference
minMobileAspectRatio={14 / 9}>
{contents}
<MediaInsetBorder />
</ConstrainedImage>
</ViewportObserver>
</View>
)
+1 -1
View File
@@ -112,7 +112,7 @@ function MediaEmbed({
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<VideoEmbed embed={embed.view} crop="constrained" />
<VideoEmbed embed={embed.view} />
</ContentHider>
)
}
+6 -7
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 Quote} from '#/components/icons/Quote'
import {Repost_Stroke2_Corner3_Rounded as Repost} from '#/components/icons/Repost'
import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote'
import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import {Text} from '#/components/Typography'
import {
@@ -84,7 +84,7 @@ let RepostButton = ({
}),
)
}>
<PostControlButtonIcon icon={Repost} />
<PostControlButtonIcon icon={RepostIcon} />
{typeof repostCount !== 'undefined' && repostCount > 0 && (
<PostControlButtonText testID="repostCount">
{formatPostStatCount(repostCount)}
@@ -156,7 +156,7 @@ let RepostButtonDialogInner = ({
size="large"
variant="ghost"
color="primary">
<Repost size="lg" fill={t.palette.primary_500} />
<RepostIcon size="lg" fill={t.palette.primary_500} />
<Text style={[a.font_semi_bold, a.text_xl]}>
{isReposted ? (
<Trans>Remove repost</Trans>
@@ -178,7 +178,7 @@ let RepostButtonDialogInner = ({
size="large"
variant="ghost"
color="primary">
<Quote
<QuoteIcon
size="lg"
fill={
embeddingDisabled
@@ -204,8 +204,7 @@ let RepostButtonDialogInner = ({
label={_(msg`Cancel quote post`)}
onPress={onPressClose}
size="large"
variant="outline"
color="primary">
color="secondary">
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
@@ -11,7 +11,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
@@ -24,6 +23,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
import * as Menu from '#/components/Menu'
import {useAgeAssurance} from '#/ageAssurance'
import {useDevMode} from '#/storage/hooks/dev-mode'
import {RecentChats} from './RecentChats'
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
@@ -37,7 +37,7 @@ let ShareMenuItems = ({
const navigation = useNavigation<NavigationProp>()
const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode()
const {isAgeRestricted} = useAgeAssurance()
const aa = useAgeAssurance()
const postUri = post.uri
const postAuthor = useProfileShadow(post.author)
@@ -91,7 +91,7 @@ let ShareMenuItems = ({
return (
<>
<Menu.Outer>
{hasSession && !isAgeRestricted && (
{hasSession && aa.state.access === aa.Access.Full && (
<Menu.Group>
<Menu.ContainerItem>
<RecentChats postUri={postUri} />
@@ -10,7 +10,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import {useBreakpoints} from '#/alf'
@@ -22,6 +21,7 @@ import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/i
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
import * as Menu from '#/components/Menu'
import {useAgeAssurance} from '#/ageAssurance'
import {useDevMode} from '#/storage/hooks/dev-mode'
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
@@ -38,7 +38,7 @@ let ShareMenuItems = ({
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode()
const {isAgeRestricted} = useAgeAssurance()
const aa = useAgeAssurance()
const postUri = post.uri
const postCid = post.cid
@@ -97,7 +97,7 @@ let ShareMenuItems = ({
<Menu.Outer>
{!hideInPWI && copyLinkItem}
{hasSession && !isAgeRestricted && (
{hasSession && aa.state.access === aa.Access.Full && (
<Menu.Item
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
+31 -2
View File
@@ -1,5 +1,11 @@
import {useMemo} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
type GestureResponderEvent,
type StyleProp,
type TextStyle,
View,
type ViewStyle,
} from 'react-native'
import {
moderateProfile,
type ModerationOpts,
@@ -48,11 +54,15 @@ export function Default({
moderationOpts,
logContext = 'ProfileCard',
testID,
position,
contextProfileDid,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
testID?: string
position?: number
contextProfileDid?: string
}) {
return (
<Link testID={testID} profile={profile}>
@@ -60,6 +70,8 @@ export function Default({
profile={profile}
moderationOpts={moderationOpts}
logContext={logContext}
position={position}
contextProfileDid={contextProfileDid}
/>
</Link>
)
@@ -69,10 +81,14 @@ export function Card({
profile,
moderationOpts,
logContext = 'ProfileCard',
position,
contextProfileDid,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
position?: number
contextProfileDid?: string
}) {
return (
<Outer>
@@ -83,6 +99,8 @@ export function Card({
profile={profile}
moderationOpts={moderationOpts}
logContext={logContext}
position={position}
contextProfileDid={contextProfileDid}
/>
</Header>
@@ -271,9 +289,13 @@ function InlineNameAndHandle({
export function Name({
profile,
moderationOpts,
style,
textStyle,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
style?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle>
}) {
const moderation = moderateProfile(profile, moderationOpts)
const name = sanitizeDisplayName(
@@ -282,7 +304,7 @@ export function Name({
)
const verification = useSimpleVerificationState({profile})
return (
<View style={[a.flex_row, a.align_center, a.max_w_full]}>
<View style={[a.flex_row, a.align_center, a.max_w_full, style]}>
<Text
emoji
style={[
@@ -291,6 +313,7 @@ export function Name({
a.leading_snug,
a.self_start,
a.flex_shrink,
textStyle,
]}
numberOfLines={1}>
{name}
@@ -437,6 +460,8 @@ export type FollowButtonProps = {
colorInverted?: boolean
onFollow?: () => void
withIcon?: boolean
position?: number
contextProfileDid?: string
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
@@ -453,6 +478,8 @@ export function FollowButtonInner({
onFollow,
colorInverted,
withIcon = true,
position,
contextProfileDid,
...rest
}: FollowButtonProps) {
const {_} = useLingui()
@@ -461,6 +488,8 @@ export function FollowButtonInner({
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
logContext,
position,
contextProfileDid,
)
const isRound = Boolean(rest.shape && rest.shape === 'round')
@@ -8,7 +8,7 @@ import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {logEvent} from '#/lib/statsig/statsig'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {useActorSearch} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {useSession} from '#/state/session'
@@ -128,7 +128,7 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
isFetching: isFetchingSearchResults,
error: searchResultsError,
isError: isSearchResultsError,
} = useActorSearchPaginated({
} = useActorSearch({
enabled: !!searchText,
query: searchText,
})
@@ -1,85 +0,0 @@
import {View} from 'react-native'
import {type AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, useButtonContext} from '#/components/Button'
import {Divider} from '#/components/Divider'
import * as LabelingServiceCard from '#/components/LabelingServiceCard'
import {Text} from '#/components/Typography'
import {type ReportDialogProps} from './types'
export function SelectLabelerView({
...props
}: ReportDialogProps & {
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
onSelectLabeler: (v: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
return (
<View style={[a.gap_lg]}>
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
<Trans>Select moderator</Trans>
</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>To whom would you like to send this report?</Trans>
</Text>
</View>
<Divider />
<View style={[a.gap_sm]}>
{props.labelers.map(labeler => {
return (
<Button
key={labeler.creator.did}
label={_(msg`Send report to ${labeler.creator.displayName}`)}
onPress={() => props.onSelectLabeler(labeler.creator.did)}>
<LabelerButton labeler={labeler} />
</Button>
)
})}
</View>
</View>
)
}
function LabelerButton({
labeler,
}: {
labeler: AppBskyLabelerDefs.LabelerViewDetailed
}) {
const t = useTheme()
const {hovered, pressed} = useButtonContext()
const interacted = hovered || pressed
return (
<LabelingServiceCard.Outer
style={[
a.p_md,
a.rounded_sm,
t.atoms.bg_contrast_25,
interacted && t.atoms.bg_contrast_50,
]}>
<LabelingServiceCard.Avatar avatar={labeler.creator.avatar} />
<LabelingServiceCard.Content>
<LabelingServiceCard.Title
value={getLabelingServiceTitle({
displayName: labeler.creator.displayName,
handle: labeler.creator.handle,
})}
/>
<Text
style={[t.atoms.text_contrast_medium, a.text_sm, a.font_semi_bold]}>
@{labeler.creator.handle}
</Text>
</LabelingServiceCard.Content>
</LabelingServiceCard.Outer>
)
}
@@ -1,195 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {type AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type ReportOption,
useReportOptions,
} from '#/lib/moderation/useReportOptions'
import {Link} from '#/components/Link'
import {DMCA_LINK} from '#/components/ReportDialog/const'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {
Button,
ButtonIcon,
ButtonText,
useButtonContext,
} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft,
ChevronRight_Stroke2_Corner0_Rounded as ChevronRight,
} from '#/components/icons/Chevron'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
import {Text} from '#/components/Typography'
import {type ReportDialogProps} from './types'
export function SelectReportOptionView(props: {
params: ReportDialogProps['params']
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
onSelectReportOption: (reportOption: ReportOption) => void
goBack: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const allReportOptions = useReportOptions()
const reportOptions = allReportOptions[props.params.type]
const i18n = React.useMemo(() => {
let title = _(msg`Report this content`)
let description = _(msg`Why should this content be reviewed?`)
if (props.params.type === 'account') {
title = _(msg`Report this user`)
description = _(msg`Why should this user be reviewed?`)
} else if (props.params.type === 'post') {
title = _(msg`Report this post`)
description = _(msg`Why should this post be reviewed?`)
} else if (props.params.type === 'list') {
title = _(msg`Report this list`)
description = _(msg`Why should this list be reviewed?`)
} else if (props.params.type === 'feedgen') {
title = _(msg`Report this feed`)
description = _(msg`Why should this feed be reviewed?`)
} else if (props.params.type === 'starterpack') {
title = _(msg`Report this starter pack`)
description = _(msg`Why should this starter pack be reviewed?`)
} else if (props.params.type === 'convoMessage') {
title = _(msg`Report this message`)
description = _(msg`Why should this message be reviewed?`)
}
return {
title,
description,
}
}, [_, props.params.type])
return (
<View style={[a.gap_lg]}>
{props.labelers?.length > 1 ? (
<Button
size="small"
variant="solid"
color="secondary"
shape="round"
label={_(msg`Go back to previous step`)}
onPress={props.goBack}>
<ButtonIcon icon={ChevronLeft} />
</Button>
) : null}
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
<Text style={[a.text_2xl, a.font_semi_bold]}>{i18n.title}</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
{i18n.description}
</Text>
</View>
<Divider />
<View style={[a.gap_sm]}>
{reportOptions.map(reportOption => {
return (
<Button
key={reportOption.reason}
testID={reportOption.reason}
label={_(msg`Create report for ${reportOption.title}`)}
onPress={() => props.onSelectReportOption(reportOption)}>
<ReportOptionButton
title={reportOption.title}
description={reportOption.description}
/>
</Button>
)
})}
{(props.params.type === 'post' || props.params.type === 'account') && (
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.p_md,
a.pl_lg,
a.rounded_md,
t.atoms.bg_contrast_900,
]}>
<Text
style={[
a.flex_1,
t.atoms.text_inverted,
a.italic,
a.leading_snug,
]}>
<Trans>Need to report a copyright violation?</Trans>
</Text>
<Link
to={DMCA_LINK}
label={_(msg`View details for reporting a copyright violation`)}
size="small"
variant="solid"
color="secondary">
<ButtonText>
<Trans>View details</Trans>
</ButtonText>
<ButtonIcon position="right" icon={SquareArrowTopRight} />
</Link>
</View>
)}
</View>
</View>
)
}
function ReportOptionButton({
title,
description,
}: {
title: string
description: string
}) {
const t = useTheme()
const {hovered, pressed} = useButtonContext()
const interacted = hovered || pressed
return (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_between,
a.p_md,
a.rounded_md,
{paddingRight: 70},
t.atoms.bg_contrast_25,
interacted && t.atoms.bg_contrast_50,
]}>
<View style={[a.flex_1, a.gap_xs]}>
<Text
style={[a.text_md, a.font_semi_bold, t.atoms.text_contrast_medium]}>
{title}
</Text>
<Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text>
</View>
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.pr_md,
{left: 'auto'},
]}>
<ChevronRight size="md" fill={t.atoms.text_contrast_low.color} />
</View>
</View>
)
}
-274
View File
@@ -1,274 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {type AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {type ReportOption} from '#/lib/moderation/useReportOptions'
import {isAndroid} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {PaperPlane_Stroke2_Corner0_Rounded as SendIcon} from '#/components/icons/PaperPlane'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {type ReportDialogProps} from './types'
export function SubmitView({
params,
labelers,
selectedLabeler,
selectedReportOption,
goBack,
onSubmitComplete,
}: ReportDialogProps & {
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
selectedLabeler: string
selectedReportOption: ReportOption
goBack: () => void
onSubmitComplete: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
const [details, setDetails] = React.useState<string>('')
const [submitting, setSubmitting] = React.useState<boolean>(false)
const [selectedServices, setSelectedServices] = React.useState<string[]>([
selectedLabeler,
])
const [error, setError] = React.useState('')
const submit = React.useCallback(async () => {
setSubmitting(true)
setError('')
const $type =
params.type === 'account'
? 'com.atproto.admin.defs#repoRef'
: 'com.atproto.repo.strongRef'
const report = {
reasonType: selectedReportOption.reason,
subject: {
$type,
...params,
},
reason: details,
}
const results = await Promise.all(
selectedServices.map(did => {
return agent
.createModerationReport(report, {
encoding: 'application/json',
headers: {
'atproto-proxy': `${did}#atproto_labeler`,
},
})
.then(
_ => true,
_ => false,
)
}),
)
setSubmitting(false)
if (results.includes(true)) {
Toast.show(_(msg`Thank you. Your report has been sent.`))
onSubmitComplete()
} else {
setError(
_(
msg`There was an issue sending your report. Please check your internet connection.`,
),
)
}
}, [
_,
params,
details,
selectedReportOption,
selectedServices,
onSubmitComplete,
setError,
agent,
])
return (
<View style={[a.gap_2xl]}>
<Button
size="small"
variant="solid"
color="secondary"
shape="round"
label={_(msg`Go back to previous step`)}
onPress={goBack}>
<ButtonIcon icon={ChevronLeft} />
</Button>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.p_md,
a.rounded_md,
a.border,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.text_md, a.font_semi_bold]}>
{selectedReportOption.title}
</Text>
<Text style={[a.leading_tight, {maxWidth: 400}]}>
{selectedReportOption.description}
</Text>
</View>
<Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} />
</View>
<View style={[a.gap_md]}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Select the moderation service(s) to report to</Trans>
</Text>
<Toggle.Group
label="Select mod services"
values={selectedServices}
onChange={setSelectedServices}>
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
{labelers.map(labeler => {
const title = getLabelingServiceTitle({
displayName: labeler.creator.displayName,
handle: labeler.creator.handle,
})
return (
<Toggle.Item
key={labeler.creator.did}
name={labeler.creator.did}
label={title}>
<LabelerToggle title={title} />
</Toggle.Item>
)
})}
</View>
</Toggle.Group>
</View>
<View style={[a.gap_md]}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Optionally provide additional information below:</Trans>
</Text>
<View style={[a.relative, a.w_full]}>
<Dialog.Input
multiline
value={details}
onChangeText={setDetails}
label="Text field"
style={{paddingRight: 60}}
numberOfLines={6}
/>
<View
style={[
a.absolute,
a.flex_row,
a.align_center,
a.pr_md,
a.pb_sm,
{
bottom: 0,
right: 0,
},
]}>
<CharProgress count={details?.length || 0} />
</View>
</View>
</View>
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}>
{!selectedServices.length ||
(error && (
<Text
style={[
a.flex_1,
a.italic,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{error ? (
error
) : (
<Trans>You must select at least one labeler for a report</Trans>
)}
</Text>
))}
<Button
testID="sendReportBtn"
size="large"
variant="solid"
color="negative"
label={_(msg`Send report`)}
onPress={submit}
disabled={!selectedServices.length}>
<ButtonText>
<Trans>Send report</Trans>
</ButtonText>
<ButtonIcon icon={submitting ? Loader : SendIcon} />
</Button>
</View>
{/* Maybe fix this later -h */}
{isAndroid ? <View style={{height: 300}} /> : null}
</View>
)
}
function LabelerToggle({title}: {title: string}) {
const t = useTheme()
const ctx = Toggle.useItemContext()
return (
<View
style={[
a.flex_row,
a.align_center,
a.gap_md,
a.p_md,
a.pr_lg,
a.rounded_sm,
a.overflow_hidden,
t.atoms.bg_contrast_25,
ctx.selected && [t.atoms.bg_contrast_50],
]}>
<Toggle.Checkbox />
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.z_10,
]}>
<Text
emoji
style={[
native({marginTop: 2}),
t.atoms.text_contrast_medium,
ctx.selected && t.atoms.text,
]}>
{title}
</Text>
</View>
</View>
)
}
-1
View File
@@ -1 +0,0 @@
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
-97
View File
@@ -1,97 +0,0 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {type ScrollView} from 'react-native-gesture-handler'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type ReportOption} from '#/lib/moderation/useReportOptions'
import {useMyLabelersQuery} from '#/state/queries/preferences'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {type AppBskyLabelerDefs} from '@atproto/api'
import {atoms as a} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {SelectLabelerView} from './SelectLabelerView'
import {SelectReportOptionView} from './SelectReportOptionView'
import {SubmitView} from './SubmitView'
import {type ReportDialogProps} from './types'
export function ReportDialog(props: ReportDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<ReportDialogInner {...props} />
</Dialog.Outer>
)
}
function ReportDialogInner(props: ReportDialogProps) {
const {_} = useLingui()
const {
isLoading: isLabelerLoading,
data: labelers,
error,
} = useMyLabelersQuery({excludeNonConfigurableLabelers: true})
const isLoading = useDelayedLoading(500, isLabelerLoading)
const ref = React.useRef<ScrollView>(null)
return (
<Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
{isLoading ? (
<View style={[a.align_center, {height: 100}]}>
<Loader size="xl" />
{/* Here to capture focus for a hot sec to prevent flash */}
<Pressable accessible={false} />
</View>
) : error || !labelers ? (
<View>
<Text style={[a.text_md]}>
<Trans>Something went wrong, please try again.</Trans>
</Text>
</View>
) : (
<ReportDialogLoaded labelers={labelers} {...props} />
)}
</Dialog.ScrollableInner>
)
}
function ReportDialogLoaded(
props: ReportDialogProps & {
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
},
) {
const [selectedLabeler, setSelectedLabeler] = React.useState<
string | undefined
>(props.labelers.length === 1 ? props.labelers[0].creator.did : undefined)
const [selectedReportOption, setSelectedReportOption] = React.useState<
ReportOption | undefined
>()
if (selectedReportOption && selectedLabeler) {
return (
<SubmitView
{...props}
selectedLabeler={selectedLabeler}
selectedReportOption={selectedReportOption}
goBack={() => setSelectedReportOption(undefined)}
onSubmitComplete={() => props.control.close()}
/>
)
}
if (selectedLabeler) {
return (
<SelectReportOptionView
{...props}
goBack={() => setSelectedLabeler(undefined)}
onSelectReportOption={setSelectedReportOption}
/>
)
}
return <SelectLabelerView {...props} onSelectLabeler={setSelectedLabeler} />
}
-16
View File
@@ -1,16 +0,0 @@
import type * as Dialog from '#/components/Dialog'
export type ReportDialogProps = {
control: Dialog.DialogOuterProps['control']
params:
| {
type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other'
uri: string
cid: string
}
| {
type: 'account'
did: string
}
| {type: 'convoMessage'}
}
+3 -3
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {type TextStyle} from 'react-native'
import {type StyleProp, type TextStyle} from 'react-native'
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
import {toShortUrl} from '#/lib/strings/url-helpers'
@@ -21,7 +21,7 @@ export type RichTextProps = TextStyleProp &
enableTags?: boolean
authorHandle?: string
onLinkPress?: LinkProps['onPress']
interactiveStyle?: TextStyle
interactiveStyle?: StyleProp<TextStyle>
emojiMultiplier?: number
shouldProxyLinks?: boolean
}
@@ -55,7 +55,7 @@ export function RichText({
if (!facets?.length) {
if (isOnlyEmoji(text)) {
const flattenedStyle = flatten(style)
const flattenedStyle = flatten(style) ?? {}
const fontSize =
(flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
return (
+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"
variant="solid">
shape="rectangular">
<>{children}</>
</Button>
)
+18 -8
View File
@@ -1,4 +1,4 @@
import {createContext, forwardRef, useContext, useMemo} from 'react'
import {createContext, forwardRef, Fragment, useContext, useMemo} from 'react'
import {View} from 'react-native'
import {Select as RadixSelect} from 'radix-ui'
@@ -96,8 +96,7 @@ export function Trigger({children, label}: TriggerProps) {
style={flatten([
a.flex,
a.relative,
t.atoms.bg_contrast_25,
a.rounded_sm,
t.atoms.bg_contrast_50,
a.w_full,
a.align_center,
a.gap_sm,
@@ -106,15 +105,14 @@ export function Trigger({children, label}: TriggerProps) {
a.px_md,
a.pointer,
{
borderRadius: 10,
maxWidth: 400,
outline: 0,
borderWidth: 2,
borderStyle: 'solid',
borderColor: focused
? t.palette.primary_500
: hovered
? t.palette.contrast_100
: t.palette.contrast_25,
: t.palette.contrast_50,
},
])}>
{children}
@@ -140,7 +138,11 @@ export function Icon({style}: IconProps) {
)
}
export function Content<T>({items, renderItem}: ContentProps<T>) {
export function Content<T>({
items,
renderItem,
valueExtractor = defaultItemValueExtractor,
}: ContentProps<T>) {
const t = useTheme()
const selectedValue = useContext(SelectedValueContext)
@@ -198,7 +200,11 @@ export function Content<T>({items, renderItem}: ContentProps<T>) {
<ChevronUpIcon style={[t.atoms.text]} size="xs" />
</RadixSelect.ScrollUpButton>
<RadixSelect.Viewport style={flatten([a.p_xs])}>
{items.map((item, index) => renderItem(item, index, selectedValue))}
{items.map((item, index) => (
<Fragment key={valueExtractor(item)}>
{renderItem(item, index, selectedValue)}
</Fragment>
))}
</RadixSelect.Viewport>
<RadixSelect.ScrollDownButton style={flatten(down)}>
<ChevronDownIcon style={[t.atoms.text]} size="xs" />
@@ -209,6 +215,10 @@ export function Content<T>({items, renderItem}: ContentProps<T>) {
)
}
function defaultItemValueExtractor(item: any) {
return item.value
}
const ItemContext = createContext<{
hovered: boolean
focused: boolean
@@ -9,6 +9,7 @@ import {PostFeed} from '#/view/com/posts/PostFeed'
import {EmptyState} from '#/view/com/util/EmptyState'
import {type ListRef} from '#/view/com/util/List'
import {type SectionRef} from '#/screens/Profile/Sections/types'
import {HashtagWide_Stroke1_Corner0_Rounded as HashtagWideIcon} from '#/components/icons/Hashtag'
interface ProfilesListProps {
listUri: string
@@ -33,7 +34,13 @@ export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
}))
const renderPostsEmpty = useCallback(() => {
return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
return (
<EmptyState
icon={HashtagWideIcon}
iconSize="2xl"
message={_(msg`This feed is empty.`)}
/>
)
}, [_])
return (
@@ -21,6 +21,10 @@ import {parseStarterPackUri} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
import {
EmptyState,
type EmptyStateButtonProps,
} from '#/view/com/util/EmptyState'
import {List, type ListRef} from '#/view/com/util/List'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, ios, useTheme} from '#/alf'
@@ -47,6 +51,9 @@ interface ProfileFeedgensProps {
testID?: string
setScrollViewTag: (tag: number | null) => void
isMe: boolean
emptyStateMessage?: string
emptyStateButton?: EmptyStateButtonProps
emptyStateIcon?: React.ComponentType<any> | React.ReactElement
}
function keyExtractor(item: AppBskyGraphDefs.StarterPackView) {
@@ -63,6 +70,9 @@ export function ProfileStarterPacks({
testID,
setScrollViewTag,
isMe,
emptyStateMessage,
emptyStateButton,
emptyStateIcon,
}: ProfileFeedgensProps) {
const t = useTheme()
const bottomBarOffset = useBottomBarOffset(100)
@@ -79,6 +89,28 @@ export function ProfileStarterPacks({
const {isTabletOrDesktop} = useWebMediaQueries()
const items = data?.pages.flatMap(page => page.starterPacks)
const {_} = useLingui()
const EmptyComponent = useCallback(() => {
if (emptyStateMessage || emptyStateButton || emptyStateIcon) {
return (
<View style={[a.px_lg, a.align_center, a.justify_center]}>
<EmptyState
icon={emptyStateIcon}
iconSize="3xl"
message={
emptyStateMessage ??
_(
'Starter packs let you share your favorite feeds and people with your friends.',
)
}
button={emptyStateButton}
/>
</View>
)
}
return <Empty />
}, [_, emptyStateMessage, emptyStateButton, emptyStateIcon])
useImperativeHandle(ref, () => ({
scrollToTop: () => {},
@@ -146,7 +178,7 @@ export function ProfileStarterPacks({
onEndReached={onEndReached}
onRefresh={onRefresh}
ListEmptyComponent={
data ? (isMe ? Empty : undefined) : FeedLoadingPlaceholder
data ? (isMe ? EmptyComponent : undefined) : FeedLoadingPlaceholder
}
ListFooterComponent={
!!data && items?.length !== 0 && isMe ? CreateAnother : undefined
+19
View File
@@ -0,0 +1,19 @@
export function SheetCompatProvider({children}: {children: React.ReactNode}) {
return children
}
export function Outer({children}: {children: React.ReactNode}) {
return children
}
export function Target({children}: {children: React.ReactNode}) {
return children
}
export function Content() {
return null
}
export function TextBubble() {
return null
}

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