Compare commits

..

61 Commits

Author SHA1 Message Date
Samuel Newman 0081b37900 dispose of input once finished 2025-11-27 16:41:32 +02:00
Samuel Newman 0a7209c623 process videos on web with webcodecs 2025-11-27 16:31:19 +02: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
244 changed files with 51255 additions and 44786 deletions
+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 () => {
@@ -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
@@ -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
@@ -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)
}
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.110.0",
"version": "1.111.0",
"private": true,
"engines": {
"node": ">=20"
@@ -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",
@@ -173,10 +173,11 @@
"lodash.isequal": "^4.5.0",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"mediabunny": "^1.25.3",
"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)
+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,
+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
+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>
+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>
+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,
})
+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
}
+65 -9
View File
@@ -12,9 +12,11 @@ import {useWindowDimensions, View} from 'react-native'
import Animated, {Easing, ZoomIn} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {atoms as a, select, useTheme} from '#/alf'
import {useOnGesture} from '#/components/hooks/useOnGesture'
import {Portal} from '#/components/Portal'
import {createPortalGroup, Portal as RootPortal} from '#/components/Portal'
import {
ARROW_HALF_SIZE,
ARROW_SIZE,
@@ -23,6 +25,33 @@ import {
} from '#/components/Tooltip/const'
import {Text} from '#/components/Typography'
const TooltipPortal = createPortalGroup()
const TooltipProviderContext =
createContext<React.RefObject<View | null> | null>(null)
/**
* Provider for Tooltip component. Only needed when you need to position the tooltip relative to a container,
* such as in the composer sheet.
*
* Only really necessary on iOS but can work on Android.
*/
export function SheetCompatProvider({children}: {children: React.ReactNode}) {
const ref = useRef<View | null>(null)
return (
<GlobalGestureEventsProvider style={[a.flex_1]}>
<TooltipPortal.Provider>
<View ref={ref} collapsable={false} style={[a.flex_1]}>
<TooltipProviderContext value={ref}>
{children}
</TooltipProviderContext>
</View>
<TooltipPortal.Outlet />
</TooltipPortal.Provider>
</GlobalGestureEventsProvider>
)
}
SheetCompatProvider.displayName = 'TooltipSheetCompatProvider'
/**
* These are native specific values, not shared with web
*/
@@ -120,22 +149,46 @@ export function Outer({
export function Target({children}: {children: React.ReactNode}) {
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
const [hasLayedOut, setHasLayedOut] = useState(false)
const targetRef = useRef<View>(null)
const containerRef = useContext(TooltipProviderContext)
const keyboardIsOpen = useIsKeyboardVisible()
useEffect(() => {
if (!shouldMeasure) return
if (!shouldMeasure || !hasLayedOut) return
/*
* Once opened, measure the dimensions and position of the target
*/
targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
if (pageX !== undefined && pageY !== undefined && width && height) {
setTargetMeasurements({x: pageX, y: pageY, width, height})
}
})
}, [shouldMeasure, setTargetMeasurements])
if (containerRef?.current) {
targetRef.current?.measureLayout(
containerRef.current,
(x, y, width, height) => {
if (x !== undefined && y !== undefined && width && height) {
setTargetMeasurements({x, y, width, height})
}
},
)
} else {
targetRef.current?.measure((_x, _y, width, height, x, y) => {
if (x !== undefined && y !== undefined && width && height) {
setTargetMeasurements({x, y, width, height})
}
})
}
}, [
shouldMeasure,
setTargetMeasurements,
hasLayedOut,
containerRef,
keyboardIsOpen,
])
return (
<View collapsable={false} ref={targetRef}>
<View
collapsable={false}
ref={targetRef}
onLayout={() => setHasLayedOut(true)}>
{children}
</View>
)
@@ -150,12 +203,15 @@ export function Content({
}) {
const {position, visible, onVisibleChange} = useContext(TooltipContext)
const {targetMeasurements} = useContext(TargetContext)
const isWithinProvider = !!useContext(TooltipProviderContext)
const requestClose = useCallback(() => {
onVisibleChange(false)
}, [onVisibleChange])
if (!visible || !targetMeasurements) return null
const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal
return (
<Portal>
<Bubble
+14 -8
View File
@@ -11,14 +11,19 @@ import {
} from '#/components/Tooltip/const'
import {Text} from '#/components/Typography'
// Portal Provider on native, but we actually don't need to do anything here
export function Provider({children}: {children: React.ReactNode}) {
return <>{children}</>
}
Provider.displayName = 'TooltipProvider'
type TooltipContextType = {
position: 'top' | 'bottom'
onVisibleChange: (open: boolean) => void
}
const TooltipContext = createContext<TooltipContextType>({
const TooltipContext = createContext<Pick<TooltipContextType, 'position'>>({
position: 'bottom',
onVisibleChange: () => {},
})
TooltipContext.displayName = 'TooltipContext'
@@ -33,10 +38,7 @@ export function Outer({
visible: boolean
onVisibleChange: (visible: boolean) => void
}) {
const ctx = useMemo(
() => ({position, onVisibleChange}),
[position, onVisibleChange],
)
const ctx = useMemo(() => ({position}), [position])
return (
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
@@ -60,7 +62,7 @@ export function Content({
label: string
}) {
const t = useTheme()
const {position, onVisibleChange} = useContext(TooltipContext)
const {position} = useContext(TooltipContext)
return (
<Popover.Portal>
<Popover.Content
@@ -69,7 +71,11 @@ export function Content({
side={position}
sideOffset={4}
collisionPadding={MIN_EDGE_SPACE}
onInteractOutside={() => onVisibleChange(false)}
onInteractOutside={evt => {
if (evt.type === 'dismissableLayer.focusOutside') {
evt.preventDefault()
}
}}
style={flatten([
a.rounded_sm,
select(t.name, {
+48 -16
View File
@@ -1,4 +1,4 @@
import {Fragment, useMemo} from 'react'
import {Fragment, useMemo, useRef} from 'react'
import {
Keyboard,
Platform,
@@ -17,12 +17,13 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {
type ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate'
import {atoms as a, useTheme, web} from '#/alf'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
@@ -30,13 +31,13 @@ import {
PostInteractionSettingsDialog,
usePrefetchPostInteractionSettings,
} from '#/components/dialogs/PostInteractionSettingsDialog'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronDownIcon} from '#/components/icons/Chevron'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSignIcon} from '#/components/icons/CircleBanSign'
import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps {
post: AppBskyFeedDefs.PostView
@@ -69,6 +70,11 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
postUri: post.uri,
rootPostUri: rootUri,
})
const prefetchPromise = useRef<Promise<void>>(Promise.resolve())
const prefetch = () => {
prefetchPromise.current = prefetchPostInteractionSettings()
}
const anyoneCanReply =
settings.length === 1 && settings[0].type === 'everybody'
@@ -84,8 +90,19 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
Keyboard.dismiss()
}
if (isThreadAuthor) {
editDialogControl.open()
logger.metric('thread:click:editOwnThreadgate', {})
// wait on prefetch if it manages to resolve in under 200ms
// otherwise, proceed immediately and show the spinner -sfn
Promise.race([
prefetchPromise.current,
new Promise(res => setTimeout(res, 200)),
]).finally(() => {
editDialogControl.open()
})
} else {
logger.metric('thread:click:viewSomeoneElsesThreadgate', {})
infoDialogControl.open()
}
}
@@ -100,18 +117,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
{...(isThreadAuthor
? Platform.select({
web: {
onHoverIn: prefetchPostInteractionSettings,
onHoverIn: prefetch,
},
native: {
onPressIn: prefetchPostInteractionSettings,
onPressIn: prefetch,
},
})
: {})}
hitSlop={HITSLOP_10}>
{({hovered}) => (
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
{({hovered, focused, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
(hovered || focused || pressed) && native({opacity: 0.5}),
style,
]}>
<Icon
color={t.palette.contrast_400}
color={
isThreadAuthor ? t.palette.primary_500 : t.palette.contrast_400
}
width={16}
settings={settings}
/>
@@ -119,14 +145,16 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
style={[
a.text_sm,
a.leading_tight,
t.atoms.text_contrast_medium,
hovered && a.underline,
isThreadAuthor
? {color: t.palette.primary_500}
: t.atoms.text_contrast_medium,
(hovered || focused || pressed) && web(a.underline),
]}>
{description}
</Text>
{isThreadAuthor && (
<PencilLine width={12} fill={t.palette.primary_500} />
<TinyChevronDownIcon width={8} fill={t.palette.primary_500} />
)}
</View>
)}
@@ -164,7 +192,11 @@ function Icon({
settings.length === 0 ||
settings.every(setting => setting.type === 'everybody')
const isNobody = !!settings.find(gate => gate.type === 'nobody')
const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group
const IconComponent = isEverybody
? EarthIcon
: isNobody
? CircleBanSignIcon
: GroupIcon
return <IconComponent fill={color} width={width} />
}
@@ -1,4 +1,4 @@
import {useCallback} from 'react'
import {useCallback, useEffect, useState} from 'react'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -18,17 +18,32 @@ import {SubscribeProfileDialog} from './SubscribeProfileDialog'
export function SubscribeProfileButton({
profile,
moderationOpts,
disableHint,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
disableHint?: boolean
}) {
const {_} = useLingui()
const requireEmailVerification = useRequireEmailVerification()
const subscribeDialogControl = useDialogControl()
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
useActivitySubscriptionsNudged()
const [showTooltip, setShowTooltip] = useState(false)
const onDismissTooltip = () => {
useEffect(() => {
if (!activitySubscriptionsNudged) {
const timeout = setTimeout(() => {
setShowTooltip(true)
}, 500)
return () => clearTimeout(timeout)
}
}, [activitySubscriptionsNudged])
const onDismissTooltip = (visible: boolean) => {
if (visible) return
setShowTooltip(false)
setActivitySubscriptionsNudged(true)
}
@@ -56,7 +71,7 @@ export function SubscribeProfileButton({
return (
<>
<Tooltip.Outer
visible={!activitySubscriptionsNudged}
visible={showTooltip && !disableHint}
onVisibleChange={onDismissTooltip}
position="bottom">
<Tooltip.Target>
@@ -65,7 +80,6 @@ export function SubscribeProfileButton({
testID="dmBtn"
size="small"
color="secondary"
variant="solid"
shape="round"
label={_(msg`Get notified when ${name} posts`)}
onPress={wrappedOnPress}>
@@ -1,6 +1,6 @@
import React from 'react'
import {View} from 'react-native'
import {ComAtprotoModerationDefs} from '@atproto/api'
import {ToolsOzoneReportDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
@@ -50,7 +50,7 @@ function Inner({control}: {control: Dialog.DialogControlProps}) {
await agent.createModerationReport(
{
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
reasonType: ToolsOzoneReportDefs.REASONAPPEAL,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: currentAccount?.did,
@@ -2,7 +2,10 @@ import {useMutation} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session'
export function useConfirmEmail() {
export function useConfirmEmail({
onSuccess,
onError,
}: {onSuccess?: () => void; onError?: () => void} = {}) {
const agent = useAgent()
const {currentAccount} = useSession()
@@ -13,11 +16,13 @@ export function useConfirmEmail() {
}
await agent.com.atproto.server.confirmEmail({
email: currentAccount.email,
email: currentAccount.email.trim(),
token: token.trim(),
})
// will update session state at root of app
await agent.resumeSession(agent.session!)
},
onSuccess,
onError,
})
}
+18 -17
View File
@@ -10,8 +10,8 @@ import {toShareUrl} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
import * as TextField from '#/components/forms/TextField'
import * as ToggleButton from '#/components/forms/ToggleButton'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
@@ -150,26 +150,27 @@ function EmbedDialogInner({
<Text style={[t.atoms.text_contrast_medium, a.font_semi_bold]}>
<Trans>Color theme</Trans>
</Text>
<ToggleButton.Group
<SegmentedControl.Root
label={_(msg`Color mode`)}
values={[colorMode]}
onChange={([value]) => setColorMode(value as ColorModeValues)}>
<ToggleButton.Button name="system" label={_(msg`System`)}>
<ToggleButton.ButtonText>
type="radio"
value={colorMode}
onChange={setColorMode}>
<SegmentedControl.Item value="system" label={_(msg`System`)}>
<SegmentedControl.ItemText>
<Trans>System</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button name="light" label={_(msg`Light`)}>
<ToggleButton.ButtonText>
</SegmentedControl.ItemText>
</SegmentedControl.Item>
<SegmentedControl.Item value="light" label={_(msg`Light`)}>
<SegmentedControl.ItemText>
<Trans>Light</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button name="dark" label={_(msg`Dark`)}>
<ToggleButton.ButtonText>
</SegmentedControl.ItemText>
</SegmentedControl.Item>
<SegmentedControl.Item value="dark" label={_(msg`Dark`)}>
<SegmentedControl.ItemText>
<Trans>Dark</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
</ToggleButton.Group>
</SegmentedControl.ItemText>
</SegmentedControl.Item>
</SegmentedControl.Root>
</View>
)}
</View>
@@ -1,16 +1,18 @@
import React from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {useCallback, useMemo, useState} from 'react'
import {LayoutAnimation, Text as NestedText, View} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyFeedPostgate,
AtUri,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import isEqual from 'lodash.isequal'
import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {STALE} from '#/state/queries'
import {useMyListsQuery} from '#/state/queries/my-lists'
import {useGetPost} from '#/state/queries/post'
@@ -37,13 +39,17 @@ import {
} from '#/state/queries/usePostThread'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
} from '#/components/icons/Chevron'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
@@ -52,6 +58,10 @@ export type PostInteractionSettingsFormProps = {
onSave: () => void
isSaving?: boolean
isDirty?: boolean
persist?: boolean
onChangePersist?: (v: boolean) => void
postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
@@ -61,57 +71,52 @@ export type PostInteractionSettingsFormProps = {
replySettingsDisabled?: boolean
}
/**
* Threadgate settings dialog. Used in the composer.
*/
export function PostInteractionSettingsControlledDialog({
control,
...rest
}: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps
}) {
const t = useTheme()
const {_} = useLingui()
const onClose = useNonReactiveCallback(() => {
logger.metric('composer:threadgate:save', {
hasChanged: !!rest.isDirty,
persist: !!rest.persist,
replyOptions:
rest.threadgateAllowUISettings?.map(gate => gate.type)?.join(',') ?? '',
quotesEnabled: !rest.postgate?.embeddingRules?.find(
v => v.$type === embeddingRules.disableRule.$type,
),
})
})
return (
<Dialog.Outer control={control}>
<Dialog.Outer
control={control}
nativeOptions={{
preventExpansion: true,
preventDismiss: rest.isDirty && rest.persist,
}}
onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
<View style={[a.gap_md]}>
<Header />
<PostInteractionSettingsForm {...rest} />
<Text
style={[
a.pt_sm,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
You can set default interaction settings in{' '}
<Text style={[a.font_semi_bold, t.atoms.text_contrast_medium]}>
Settings &rarr; Moderation &rarr; Interaction settings
</Text>
.
</Trans>
</Text>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
<DialogInner {...rest} />
</Dialog.Outer>
)
}
export function Header() {
function DialogInner(props: Omit<PostInteractionSettingsFormProps, 'control'>) {
const {_} = useLingui()
return (
<View style={[a.gap_md, a.pb_sm]}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
<Trans>Post interaction settings</Trans>
</Text>
<Text style={[a.text_md, a.pb_xs]}>
<Trans>Customize who can interact with this post.</Trans>
</Text>
<Divider />
</View>
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[web({maxWidth: 400}), a.w_full]}>
<Header />
<PostInteractionSettingsForm {...props} />
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
@@ -134,12 +139,17 @@ export type PostInteractionSettingsDialogProps = {
initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
}
/**
* Threadgate settings dialog. Used in the thread.
*/
export function PostInteractionSettingsDialog(
props: PostInteractionSettingsDialogProps,
) {
const postThreadContext = usePostThreadContext()
return (
<Dialog.Outer control={props.control}>
<Dialog.Outer
control={props.control}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<PostThreadContextProvider context={postThreadContext}>
<PostInteractionSettingsDialogControlledInner {...props} />
@@ -153,7 +163,7 @@ export function PostInteractionSettingsDialogControlledInner(
) {
const {_} = useLingui()
const {currentAccount} = useSession()
const [isSaving, setIsSaving] = React.useState(false)
const [isSaving, setIsSaving] = useState(false)
const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
useThreadgateViewQuery({postUri: props.rootPostUri})
@@ -165,28 +175,28 @@ export function PostInteractionSettingsDialogControlledInner(
const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
const [editedPostgate, setEditedPostgate] =
React.useState<AppBskyFeedPostgate.Record>()
useState<AppBskyFeedPostgate.Record>()
const [editedAllowUISettings, setEditedAllowUISettings] =
React.useState<ThreadgateAllowUISetting[]>()
useState<ThreadgateAllowUISetting[]>()
const isLoading = isLoadingThreadgate || isLoadingPostgate
const threadgateView = threadgateViewLoaded || props.initialThreadgateView
const isThreadgateOwnedByViewer = React.useMemo(() => {
const isThreadgateOwnedByViewer = useMemo(() => {
return currentAccount?.did === new AtUri(props.rootPostUri).host
}, [props.rootPostUri, currentAccount?.did])
const postgateValue = React.useMemo(() => {
const postgateValue = useMemo(() => {
return (
editedPostgate || postgate || createPostgateRecord({post: props.postUri})
)
}, [postgate, editedPostgate, props.postUri])
const allowUIValue = React.useMemo(() => {
const allowUIValue = useMemo(() => {
return (
editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
)
}, [threadgateView, editedAllowUISettings])
const onSave = React.useCallback(async () => {
const onSave = useCallback(async () => {
if (!editedPostgate && !editedAllowUISettings) {
props.control.close()
return
@@ -248,15 +258,24 @@ export function PostInteractionSettingsDialogControlledInner(
return (
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
<View style={[a.gap_md]}>
<Header />
{isLoading ? (
<View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
) : (
style={[web({maxWidth: 400}), a.w_full]}>
{isLoading ? (
<View
style={[
a.flex_1,
a.py_5xl,
a.gap_md,
a.align_center,
a.justify_center,
]}>
<Loader size="xl" />
<Text style={[a.italic, a.text_center]}>
<Trans>Loading post interaction settings...</Trans>
</Text>
</View>
) : (
<>
<Header />
<PostInteractionSettingsForm
replySettingsDisabled={!isThreadgateOwnedByViewer}
isSaving={isSaving}
@@ -266,8 +285,9 @@ export function PostInteractionSettingsDialogControlledInner(
threadgateAllowUISettings={allowUIValue}
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
/>
)}
</View>
</>
)}
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
@@ -281,11 +301,20 @@ export function PostInteractionSettingsForm({
threadgateAllowUISettings,
onChangeThreadgateAllowUISettings,
replySettingsDisabled,
isDirty,
persist,
onChangePersist,
}: PostInteractionSettingsFormProps) {
const t = useTheme()
const {_} = useLingui()
const {data: lists} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = React.useState(
const playHaptic = useHaptics()
const [showLists, setShowLists] = useState(false)
const {
data: lists,
isPending: isListsPending,
isError: isListsError,
} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = useState(
!(
postgate.embeddingRules &&
postgate.embeddingRules.find(
@@ -294,27 +323,7 @@ export function PostInteractionSettingsForm({
),
)
const onPressAudience = (setting: ThreadgateAllowUISetting) => {
// remove boolean values
let newSelected: ThreadgateAllowUISetting[] =
threadgateAllowUISettings.filter(
v => v.type !== 'nobody' && v.type !== 'everybody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
newSelected.push(setting)
} else {
newSelected.splice(i, 1)
}
if (newSelected.length === 0) {
newSelected.push({type: 'everybody'})
}
onChangeThreadgateAllowUISettings(newSelected)
}
const onChangeQuotesEnabled = React.useCallback(
const onChangeQuotesEnabled = useCallback(
(enabled: boolean) => {
setQuotesEnabled(enabled)
onChangePostgate(
@@ -330,229 +339,346 @@ export function PostInteractionSettingsForm({
const noOneCanReply = !!threadgateAllowUISettings.find(
v => v.type === 'nobody',
)
const everyoneCanReply = !!threadgateAllowUISettings.find(
v => v.type === 'everybody',
)
const numberOfListsSelected = threadgateAllowUISettings.filter(
v => v.type === 'list',
).length
const toggleGroupValues = useMemo(() => {
const values: string[] = []
for (const setting of threadgateAllowUISettings) {
switch (setting.type) {
case 'everybody':
case 'nobody':
// no granularity, early return with nothing
return []
case 'followers':
values.push('followers')
break
case 'following':
values.push('following')
break
case 'mention':
values.push('mention')
break
case 'list':
values.push(`list:${setting.list}`)
break
default:
break
}
}
return values
}, [threadgateAllowUISettings])
const toggleGroupOnChange = (values: string[]) => {
const settings: ThreadgateAllowUISetting[] = []
if (values.length === 0) {
settings.push({type: 'everybody'})
} else {
for (const value of values) {
if (value.startsWith('list:')) {
const listId = value.slice('list:'.length)
settings.push({type: 'list', list: listId})
} else {
settings.push({type: value as 'followers' | 'following' | 'mention'})
}
}
}
onChangeThreadgateAllowUISettings(settings)
}
return (
<View>
<View style={[a.flex_1, a.gap_md]}>
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_semi_bold, a.text_lg]}>
<Trans>Quote settings</Trans>
</Text>
<Toggle.Item
name="quoteposts"
type="checkbox"
label={
quotesEnabled
? _(msg`Click to disable quote posts of this post.`)
: _(msg`Click to enable quote posts of this post.`)
}
value={quotesEnabled}
onChange={onChangeQuotesEnabled}
style={[a.justify_between, a.pt_xs]}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Allow quote posts</Trans>
</Text>
<Toggle.Switch />
</Toggle.Item>
</View>
<Divider />
{replySettingsDisabled && (
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
a.flex_row,
a.align_center,
a.gap_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo fill={t.atoms.text_contrast_low.color} />
<Text
style={[
a.flex_1,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Reply settings are chosen by the author of the thread
</Trans>
</Text>
</View>
)}
<View style={[a.flex_1, a.gap_lg]}>
<View style={[a.gap_lg]}>
{replySettingsDisabled && (
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
a.flex_row,
a.align_center,
a.gap_sm,
{
opacity: replySettingsDisabled ? 0.3 : 1,
},
t.atoms.bg_contrast_25,
]}>
<Text style={[a.font_semi_bold, a.text_lg]}>
<Trans>Reply settings</Trans>
<CircleInfo fill={t.atoms.text_contrast_low.color} />
<Text
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Reply settings are chosen by the author of the thread
</Trans>
</Text>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Allow replies from:</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={
!!threadgateAllowUISettings.find(v => v.type === 'everybody')
}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'everybody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={noOneCanReply}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
</View>
{!noOneCanReply && (
<>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'mention',
)
}
onPress={() => onPressAudience({type: 'mention'})}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Users you follow`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'following',
)
}
onPress={() => onPressAudience({type: 'following'})}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Your followers`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'followers',
)
}
onPress={() => onPressAudience({type: 'followers'})}
disabled={replySettingsDisabled}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'list' && v.list === list.uri,
)
}
onPress={() =>
onPressAudience({type: 'list', list: list.uri})
}
disabled={replySettingsDisabled}
/>
))
: // No loading states to avoid jumps for the common case (no lists)
null}
</View>
</>
)}
</View>
)}
<View style={[a.gap_sm, {opacity: replySettingsDisabled ? 0.3 : 1}]}>
<Text style={[a.text_md, a.font_medium]}>
<Trans>Who can reply</Trans>
</Text>
<Toggle.Group
label={_(msg`Set who can reply to your post`)}
type="radio"
maxSelections={1}
disabled={replySettingsDisabled}
values={
everyoneCanReply ? ['everyone'] : noOneCanReply ? ['nobody'] : []
}
onChange={val => {
if (val.includes('everyone')) {
onChangeThreadgateAllowUISettings([{type: 'everybody'}])
} else if (val.includes('nobody')) {
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
} else {
onChangeThreadgateAllowUISettings([{type: 'mention'}])
}
}}>
<View style={[a.flex_row, a.gap_sm]}>
<Toggle.Item
name="everyone"
type="checkbox"
label={_(msg`Allow anyone to reply`)}
style={[a.flex_1]}>
{({selected}) => (
<Toggle.Panel active={selected}>
<Toggle.Radio />
<Toggle.PanelText>
<Trans>Anyone</Trans>
</Toggle.PanelText>
</Toggle.Panel>
)}
</Toggle.Item>
<Toggle.Item
name="nobody"
type="checkbox"
label={_(msg`Disable replies entirely`)}
style={[a.flex_1]}>
{({selected}) => (
<Toggle.Panel active={selected}>
<Toggle.Radio />
<Toggle.PanelText>
<Trans>Nobody</Trans>
</Toggle.PanelText>
</Toggle.Panel>
)}
</Toggle.Item>
</View>
</Toggle.Group>
<Toggle.Group
label={_(
msg`Set precisely which groups of people can reply to your post`,
)}
values={toggleGroupValues}
onChange={toggleGroupOnChange}
disabled={replySettingsDisabled}>
<Toggle.PanelGroup>
<Toggle.Item
name="followers"
type="checkbox"
label={_(msg`Allow your followers to reply`)}
hitSlop={0}>
{({selected}) => (
<Toggle.Panel active={selected} adjacent="trailing">
<Toggle.Checkbox />
<Toggle.PanelText>
<Trans>Your followers</Trans>
</Toggle.PanelText>
</Toggle.Panel>
)}
</Toggle.Item>
<Toggle.Item
name="following"
type="checkbox"
label={_(msg`Allow people you follow to reply`)}
hitSlop={0}>
{({selected}) => (
<Toggle.Panel active={selected} adjacent="both">
<Toggle.Checkbox />
<Toggle.PanelText>
<Trans>People you follow</Trans>
</Toggle.PanelText>
</Toggle.Panel>
)}
</Toggle.Item>
<Toggle.Item
name="mention"
type="checkbox"
label={_(msg`Allow people you mention to reply`)}
hitSlop={0}>
{({selected}) => (
<Toggle.Panel active={selected} adjacent="both">
<Toggle.Checkbox />
<Toggle.PanelText>
<Trans>People you mention</Trans>
</Toggle.PanelText>
</Toggle.Panel>
)}
</Toggle.Item>
<Button
label={
showLists
? _(msg`Hide lists`)
: _(msg`Show lists of users to select from`)
}
accessibilityRole="togglebutton"
hitSlop={0}
onPress={() => {
playHaptic('Light')
if (isIOS && !showLists) {
LayoutAnimation.configureNext({
...LayoutAnimation.Presets.linear,
duration: 175,
})
}
setShowLists(s => !s)
}}>
<Toggle.Panel
active={numberOfListsSelected > 0}
adjacent={showLists ? 'both' : 'leading'}>
<Toggle.PanelText>
{numberOfListsSelected === 0 ? (
<Trans>Select from your lists</Trans>
) : (
<Trans>
Select from your lists{' '}
<NestedText style={[a.font_normal, a.italic]}>
<Plural
value={numberOfListsSelected}
other="(# selected)"
/>
</NestedText>
</Trans>
)}
</Toggle.PanelText>
<Toggle.PanelIcon
icon={showLists ? ChevronUpIcon : ChevronDownIcon}
/>
</Toggle.Panel>
</Button>
{showLists &&
(isListsPending ? (
<Toggle.Panel>
<Toggle.PanelText>
<Trans>Loading lists...</Trans>
</Toggle.PanelText>
</Toggle.Panel>
) : isListsError ? (
<Toggle.Panel>
<Toggle.PanelText>
<Trans>
An error occurred while loading your lists :/
</Trans>
</Toggle.PanelText>
</Toggle.Panel>
) : lists.length === 0 ? (
<Toggle.Panel>
<Toggle.PanelText>
<Trans>You don't have any lists yet.</Trans>
</Toggle.PanelText>
</Toggle.Panel>
) : (
lists.map((list, i) => (
<Toggle.Item
key={list.uri}
name={`list:${list.uri}`}
type="checkbox"
label={_(msg`Allow users in ${list.name} to reply`)}
hitSlop={0}>
{({selected}) => (
<Toggle.Panel
active={selected}
adjacent={
i === lists.length - 1 ? 'leading' : 'both'
}>
<Toggle.Checkbox />
<UserAvatar
size={24}
type="list"
avatar={list.avatar}
/>
<Toggle.PanelText>{list.name}</Toggle.PanelText>
</Toggle.Panel>
)}
</Toggle.Item>
))
))}
</Toggle.PanelGroup>
</Toggle.Group>
</View>
</View>
<Toggle.Item
name="quoteposts"
type="checkbox"
label={
quotesEnabled
? _(msg`Disable quote posts of this post`)
: _(msg`Enable quote posts of this post`)
}
value={quotesEnabled}
onChange={onChangeQuotesEnabled}>
{({selected}) => (
<Toggle.Panel active={selected}>
<Toggle.PanelText icon={QuoteIcon}>
<Trans>Allow quote posts</Trans>
</Toggle.PanelText>
<Toggle.Switch />
</Toggle.Panel>
)}
</Toggle.Item>
{typeof persist !== 'undefined' && (
<View style={[{minHeight: 24}, a.justify_center]}>
{isDirty ? (
<Toggle.Item
name="persist"
type="checkbox"
label={_(msg`Save these options for next time`)}
value={persist}
onChange={() => onChangePersist?.(!persist)}>
<Toggle.Checkbox />
<Toggle.LabelText
style={[a.text_md, a.font_normal, t.atoms.text]}>
<Trans>Save these options for next time</Trans>
</Toggle.LabelText>
</Toggle.Item>
) : (
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>These are your default settings</Trans>
</Text>
)}
</View>
)}
<Button
disabled={!canSave || isSaving}
label={_(msg`Save`)}
onPress={onSave}
color="primary"
size="large"
variant="solid"
style={a.mt_xl}>
<ButtonText>{_(msg`Save`)}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} position="right" />}
size="large">
<ButtonText>
<Trans>Save</Trans>
</ButtonText>
{isSaving && <ButtonIcon icon={Loader} />}
</Button>
</View>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
disabled,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
disabled?: boolean
}) {
const t = useTheme()
function Header() {
return (
<Button
disabled={disabled}
onPress={onPress}
label={label}
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{minHeight: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semi_bold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
<View style={[a.pb_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Post interaction settings</Trans>
</Text>
</View>
)
}
@@ -567,7 +693,7 @@ export function usePrefetchPostInteractionSettings({
const agent = useAgent()
const getPost = useGetPost()
return React.useCallback(async () => {
return useCallback(async () => {
try {
await Promise.all([
queryClient.prefetchQuery({
@@ -5,18 +5,20 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {BSKY_SERVICE} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import * as persisted from '#/state/persisted'
import {useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
import * as TextField from '#/components/forms/TextField'
import * as ToggleButton from '#/components/forms/ToggleButton'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {InlineLinkText} from '#/components/Link'
import {P, Text} from '#/components/Typography'
import {Text} from '#/components/Typography'
type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom'
export function ServerInputDialog({
control,
@@ -29,7 +31,8 @@ export function ServerInputDialog({
const formRef = useRef<DialogInnerRef>(null)
// persist these options between dialog open/close
const [fixedOption, setFixedOption] = useState(BSKY_SERVICE)
const [fixedOption, setFixedOption] =
useState<SegmentedControlOptions>(BSKY_SERVICE)
const [previousCustomAddress, setPreviousCustomAddress] = useState('')
const onClose = useCallback(() => {
@@ -40,7 +43,7 @@ export function ServerInputDialog({
setPreviousCustomAddress(result)
}
}
logEvent('signin:hostingProviderPressed', {
logger.metric('signin:hostingProviderPressed', {
hostingProviderDidChange: fixedOption !== BSKY_SERVICE,
})
}, [onSelect, fixedOption])
@@ -49,7 +52,10 @@ export function ServerInputDialog({
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{minHeight: height / 2}}>
nativeOptions={platform({
android: {minHeight: height / 2},
ios: {preventExpansion: true},
})}>
<Dialog.Handle />
<DialogInner
formRef={formRef}
@@ -70,8 +76,8 @@ function DialogInner({
initialCustomAddress,
}: {
formRef: React.Ref<DialogInnerRef>
fixedOption: string
setFixedOption: (opt: string) => void
fixedOption: SegmentedControlOptions
setFixedOption: (opt: SegmentedControlOptions) => void
initialCustomAddress: string
}) {
const control = Dialog.useDialogContext()
@@ -124,45 +130,49 @@ function DialogInner({
return (
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
accessibilityLabelledBy="dialog-title"
style={web({maxWidth: 500})}>
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_semi_bold]}>
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
<Trans>Choose your account provider</Trans>
</Text>
<ToggleButton.Group
label="Preferences"
values={[fixedOption]}
onChange={values => setFixedOption(values[0])}>
<ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}>
<ToggleButton.ButtonText>{_(msg`Bluesky`)}</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button
<SegmentedControl.Root
type="tabs"
label={_(msg`Account provider`)}
value={fixedOption}
onChange={setFixedOption}>
<SegmentedControl.Item
testID="bskyServiceSelectBtn"
value={BSKY_SERVICE}
label={_(msg`Bluesky`)}>
<SegmentedControl.ItemText>
{_(msg`Bluesky`)}
</SegmentedControl.ItemText>
</SegmentedControl.Item>
<SegmentedControl.Item
testID="customSelectBtn"
name="custom"
value="custom"
label={_(msg`Custom`)}>
<ToggleButton.ButtonText>{_(msg`Custom`)}</ToggleButton.ButtonText>
</ToggleButton.Button>
</ToggleButton.Group>
<SegmentedControl.ItemText>
{_(msg`Custom`)}
</SegmentedControl.ItemText>
</SegmentedControl.Item>
</SegmentedControl.Root>
{fixedOption === BSKY_SERVICE && isFirstTimeUser && (
<Admonition type="tip">
<Trans>
Bluesky is an open network where you can choose your own provider.
If you're new here, we recommend sticking with the default Bluesky
Social option.
</Trans>
</Admonition>
<View role="tabpanel">
<Admonition type="tip">
<Trans>
Bluesky is an open network where you can choose your own
provider. If you're new here, we recommend sticking with the
default Bluesky Social option.
</Trans>
</Admonition>
</View>
)}
{fixedOption === 'custom' && (
<View
style={[
a.border,
t.atoms.border_contrast_low,
a.rounded_sm,
a.px_md,
a.py_md,
]}>
<View role="tabpanel">
<TextField.LabelText nativeID="address-input-label">
<Trans>Server address</Trans>
</TextField.LabelText>
@@ -197,13 +207,8 @@ function DialogInner({
)}
<View style={[a.py_xs]}>
<P
style={[
t.atoms.text_contrast_medium,
a.text_sm,
a.leading_snug,
a.flex_1,
]}>
<Text
style={[t.atoms.text_contrast_medium, a.text_sm, a.leading_snug]}>
{isFirstTimeUser ? (
<Trans>
If you're a developer, you can host your own server.
@@ -219,18 +224,23 @@ function DialogInner({
to="https://atproto.com/guides/self-hosting">
<Trans>Learn more.</Trans>
</InlineLinkText>
</P>
</Text>
</View>
<View style={gtMobile && [a.flex_row, a.justify_end]}>
<Button
testID="doneBtn"
variant="outline"
variant="solid"
color="primary"
size="small"
size={platform({
native: 'large',
web: 'small',
})}
onPress={() => control.close()}
label={_(msg`Done`)}>
<ButtonText>{_(msg`Done`)}</ButtonText>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</View>
</View>
+1 -1
View File
@@ -41,7 +41,7 @@ export function SwitchAccountDialog({
}, [setShowLoggedOut, control])
return (
<Dialog.Outer control={control}>
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
<View style={[a.gap_lg]}>
+2 -2
View File
@@ -46,7 +46,7 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(
msg`Would you like to block this account or delete this conversation?`,
msg`Would you like to block this user and/or delete this conversation?`,
)}
style={[web({maxWidth: 400})]}>
<DialogInner params={params} currentScreen={currentScreen} />
@@ -177,7 +177,7 @@ function DoneStep({
</Text>
</View>
<Toggle.Group
label={_(msg`Block and/or delete this conversation`)}
label={_(msg`Block user and/or delete this conversation`)}
values={actions}
onChange={setActions}>
<View style={[a.gap_md]}>
@@ -134,12 +134,12 @@ function MenuInner({
accessibilityRole="button"
role="button"
onPress={() => setExpanded(true)}
style={[
style={flatten([
a.rounded_full,
{height: 34, width: 34},
a.justify_center,
a.align_center,
]}>
])}>
<DotGridIcon size="lg" style={t.atoms.text_contrast_medium} />
</Pressable>
</DropdownMenu.Item>
@@ -73,8 +73,8 @@ export function DateFieldButton({
a.flex_row,
a.flex_1,
a.w_full,
a.rounded_sm,
t.atoms.bg_contrast_25,
{borderRadius: 10},
t.atoms.bg_contrast_50,
a.align_center,
hovered ? chromeHover : {},
focused || pressed ? chromeFocus : {},
+1 -1
View File
@@ -4,10 +4,10 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {ServerInputDialog} from '#/view/com/auth/server-input'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {ServerInputDialog} from '#/components/dialogs/ServerInput'
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
import {Text} from '#/components/Typography'
+287
View File
@@ -0,0 +1,287 @@
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useState,
} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import Animated, {Easing, LinearTransition} from 'react-native-reanimated'
import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {
Button,
type ButtonProps,
ButtonText,
type ButtonTextProps,
} from '../Button'
const InternalContext = createContext<{
type: 'tabs' | 'radio'
size: 'small' | 'large'
selectedValue: string
selectedPosition: {width: number; x: number} | null
onSelectValue: (
value: string,
position: {width: number; x: number} | null,
) => void
updatePosition: (position: {width: number; x: number}) => void
} | null>(null)
/**
* Segmented control component.
*
* @example
* ```tsx
* <SegmentedControl.Root value={value} onChange={setValue}>
* <SegmentedControl.Item value="one">
* <SegmentedControl.ItemText value="one">
* One
* </SegmentedControl.ItemText>
* </SegmentedControl.Item>
* <SegmentedControl.Item value="two">
* <SegmentedControl.ItemText value="two">
* Two
* </SegmentedControl.ItemText>
* </SegmentedControl.Item>
* </SegmentedControl.Root>
* ```
*/
export function Root<T extends string>({
label,
type = 'radio',
size = 'large',
value,
onChange,
children,
style,
accessibilityHint,
}: {
label: string
type: 'tabs' | 'radio'
size?: 'small' | 'large'
value: T
onChange: (value: T) => void
children: React.ReactNode
style?: StyleProp<ViewStyle>
accessibilityHint?: string
}) {
const t = useTheme()
const [selectedPosition, setSelectedPosition] = useState<{
width: number
x: number
} | null>(null)
const contextValue = useMemo(() => {
return {
type,
size,
selectedValue: value,
selectedPosition,
onSelectValue: (
val: string,
position: {width: number; x: number} | null,
) => {
onChange(val as T)
if (position) setSelectedPosition(position)
},
updatePosition: (position: {width: number; x: number}) => {
setSelectedPosition(currPos => {
if (
currPos &&
currPos.width === position.width &&
currPos.x === position.x
) {
return currPos
}
return position
})
},
}
}, [value, selectedPosition, setSelectedPosition, onChange, type, size])
return (
<View
accessibilityLabel={label}
accessibilityHint={accessibilityHint ?? ''}
style={[
a.w_full,
a.flex_1,
a.relative,
a.flex_row,
t.atoms.bg_contrast_50,
{borderRadius: 14},
a.curve_continuous,
a.p_xs,
style,
]}
role={type === 'tabs' ? 'tablist' : 'radiogroup'}>
{selectedPosition !== null && (
<Slider x={selectedPosition.x} width={selectedPosition.width} />
)}
<InternalContext.Provider value={contextValue}>
{children}
</InternalContext.Provider>
</View>
)
}
const InternalItemContext = createContext<{
active: boolean
pressed: boolean
hovered: boolean
focused: boolean
} | null>(null)
export function Item({
value,
style,
children,
onPress: onPressProp,
...props
}: {value: string; children: React.ReactNode} & Omit<ButtonProps, 'children'>) {
const playHaptic = useHaptics()
const [position, setPosition] = useState<{x: number; width: number} | null>(
null,
)
const ctx = useContext(InternalContext)
if (!ctx)
throw new Error(
'SegmentedControl.Item must be used within a SegmentedControl.Root',
)
const active = ctx.selectedValue === value
// update position if change was external, and not due to onPress
const needsUpdate =
active &&
position &&
(ctx.selectedPosition?.x !== position.x ||
ctx.selectedPosition?.width !== position.width)
// can't wait for `useEffectEvent`
const update = useNonReactiveCallback(() => {
if (position) ctx.updatePosition(position)
})
useLayoutEffect(() => {
if (needsUpdate) {
update()
}
}, [needsUpdate, update])
const onPress = useCallback(
(evt: any) => {
playHaptic('Light')
ctx.onSelectValue(value, position)
onPressProp?.(evt)
},
[ctx, value, position, onPressProp, playHaptic],
)
return (
<View
style={[a.flex_1, a.flex_row]}
onLayout={evt => {
const measuredPosition = {
x: evt.nativeEvent.layout.x,
width: evt.nativeEvent.layout.width,
}
if (!ctx.selectedPosition && active) {
ctx.onSelectValue(value, measuredPosition)
}
setPosition(measuredPosition)
}}>
<Button
{...props}
onPress={onPress}
role={ctx.type === 'tabs' ? 'tab' : 'radio'}
accessibilityState={{selected: active}}
style={[
a.flex_1,
a.bg_transparent,
a.px_sm,
a.py_xs,
{minHeight: ctx.size === 'large' ? 40 : 32},
style,
]}>
{({pressed, hovered, focused}) => (
<InternalItemContext.Provider
value={{active, pressed, hovered, focused}}>
{children}
</InternalItemContext.Provider>
)}
</Button>
</View>
)
}
export function ItemText({style, ...props}: ButtonTextProps) {
const t = useTheme()
const ctx = useContext(InternalItemContext)
if (!ctx)
throw new Error(
'SegmentedControl.ItemText must be used within a SegmentedControl.Item',
)
return (
<ButtonText
{...props}
style={[
a.text_center,
a.text_md,
a.font_medium,
a.px_xs,
ctx.active
? t.atoms.text
: ctx.focused || ctx.hovered || ctx.pressed
? t.atoms.text_contrast_medium
: t.atoms.text_contrast_low,
style,
]}
/>
)
}
function Slider({x, width}: {x: number; width: number}) {
const t = useTheme()
return (
<Animated.View
layout={native(LinearTransition.easing(Easing.out(Easing.exp)))}
style={[
a.absolute,
a.curve_continuous,
t.atoms.bg,
{
top: 4,
bottom: 4,
left: 0,
width,
borderRadius: 10,
},
// TODO: new arch supports boxShadow on native
// in the meantime this is an attempt to get close
platform({
web: {
boxShadow: '0px 2px 4px 0px #0000000D',
},
ios: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0x0d / 0xff,
shadowRadius: 4,
},
android: {elevation: 0.25},
}),
platform({
native: [{left: x}],
web: [{transform: [{translateX: x}]}, a.transition_transform],
}),
]}
/>
)
}
+1 -1
View File
@@ -266,7 +266,7 @@ export function createInput(Component: typeof TextInput) {
a.z_10,
a.absolute,
a.inset_0,
a.rounded_sm,
{borderRadius: 10},
t.atoms.bg_contrast_50,
{borderColor: 'transparent', borderWidth: 2},
ctx.hovered ? chromeHover : {},
+120
View File
@@ -0,0 +1,120 @@
import {createContext, useContext} from 'react'
import {View, type ViewStyle} from 'react-native'
import {atoms as a, tokens, useTheme} from '#/alf'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography'
const PanelContext = createContext<{active: boolean}>({active: false})
/**
* A nice container for Toggles. See the Threadgate dialog for an example.
*/
export function Panel({
children,
active = false,
adjacent,
}: {
children: React.ReactNode
active?: boolean
adjacent?: 'leading' | 'trailing' | 'both'
}) {
const t = useTheme()
const leading = adjacent === 'leading' || adjacent === 'both'
const trailing = adjacent === 'trailing' || adjacent === 'both'
const rounding = {
borderTopLeftRadius: leading
? tokens.borderRadius.xs
: tokens.borderRadius.md,
borderTopRightRadius: leading
? tokens.borderRadius.xs
: tokens.borderRadius.md,
borderBottomLeftRadius: trailing
? tokens.borderRadius.xs
: tokens.borderRadius.md,
borderBottomRightRadius: trailing
? tokens.borderRadius.xs
: tokens.borderRadius.md,
} satisfies ViewStyle
return (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_sm,
a.px_md,
a.py_md,
{minHeight: tokens.space._2xl + tokens.space.md * 2},
rounding,
active
? {backgroundColor: t.palette.primary_50}
: t.atoms.bg_contrast_50,
]}>
<PanelContext value={{active}}>{children}</PanelContext>
</View>
)
}
export function PanelText({
children,
icon,
}: {
children: React.ReactNode
icon?: React.ComponentType<SVGIconProps>
}) {
const t = useTheme()
const ctx = useContext(PanelContext)
const text = (
<Text
style={[
a.text_md,
a.flex_1,
ctx.active
? [a.font_medium, t.atoms.text]
: [t.atoms.text_contrast_medium],
]}>
{children}
</Text>
)
if (icon) {
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
return (
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
<PanelIcon icon={icon} />
{text}
</View>
)
}
return text
}
export function PanelIcon({
icon: Icon,
}: {
icon: React.ComponentType<SVGIconProps>
}) {
const t = useTheme()
const ctx = useContext(PanelContext)
return (
<Icon
style={[
ctx.active ? t.atoms.text : t.atoms.text_contrast_medium,
a.flex_shrink_0,
]}
size="md"
/>
)
}
/**
* A group of panels. TODO: auto-leading/trailing
*/
export function PanelGroup({children}: {children: React.ReactNode}) {
return <View style={[a.w_full, a.gap_2xs]}>{children}</View>
}
@@ -1,12 +1,20 @@
import React from 'react'
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import {createContext, useCallback, useContext, useMemo} from 'react'
import {
Pressable,
type PressableProps,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import Animated, {Easing, LinearTransition} from 'react-native-reanimated'
import {HITSLOP_10} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {isNative} from '#/platform/detection'
import {
atoms as a,
native,
platform,
type TextStyleProp,
useTheme,
type ViewStyleProp,
@@ -15,6 +23,8 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
import {Text} from '#/components/Typography'
export * from './Panel'
export type ItemState = {
name: string
selected: boolean
@@ -25,7 +35,7 @@ export type ItemState = {
focused: boolean
}
const ItemContext = React.createContext<ItemState>({
const ItemContext = createContext<ItemState>({
name: '',
selected: false,
disabled: false,
@@ -36,7 +46,7 @@ const ItemContext = React.createContext<ItemState>({
})
ItemContext.displayName = 'ToggleItemContext'
const GroupContext = React.createContext<{
const GroupContext = createContext<{
values: string[]
disabled: boolean
type: 'radio' | 'checkbox'
@@ -70,10 +80,11 @@ export type ItemProps = ViewStyleProp & {
onChange?: (selected: boolean) => void
isInvalid?: boolean
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
hitSlop?: PressableProps['hitSlop']
}
export function useItemContext() {
return React.useContext(ItemContext)
return useContext(ItemContext)
}
export function Group({
@@ -88,9 +99,8 @@ export function Group({
}: GroupProps) {
const groupRole = type === 'radio' ? 'radiogroup' : undefined
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
const [maxReached, setMaxReached] = React.useState(false)
const setFieldValue = React.useCallback<
const setFieldValue = useCallback<
(props: {name: string; value: boolean}) => void
>(
({name, value}) => {
@@ -105,25 +115,13 @@ export function Group({
[type, onChange, values],
)
React.useEffect(() => {
if (type === 'checkbox') {
if (
maxSelections &&
values.length >= maxSelections &&
maxReached === false
) {
setMaxReached(true)
} else if (
maxSelections &&
values.length < maxSelections &&
maxReached === true
) {
setMaxReached(false)
}
}
}, [type, values.length, maxSelections, maxReached, setMaxReached])
const maxReached = !!(
type === 'checkbox' &&
maxSelections &&
values.length >= maxSelections
)
const context = React.useMemo(
const context = useMemo(
() => ({
values,
type,
@@ -170,7 +168,7 @@ export function Item({
disabled: groupDisabled,
setFieldValue,
maxSelectionsReached,
} = React.useContext(GroupContext)
} = useContext(GroupContext)
const {
state: hovered,
onIn: onHoverIn,
@@ -182,19 +180,21 @@ export function Item({
onOut: onPressOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const playHaptic = useHaptics()
const role = groupType === 'radio' ? 'radio' : type
const selected = selectedValues.includes(name) || !!value
const disabled =
groupDisabled || itemDisabled || (!selected && maxSelectionsReached)
const onPress = React.useCallback(() => {
const onPress = useCallback(() => {
playHaptic('Light')
const next = !selected
setFieldValue({name, value: next})
onChange?.(next)
}, [name, selected, onChange, setFieldValue])
}, [playHaptic, name, selected, onChange, setFieldValue])
const state = React.useMemo(
const state = useMemo(
() => ({
name,
selected,
@@ -250,8 +250,8 @@ export function LabelText({
style={[
a.font_semi_bold,
a.leading_tight,
a.user_select_none,
{
userSelect: 'none',
color: disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_high.color,
@@ -287,21 +287,26 @@ export function createSharedToggleStyles({
if (selected) {
base.push({
backgroundColor: t.palette.primary_25,
backgroundColor: t.palette.primary_500,
borderColor: t.palette.primary_500,
})
if (hovered) {
baseHover.push({
backgroundColor: t.palette.primary_100,
borderColor: t.palette.primary_600,
backgroundColor: t.palette.primary_400,
borderColor: t.palette.primary_400,
})
}
} else {
base.push({
backgroundColor: t.palette.contrast_25,
borderColor: t.palette.contrast_100,
})
if (hovered) {
baseHover.push({
backgroundColor: t.palette.contrast_50,
borderColor: t.palette.contrast_500,
borderColor: t.palette.contrast_200,
})
}
}
@@ -318,6 +323,20 @@ export function createSharedToggleStyles({
borderColor: t.palette.negative_600,
})
}
if (selected) {
base.push({
backgroundColor: t.palette.negative_500,
borderColor: t.palette.negative_500,
})
if (hovered) {
baseHover.push({
backgroundColor: t.palette.negative_400,
borderColor: t.palette.negative_400,
})
}
}
}
if (disabled) {
@@ -325,6 +344,13 @@ export function createSharedToggleStyles({
backgroundColor: t.palette.contrast_100,
borderColor: t.palette.contrast_400,
})
if (selected) {
base.push({
backgroundColor: t.palette.primary_100,
borderColor: t.palette.contrast_400,
})
}
}
return {
@@ -350,66 +376,125 @@ export function Checkbox() {
style={[
a.justify_center,
a.align_center,
a.rounded_xs,
t.atoms.border_contrast_high,
a.transition_color,
{
borderWidth: 1,
height: 24,
width: 24,
borderRadius: 6,
},
baseStyles,
hovered ? baseHoverStyles : {},
]}>
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
{selected && <Checkmark width={14} fill={t.palette.white} />}
</View>
)
}
export function Switch() {
const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
const {baseStyles, baseHoverStyles, indicatorStyles} =
createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
})
const {selected, hovered, disabled, isInvalid} = useItemContext()
const {baseStyles, baseHoverStyles, indicatorStyles} = useMemo(() => {
const base: ViewStyle[] = []
const baseHover: ViewStyle[] = []
const indicator: ViewStyle[] = []
if (selected) {
base.push({
backgroundColor: t.palette.primary_500,
})
if (hovered) {
baseHover.push({
backgroundColor: t.palette.primary_400,
})
}
} else {
base.push({
backgroundColor: t.palette.contrast_200,
})
if (hovered) {
baseHover.push({
backgroundColor: t.palette.contrast_100,
})
}
}
if (isInvalid) {
base.push({
backgroundColor: t.palette.negative_200,
})
if (hovered) {
baseHover.push({
backgroundColor: t.palette.negative_100,
})
}
if (selected) {
base.push({
backgroundColor: t.palette.negative_500,
})
if (hovered) {
baseHover.push({
backgroundColor: t.palette.negative_400,
})
}
}
}
if (disabled) {
base.push({
backgroundColor: t.palette.contrast_50,
})
if (selected) {
base.push({
backgroundColor: t.palette.primary_100,
})
}
}
return {
baseStyles: base,
baseHoverStyles: disabled ? [] : baseHover,
indicatorStyles: indicator,
}
}, [t, hovered, disabled, selected, isInvalid])
return (
<View
style={[
a.relative,
a.rounded_full,
t.atoms.bg,
t.atoms.border_contrast_high,
{
borderWidth: 1,
height: 24,
width: 36,
height: 28,
width: 48,
padding: 3,
},
a.transition_color,
baseStyles,
hovered ? baseHoverStyles : {},
]}>
<Animated.View
layout={LinearTransition.duration(100)}
layout={LinearTransition.duration(
platform({
web: 100,
default: 200,
}),
).easing(Easing.inOut(Easing.cubic))}
style={[
a.rounded_full,
{
height: 16,
width: 16,
backgroundColor: t.palette.white,
height: 22,
width: 22,
},
selected
? {
backgroundColor: t.palette.primary_500,
alignSelf: 'flex-end',
}
: {
backgroundColor: t.palette.contrast_400,
alignSelf: 'flex-start',
},
selected ? {alignSelf: 'flex-end'} : {alignSelf: 'flex-start'},
indicatorStyles,
]}
/>
@@ -420,7 +505,7 @@ export function Switch() {
export function Radio() {
const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} =
React.useContext(ItemContext)
useContext(ItemContext)
const {baseStyles, baseHoverStyles, indicatorStyles} =
createSharedToggleStyles({
theme: t,
@@ -437,29 +522,27 @@ export function Radio() {
a.align_center,
a.rounded_full,
t.atoms.border_contrast_high,
a.transition_color,
{
borderWidth: 1,
height: 24,
width: 24,
height: 25,
width: 25,
margin: -1,
},
baseStyles,
hovered ? baseHoverStyles : {},
]}>
{selected ? (
{selected && (
<View
style={[
a.absolute,
a.rounded_full,
{height: 16, width: 16},
selected
? {
backgroundColor: t.palette.primary_500,
}
: {},
{height: 12, width: 12},
{backgroundColor: t.palette.white},
indicatorStyles,
]}
/>
) : null}
)}
</View>
)
}
+12 -3
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {useMemo} from 'react'
import {
type AccessibilityProps,
type TextStyle,
@@ -20,6 +20,9 @@ export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
multiple?: boolean
}
/**
* @deprecated - use SegmentedControl
*/
export function Group({children, multiple, ...props}: GroupProps) {
const t = useTheme()
return (
@@ -39,6 +42,9 @@ export function Group({children, multiple, ...props}: GroupProps) {
)
}
/**
* @deprecated - use SegmentedControl
*/
export function Button({children, ...props}: ItemProps) {
return (
<Toggle.Item {...props} style={[a.flex_grow, a.flex_1]}>
@@ -51,7 +57,7 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const state = Toggle.useItemContext()
const {baseStyles, hoverStyles, activeStyles} = React.useMemo(() => {
const {baseStyles, hoverStyles, activeStyles} = useMemo(() => {
const base: ViewStyle[] = []
const hover: ViewStyle[] = []
const active: ViewStyle[] = []
@@ -112,11 +118,14 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
)
}
/**
* @deprecated - use SegmentedControl
*/
export function ButtonText({children}: {children: React.ReactNode}) {
const t = useTheme()
const state = Toggle.useItemContext()
const textStyles = React.useMemo(() => {
const textStyles = useMemo(() => {
const text: TextStyle[] = []
if (state.selected) {
text.push(t.atoms.text_inverted)
+8
View File
@@ -1,5 +1,13 @@
import {createSinglePathSVG} from './TEMPLATE'
export const BulletList_Stroke1_Corner0_Rounded = createSinglePathSVG({
viewBox: '0 0 47 38',
strokeLinecap: 'round',
strokeLinejoin: 'round',
strokeWidth: 2,
path: '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',
})
export const BulletList_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M6 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2ZM3 7a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm9 0a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Zm-6 9a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm9 0a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z',
})
+7
View File
@@ -19,3 +19,10 @@ export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
export const ChevronTopBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11.293 4.293a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1-1.414 1.414L12 6.414 8.707 9.707a1 1 0 0 1-1.414-1.414l4-4Zm-4 10a1 1 0 0 1 1.414 0L12 17.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414Z',
})
/**
* NOTE: Use with size `2xs`
*/
export const TinyChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M10.928 18.882c.757.499 1.786.417 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',
})
+7
View File
@@ -0,0 +1,7 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Circle_And_Square_Stroke1_Corner0_Rounded_Filled =
createSinglePathSVG({
viewBox: '0 0 62 53',
path: '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',
})
+5
View File
@@ -1,5 +1,10 @@
import {createSinglePathSVG} from './TEMPLATE'
export const EditBig_Stroke1_Corner0_Rounded = createSinglePathSVG({
viewBox: '0 0 48 48',
path: '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',
})
export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z',
})
+8
View File
@@ -1,5 +1,13 @@
import {createSinglePathSVG} from './TEMPLATE'
export const HashtagWide_Stroke1_Corner0_Rounded = createSinglePathSVG({
viewBox: '0 0 46 46',
strokeLinecap: 'round',
strokeLinejoin: 'round',
strokeWidth: 2,
path: 'M14.333 1 9 45M37 1l-5.333 44M1 11.667h44m0 22.666H1',
})
export const Hashtag_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M9.124 3.008a1 1 0 0 1 .868 1.116L9.632 7h5.985l.39-3.124a1 1 0 0 1 1.985.248L17.632 7H20a1 1 0 1 1 0 2h-2.617l-.75 6H20a1 1 0 1 1 0 2h-3.617l-.39 3.124a1 1 0 1 1-1.985-.248l.36-2.876H8.382l-.39 3.124a1 1 0 1 1-1.985-.248L6.368 17H4a1 1 0 1 1 0-2h2.617l.75-6H4a1 1 0 1 1 0-2h3.617l.39-3.124a1 1 0 0 1 1.117-.868ZM9.383 9l-.75 6h5.984l.75-6H9.383Z',
})
+7
View File
@@ -1,5 +1,12 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Heart2_Stroke1_Corner0_Rounded = createSinglePathSVG({
viewBox: '0 0 51 46',
strokeLinejoin: 'round',
strokeWidth: 2,
path: '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',
})
export const Heart2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M16.734 5.091c-1.238-.276-2.708.047-4.022 1.38a1 1 0 0 1-1.424 0C9.974 5.137 8.504 4.814 7.266 5.09c-1.263.282-2.379 1.206-2.92 2.556C3.33 10.18 4.252 14.84 12 19.348c7.747-4.508 8.67-9.168 7.654-11.7-.541-1.351-1.657-2.275-2.92-2.557Zm4.777 1.812c1.604 4-.494 9.69-9.022 14.47a1 1 0 0 1-.978 0C2.983 16.592.885 10.902 2.49 6.902c.779-1.942 2.414-3.334 4.342-3.764 1.697-.378 3.552.003 5.169 1.286 1.617-1.283 3.472-1.664 5.17-1.286 1.927.43 3.562 1.822 4.34 3.764Z',
})
+7
View File
@@ -1,5 +1,12 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Image_Stroke1_Corner0_Rounded = createSinglePathSVG({
viewBox: '0 0 46 46',
strokeLinecap: 'round',
strokeWidth: 1.5,
path: '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',
})
export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z',
})
+8
View File
@@ -1,5 +1,13 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Message_Stroke1_Corner0_Rounded_Filled = createSinglePathSVG({
viewBox: '0 0 51 51',
strokeWidth: 2,
strokeLinecap: 'square',
strokeLinejoin: 'round',
path: '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',
})
export const Message_Stroke2_Corner0_Rounded_Filled = createSinglePathSVG({
path: 'M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10a9.968 9.968 0 0 1-4.136-.893l-4.68.876a1 1 0 0 1-1.164-1.184l.931-4.537A9.965 9.965 0 0 1 2 12Zm4.25 0a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Zm4.5 0a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Zm5.75 1.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
})
+8
View File
@@ -1,5 +1,13 @@
import {createSinglePathSVG} from './TEMPLATE'
export const PeopleRemove2_Stroke1_Corner0_Rounded = createSinglePathSVG({
viewBox: '-15 0 65 64',
strokeWidth: 2,
strokeLinecap: 'round',
strokeLinejoin: 'round',
path: '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',
})
export const PeopleRemove2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z',
})
+44 -1
View File
@@ -28,7 +28,50 @@ export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
},
)
export function createSinglePathSVG({path}: {path: string}) {
export function createSinglePathSVG({
path,
viewBox,
strokeWidth = 0,
strokeLinecap = 'butt',
strokeLinejoin = 'miter',
}: {
path: string
viewBox?: string
strokeWidth?: number
strokeLinecap?: 'butt' | 'round' | 'square'
strokeLinejoin?: 'miter' | 'round' | 'bevel'
}) {
return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
const hasStroke = strokeWidth > 0
return (
<Svg
fill="none"
{...rest}
ref={ref}
viewBox={viewBox || '0 0 24 24'}
width={size}
height={size}
style={[style]}>
{gradient}
<Path
fill={hasStroke ? 'none' : fill}
stroke={hasStroke ? fill : 'none'}
strokeWidth={strokeWidth}
strokeLinecap={strokeLinecap}
strokeLinejoin={strokeLinejoin}
fillRule="evenodd"
clipRule="evenodd"
d={path}
/>
</Svg>
)
})
}
export function createSinglePathSVG2({path}: {path: string}) {
return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
+8
View File
@@ -1,5 +1,13 @@
import {createSinglePathSVG} from './TEMPLATE'
export const VideoClip_Stroke1_Corner0_Rounded = createSinglePathSVG({
viewBox: '0 0 46 46',
strokeLinecap: 'square',
strokeLinejoin: 'round',
strokeWidth: 2,
path: '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',
})
export const VideoClip_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v16a1 1 0 01-1 1H4a1 1 0 01-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2h2V13Zm0 4h-2V19h2ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z',
})
+2
View File
@@ -13,12 +13,14 @@ export type Props = {
} & Omit<SvgProps, 'style' | 'size'>
export const sizes = {
'2xs': 8,
xs: 12,
sm: 16,
md: 20,
lg: 24,
xl: 28,
'2xl': 32,
'3xl': 48,
} as const
export function useCommonSVGProps(props: Props) {
@@ -1,4 +1,4 @@
import React from 'react'
import {useEffect, useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -9,6 +9,7 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail'
import {Divider} from '#/components/Divider'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotateCounterClockwise'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
@@ -31,29 +32,22 @@ function Inner({}: {control: DialogControlProps}) {
const {gtMobile} = useBreakpoints()
const {_} = useLingui()
const {verifyEmailState: state} = useIntentDialogs()
const [status, setStatus] = React.useState<
const [status, setStatus] = useState<
'loading' | 'success' | 'failure' | 'resent'
>('loading')
const [sending, setSending] = React.useState(false)
const [sending, setSending] = useState(false)
const agent = useAgent()
const {currentAccount} = useSession()
const {mutate: confirmEmail} = useConfirmEmail({
onSuccess: () => setStatus('success'),
onError: () => setStatus('failure'),
})
React.useEffect(() => {
;(async () => {
if (!state?.code) {
return
}
try {
await agent.com.atproto.server.confirmEmail({
email: (currentAccount?.email || '').trim(),
token: state.code.trim(),
})
setStatus('success')
} catch (e) {
setStatus('failure')
}
})()
}, [agent.com.atproto.server, currentAccount?.email, state?.code])
useEffect(() => {
if (state?.code) {
confirmEmail({token: state.code})
}
}, [state?.code, confirmEmail])
const onPressResendEmail = async () => {
setSending(true)
@@ -121,11 +115,10 @@ function Inner({}: {control: DialogControlProps}) {
<Button
label={_(msg`Resend Verification Email`)}
onPress={onPressResendEmail}
variant="solid"
color="secondary_inverted"
size="large"
disabled={sending}>
<ButtonIcon icon={sending ? Loader : Resend} position="left" />
<ButtonIcon icon={sending ? Loader : Resend} />
<ButtonText>
<Trans>Resend Email</Trans>
</ButtonText>
@@ -1,6 +1,6 @@
import React, {useState} from 'react'
import {View} from 'react-native'
import {type ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api'
import {type ComAtprotoLabelDefs, ToolsOzoneReportDefs} from '@atproto/api'
import {XRPCError} from '@atproto/xrpc'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -239,7 +239,7 @@ function AppealForm({
: 'com.atproto.admin.defs#repoRef'
await agent.createModerationReport(
{
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
reasonType: ToolsOzoneReportDefs.REASONAPPEAL,
subject: {
$type,
...subject,
@@ -685,7 +685,7 @@ function CategoryCard({
}, [onSelect, option])
return (
<Button
testID={`report:option:${option.title}`}
testID={`report:category:${option.title}`}
label={_(msg`Create report for ${option.title}`)}
onPress={onPress}
disabled={!onSelect}>

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