Compare commits

...

49 Commits

Author SHA1 Message Date
Hailey fd33c92deb patch expo-modules-core 2024-06-06 11:07:16 -07:00
dan 559764156d Fix tall image lightbox on Android (#4393) 2024-06-06 15:13:53 +01:00
dan 3801932902 Fix prettier (#4392) 2024-06-06 15:00:05 +01:00
Hailey 72f46ed734 cancel animations before updating value for min-shell animation (#4386)
* cancel animations before updating value

* comment
2024-06-06 14:28:02 +01:00
Samuel Newman 85e676257e Composer - make sure android keyboard opens (#4390)
* keep trying to open keyboard until it's open

* limit number of retries

* keep the original 50ms one as well

* Proper fix!

* disable autoFocus if not visible

* Reset derived state

* Revert "Reset derived state"

This reverts commit 71f57391ae.

* Use derived state pattern

* Rename for clarity

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-06 14:21:22 +01:00
dan 48796449ea Switch to withSpring to work around Reanimated regression (#4391) 2024-06-06 14:17:26 +01:00
dan 1f954c1065 Split ShareViaChatDialog (#4383) 2024-06-06 01:10:21 +01:00
dan 5d2119ffe5 Pin resolution of Statsig Expo dependencies (#4382)
* Force resolution of expo-constants

* Pin expo-device
2024-06-06 00:40:28 +01:00
Hailey 492c271a06 add legacy to picker for Android (#4377)
* add `legacy` to picker

* add for the other callsite
2024-06-05 16:27:57 -07:00
Hailey 42477d8bbb add notification count badge mobile web (#4375)
* add notification count badge mobile web

* rename var

* add gate to types

* implement gate

* nit

* Fix condition order, narrow types

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-05 18:13:42 +01:00
Hailey aefbab3850 Remove expo-constants (#4371) 2024-06-05 09:05:25 -07:00
Hailey af2aaa1f39 add types for desktopFixedHeight to List (#4356)
* add types for `desktopFixedHeight`

* nit

* accept both `number` and `boolean`
2024-06-05 09:03:40 -07:00
Paul Frazee 22858192dc Release 1.85 (#4372)
* Update tests

* Run intl extract
2024-06-04 20:00:21 -07:00
Minseo Lee 618a3c1ebe Update Korean localization (#4318)
* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Merge remote-tracking branch 'upstream/main' into update-korean-localization
2024-06-04 19:55:05 -07:00
Kevin Scannell 5e0452fc6b Update Irish translations back to 100% (#4306) 2024-06-04 19:54:38 -07:00
Stanislas Signoud 47033e5270 Update French localizations (#4223)
* Update French localizations

* Fix typos in French and misusage of "Ignorer"

Thanks @surfdude29 for the review!

Fixes #4296.

* Bump strings and remove unused one in French

* Translate three new missing strings in French

* Bump strings and trim unused old in French again

* Translate five new missing strings in French again
2024-06-04 19:53:59 -07:00
Takayuki KUSANO bb9afa9ce8 Upated Japanese translation (#4311)
* Delete obsoleted translated messages

* Translate new messages, update some messages

* Update Japanese translations
2024-06-04 19:52:58 -07:00
Frudrax Cheng 504bd28e80 Update Chinese Localization (#4323)
* Improve zh-TW translation

* improve and fix

* Update messages.po

msgid "Nevermind, create a handle for me"

* revert L3813

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* BOTH: Fix and clean

msgid "Copy {0}"

msgid "Feeds are custom algorithms that users build with a little coding expertise. <0/> for more information."

* CN: Unify emoji

* TW: Update and unify some translation

* TW: unify "色情

* TW: update

* TW: typo

* CN:translated latest commit

* CN:removed superseded strings

* CN:fixed string 'left to go'

* CN:unified translation

* CN:translated latest commit

* CN:fix typo

---------

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>
Co-authored-by: Kuwa Lee <kuwalee1069@gmail.com>
2024-06-04 19:52:08 -07:00
Hailey afbcac3ff3 use a timeout to focus the composer input (#4370)
* use a timeout to focus the composer input

* scope to just android

* scope useEffect to just android as well

* oops

* cleanup
2024-06-05 03:44:43 +01:00
Eric Bailey deea2f3839 Clarify some things in OTA docs (#4367)
* Clarify some things

* add github outputs script

* add slack notify android

* test slack android

* fix indent

* fix test

* sigh...

* Revert "fix test"

This reverts commit c99764464f.

* Revert "fix indent"

This reverts commit 4cce508d28.

* Revert "test slack android"

This reverts commit b02419b247.

* test ios workflow

* remove testing

* add slack info to docs

* use correct output for android

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-06-04 18:55:18 -07:00
Paul Frazee 7613cdb89b Fix: visually indicate when quoted content is labeled (#4369) 2024-06-05 02:19:26 +01:00
Eric Bailey 3ece21cb45 [🙅] Integrate deactivate (#4308)
* Update types

(cherry picked from commit 27deac1f367825771ba76fa098ec1b0a62dcf64a)

* Integrate into deactivate dialog

(cherry picked from commit 84f299a447259cc1fbfc7be607e28197779e4ec1)

* Integrate into Deactivated screen

(cherry picked from commit 29193f34822ecdf11e2a407197fa230285dfe846)

* Bump api sdk

(cherry picked from commit 738c622d3e5a23bfbb0d3bdce3a6bdf01e54ca60)

* Update permalink

(cherry picked from commit c10bf5c071d76c3054bc4ce9d313c10b1820f038)

* Bump sdk pkg

* Update types to match backend

* Loosen types for forwards compat

* Hydrate status from persisted data

* Refresh session when re-activating, clear query cache

* Show app password error

* Refactor dialog to clear state when closed

* Add app password error to Deactivated screen
2024-06-05 02:02:22 +01:00
Eric Bailey e64b7cf698 Fix descenders cutoff in new chat dialog (#4359) 2024-06-04 20:51:38 +01:00
Eric Bailey a2d5343a87 Report persisted schema validation failures (#4358)
* Report persisted schema validation failures

* Make it safer
2024-06-04 20:51:28 +01:00
dan b5ac450442 Don't show profile labels until loaded (#4357) 2024-06-04 19:54:30 +01:00
Eric Bailey 551af88f22 [🙅] Remove fallback that's no longer valid (#4353)
* Remove fallback that's no longer valid

* Update test
2024-06-04 19:36:07 +01:00
Eric Bailey a49fe13223 Use recent convos for share via dialog (#4352) 2024-06-04 19:35:07 +01:00
Hailey 9f001526d3 Fix a few border nits (#4349)
* replace w/ hairline width

* no border for placeholder

* few notifications screen fixes tablet

* still show the border on desktop

* Simp

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-04 19:31:54 +01:00
surfdude29 d6b8313932 Mark accessibilityLabel and accessibilityHint for translation (#4351)
* mark `accessibilityLabel` and `accessibilityHint` for translation

* lint

* try again
2024-06-04 19:15:28 +01:00
Hailey e4b4d854d6 use rngh scrollview in search horizontal list (#4350) 2024-06-04 11:06:31 -07:00
dan c352e0f8b4 Fix forwarded ref (#4348) 2024-06-04 18:36:00 +01:00
Ryan Skinner e7968bc8d7 add profiles to search history (#4169)
* add profiles to search history

* increasing horizontal padding slightly

* tightening up styling

* fixing navigation issue

* making corrections

* Make the search history profiles a little smaller

* bug stomping

* Fix issues

* Persist taps

* Rm unnecessary

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-04 16:31:24 +01:00
dan 6f1589971c Fix missing top borders (#4346) 2024-06-04 11:07:11 +01:00
Samuel Newman 2ffb98e22a allow nested quotes in DMs (#4345) 2024-06-04 11:03:43 +01:00
Samuel Newman d918f8dc2a Composer - unbork web (#4344)
* reduce side gap + add overflow hidden

also remove the animations since they don't appear in prod, and are kinda broken

* removed fixed height to fix alt text
2024-06-04 10:58:09 +01:00
Samuel Newman d032734278 Composer - make bottom border more consistent when typing (#4343)
* floor values

* fix last line being obscured

* Rm unnecessary runOnUI

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-04 10:55:35 +01:00
Eric Bailey 3e1f076891 [🙅] Disambiguation of the deactivation (#4267)
* Disambiguation of the deactivation

* Snapshot crackle pop

* Change log context

* [🙅] Add status to session state (#4269)

* Add status to session state

* [🙅] Add new deactivated screen (#4270)

* Add new deactivated screen

* Update copy, handle logout

* Remove icons, adjust padding

* [🙅] Add deactivate account dialog (#4290)

* Deactivate dialog

(cherry picked from commit 33940e2dfe0d710c0665a7f68b198b46f54db4a2)

* Factor out dialog, add to delete modal too

(cherry picked from commit 47d70f6b74e7d2ea7330fd172499fe91ba41062d)

* Update copy, icon

(cherry picked from commit e6efabbe78c3f3d9f0f8fb0a06a6a1c4fbfb70a9)

* Update copy

(cherry picked from commit abb0ce26f6747ab0548f6f12df0dee3c64464852)

* Sizing tweaks

(cherry picked from commit fc716d5716873f0fddef56496fc48af0614b2e55)

* Add a11y label
2024-06-04 02:10:43 +01:00
Eric Bailey de93e8de74 [🐴] Post embeds polish (#4339)
* Handle message cleanup

* Handle last message in chat list

* Memoize lastMessage
2024-06-04 02:07:01 +01:00
Samuel Newman da96fb1ef5 Native formSheet for GIF select on iOS (#4328)
* native formsheet for gif select

* trigger confirm discard if have gif

* give modal a background color

* fix web top bar - unrelated but I cba to make a separate PR
2024-06-04 02:05:46 +01:00
Hailey b02445883a add an apk to production build outputs for Obtanium release support (#4317)
* add an apk to production build outputs

* test a build

* Revert "test a build"

This reverts commit f89bfeefb7.
2024-06-03 17:32:58 -07:00
Thomas Dickerson bd4703ca1e Support for Flickr album and group pool embeds (#3936)
* Support for Flickr album and group pool embeds

* Oops, forgot to add flickr to the persisted externalEmbeds schema

* Need a bigint since our id can have more than 52 bits...

* Remove unexpected trailing / from test data to match the expected behavior

* nits

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-06-03 17:29:45 -07:00
Samuel Newman 891b432ead Composer - add animated bottom border (#4325)
* start adding bottom border (wip)

* add content change listener

* add layout listener and move to hook

* remove logs

* use square-er image icon

* visually align bottom bar icons

* reduce keyboard vertical offset slightly

* only add border to top/bottom

* run worklet function on UI thread
2024-06-04 00:49:50 +01:00
dan 3b55f61d5f Avi follow experiment tweaks (#4341)
* Move avi button to visually align content

* Fix wrong prop warning

* Remove avi follow from post thread
2024-06-04 00:38:12 +01:00
Hailey 8c596b61c0 fix top border width for user list updates (#4340)
* fix nits in add/remove users from list screen

invert check

use `ViewHeader`

simplify

replace with hairline width

fix top border width for user list updates

* dont use `ViewHeader`

* update one more hairline
2024-06-03 16:34:37 -07:00
Bartosz Kaszubowski 44670c7ee2 Unify profile tabs and lists screens placeholders (#4315) 2024-06-03 16:05:26 -07:00
Hailey 8d8323421c remove resolution from post thread (#4297)
* remove resolution from post thread

nit

completely remove did cache lookup

move cache check for did to `usePostThreadQuery`

remove resolution from post thread

* helper function

* simplify

* simplify search too

* fix missing check for root or parent quoted post 🤯

* fix thread traversal
2024-06-03 23:58:16 +01:00
Yoshihiro Sugi 21d4d5f600 Fix filtering uris of fetchSubjects (#4324) 2024-06-03 23:41:03 +01:00
Hailey bda10510a4 use the new icon in reposted by (#4307)
* use the new icon in reposted by

* tweak
2024-06-03 23:35:57 +01:00
Hailey 16f295ca85 truncate if extending one line acct switcher (#4310) 2024-06-03 23:33:35 +01:00
96 changed files with 19233 additions and 16091 deletions
+34 -1
View File
@@ -34,6 +34,9 @@ jobs:
node-version-file: .nvmrc
cache: yarn
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
@@ -96,13 +99,17 @@ jobs:
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Android build is ready for submission. This is a production build! Download the artifact here: ${{ steps.upload-artifact-production.outputs.artifact-url }}"
"text": "Android production build for Google Play Store submission is ready!\n```Artifact: ${{ steps.upload-artifact-production.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
@@ -120,6 +127,32 @@ jobs:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: 🏗️ Build Production APK
if: ${{ inputs.profile == 'production' }}
run: yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
- name: 🚀 Upload Production APK Artifact
id: upload-artifact-production-apk
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 6
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 🔔 Notify Slack of Production APK Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Android production build for GitHub/Obtanium is ready!\n```Artifact: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
+19
View File
@@ -34,6 +34,9 @@ jobs:
node-version-file: .nvmrc
cache: yarn
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
@@ -81,6 +84,22 @@ jobs:
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_IOS_BUILD_NUMBER }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
+1 -1
View File
@@ -50,7 +50,7 @@ appId: xyz.blueskyweb.app
text: "1"
- tapOn:
id: "repostBtn"
- tapOn: "Undo repost"
- tapOn: "Remove repost"
- assertNotVisible:
id: "repostCount"
-31
View File
@@ -1,31 +0,0 @@
# Remove this test when the old onboarding is deprecated
appId: xyz.blueskyweb.app
---
- runScript:
file: ../setupServer.js
env:
SERVER_PATH: "?users"
- runFlow:
file: ../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn:
id: "e2eStartLongboarding"
- tapOn: "Continue to next step"
- tapOn: "Continue to the next step without following any accounts"
- tapOn: Show replies in Following feed
- tapOn: Show quote-posts in Following feed
- tapOn: Show re-posts in Following feed
- tapOn: Show replies in Following feed
- waitForAnimationToEnd
- tapOn: Continue to next step
- waitForAnimationToEnd
- tapOn: "Continue to the next step"
- waitForAnimationToEnd
- tapOn: Continue to next step
- waitForAnimationToEnd
- tapOn: Continue to next step
- waitForAnimationToEnd
- tapOn: "Complete onboarding and start using your account"
- waitForAnimationToEnd
- assertVisible: Following
+80
View File
@@ -480,6 +480,26 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://media.tenor.com/someID/someName.gif',
'https://media.tenor.com/someID',
'https://media.tenor.com',
'https://www.flickr.com/photos/username/albums/72177720308493661',
'https://flickr.com/photos/username/albums/72177720308493661',
'https://flickr.com/photos/username/albums/72177720308493661/',
'https://flickr.com/photos/username/albums/72177720308493661//',
'https://flic.kr/s/aHBqjAES3i',
'https://flickr.com/foetoes/username/albums/3903',
'https://flickr.com/albums/3903',
'https://flic.kr/s/OolI',
'https://flic.kr/t/aHBqjAES3i',
'https://www.flickr.com/groups/898944@N23/pool',
'https://flickr.com/groups/898944@N23/pool',
'https://flickr.com/groups/898944@N23/pool/',
'https://flickr.com/groups/898944@N23/pool//',
'https://flic.kr/go/8WJtR',
'https://www.flickr.com/groups/898944@N23/',
'https://www.flickr.com/groups',
]
const outputs = [
@@ -777,6 +797,66 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
undefined,
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
undefined,
undefined,
undefined,
undefined,
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.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.618V5Zm0 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" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 513 B

After

Width:  |  Height:  |  Size: 514 B

+22 -8
View File
@@ -14,15 +14,20 @@ diff results in incompatible native changes, a new client build will automatical
### Prerequisites
- Remove any internal client from your device and download the client from the App Store/Google Play. This will help for
testing as well as retrieving the build number.
- You should have signed in to EAS locally through npx eas login. You will need to modify the build number in a
subsequent step.
- Identify the build number of the production app you want to deploy an update for. iOS and Android build numbers are
divergent, so you will need to find both
- Find the latest production build number for both iOS and Android in Slack. These are listed in #client-builds
- Production builds always send the Version Number and Build Number in the Slack message. Search for the latest
production version number, and you should find the correct information.
![slack-build-info](./img/slack-build-info.png)
- It may also be useful to check the current production clients for these values. This will also help for testing. Note
that you will need to _fully_ remove the existing internal client build from your device, otherwise the given values in
the app may differ from the actual production values.
![app-build-number](./img/app-build-number.png)
- You should have signed in to EAS locally through npx eas login. You will need to modify the build number in a
subsequent step.
- Ensure that the commit the initial client was cut from is properly tagged in git. The tag should be in the format of 1.X.0
- Note: If the commit is not properly tagged, then the OTA deployment will simply fail since the GitHub Action will
not be able to find a commit to fingerprint and diff against.
@@ -38,14 +43,17 @@ to create your branch from, this should be properly set.
### Deployment
- Update the build number through EAS
- Update the build number through EAS to match the build numbers of the
production iOS/Android apps
- Note: This isnt strictly necessary, but having a step that takes you off of GitHub and into the terminal provides
a little “friction” to avoid fat fingering a release. Since there are legitimate reasons to just “click and deploy”
for internal builds, I felt it useful to make sure it doesnt accidentally become a prod deployment.
- Set the build number to the appropriate build number found in the prerequisite steps. Again, this should be the
- Set the build numbers to the values found in the prerequisite steps. Again, this should be the
build number for the current production release you want to deploy for.
- `npx eas build:version:set -p ios`
- `npx eas build:version:set -p android`
- These steps should spit out what the current build number is, save those values
for later too
- Run the deployment
- Navigate to https://github.com/bluesky-social/social-app/actions/workflows/bundle-deploy-eas-update.yml
- Select the “Run Workflow” dropdown
@@ -79,3 +87,9 @@ In about five minutes, the new deployment should be available for download. To t
- Launch the app once and wait approximately 15 seconds
- Relaunch the app
- Check the Settings page and scroll to the bottom. The commit hash should now be the latest commit on your deployed branch.
### Post Deployment
- Reset both platforms build numbers to what they were before the OTA
deployment. These values should have been logged by the EAS CLI when you
reset them to the production values prior to OTA.
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+14
View File
@@ -46,6 +46,20 @@
"EXPO_PUBLIC_ENV": "production"
}
},
"production-apk": {
"extends": "base",
"distribution": "internal",
"ios": {
"autoIncrement": false
},
"android": {
"autoIncrement": false
},
"channel": "production",
"env": {
"EXPO_PUBLIC_ENV": "production"
}
},
"testflight": {
"extends": "base",
"ios": {
+3 -2
View File
@@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "^0.12.14",
"@atproto/api": "^0.12.16",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -116,7 +116,6 @@
"expo-build-properties": "^0.12.1",
"expo-camera": "~15.0.9",
"expo-clipboard": "^6.0.3",
"expo-constants": "~16.0.1",
"expo-dev-client": "^4.0.14",
"expo-device": "~6.0.2",
"expo-file-system": "^17.0.1",
@@ -272,6 +271,8 @@
"resolutions": {
"@types/react": "^18",
"**/zeed-dom": "0.10.9",
"**/expo-constants": "16.0.1",
"**/expo-device": "6.0.2",
"@react-native/babel-preset": "0.74.1"
},
"jest": {
+14
View File
@@ -0,0 +1,14 @@
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/adapters/react/NativeModulesProxy.java b/node_modules/expo-modules-core/android/src/main/java/expo/modules/adapters/react/NativeModulesProxy.java
index bb74e80..0aa0202 100644
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/adapters/react/NativeModulesProxy.java
+++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/adapters/react/NativeModulesProxy.java
@@ -90,8 +90,8 @@ public class NativeModulesProxy extends ReactContextBaseJavaModule {
mModuleRegistry.ensureIsInitialized();
KotlinInteropModuleRegistry kotlinModuleRegistry = getKotlinInteropModuleRegistry();
- kotlinModuleRegistry.emitOnCreate();
kotlinModuleRegistry.installJSIInterop();
+ kotlinModuleRegistry.emitOnCreate();
Map<String, Object> constants = new HashMap<>(3);
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
outputIos=$(eas build:version:get -p ios)
outputAndroid=$(eas build:version:get -p android)
BSKY_IOS_BUILD_NUMBER=${outputIos#*buildNumber - }
BSKY_ANDROID_VERSION_CODE=${outputAndroid#*versionCode - }
echo PACKAGE_VERSION="$(jq -r '.version' package.json)" > "$GITHUB_OUTPUT"
echo BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER >> "$GITHUB_OUTPUT"
echo BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE >> "$GITHUB_OUTPUT"
+255
View File
@@ -0,0 +1,255 @@
import React, {
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {Modal, ScrollView, TextInput, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {
Gif,
useFeaturedGifsQuery,
useGifSearchQuery,
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {FlatList_INTERNAL} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Button, ButtonText} from '../Button'
import {Handle} from '../Dialog'
import {useThrottledValue} from '../hooks/useThrottledValue'
import {ListFooter, ListMaybePlaceholder} from '../Lists'
import {GifPreview} from './GifSelect.shared'
export function GifSelectDialog({
controlRef,
onClose,
onSelectGif: onSelectGifProp,
}: {
controlRef: React.RefObject<{open: () => void}>
onClose: () => void
onSelectGif: (gif: Gif) => void
}) {
const t = useTheme()
const [open, setOpen] = useState(false)
useImperativeHandle(controlRef, () => ({
open: () => setOpen(true),
}))
const close = useCallback(() => {
setOpen(false)
onClose()
}, [onClose])
const onSelectGif = useCallback(
(gif: Gif) => {
onSelectGifProp(gif)
close()
},
[onSelectGifProp, close],
)
const renderErrorBoundary = useCallback(
(error: any) => <ModalError details={String(error)} close={close} />,
[close],
)
return (
<Modal
visible={open}
animationType="slide"
presentationStyle="formSheet"
onRequestClose={close}
aria-modal
accessibilityViewIsModal>
<View style={[a.flex_1, t.atoms.bg]}>
<Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<GifList onSelectGif={onSelectGif} close={close} />
</ErrorBoundary>
</View>
</Modal>
)
}
function GifList({
onSelectGif,
}: {
close: () => void
onSelectGif: (gif: Gif) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const textInputRef = useRef<TextInput>(null)
const listRef = useRef<FlatList_INTERNAL>(null)
const [undeferredSearch, setSearch] = useState('')
const search = useThrottledValue(undeferredSearch, 500)
const isSearching = search.length > 0
const trendingQuery = useFeaturedGifsQuery()
const searchQuery = useGifSearchQuery(search)
const {
data,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
error,
isLoading,
isError,
refetch,
} = isSearching ? searchQuery : trendingQuery
const flattenedData = useMemo(() => {
return data?.pages.flatMap(page => page.results) || []
}, [data])
const renderItem = useCallback(
({item}: {item: Gif}) => {
return <GifPreview gif={item} onSelectGif={onSelectGif} />
},
[onSelectGif],
)
const onEndReached = React.useCallback(() => {
if (isFetchingNextPage || !hasNextPage || error) return
fetchNextPage()
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
const hasData = flattenedData.length > 0
const onGoBack = useCallback(() => {
if (isSearching) {
// clear the input and reset the state
textInputRef.current?.clear()
setSearch('')
} else {
close()
}
}, [isSearching])
const listHeader = useMemo(() => {
return (
<View style={[a.relative, a.mb_lg, a.pt_4xl, a.flex_row, a.align_center]}>
{/* cover top corners */}
<View
style={[
a.absolute,
a.inset_0,
{
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
},
t.atoms.bg,
]}
/>
<TextField.Root>
<TextField.Icon icon={Search} />
<TextField.Input
label={_(msg`Search GIFs`)}
placeholder={_(msg`Search Tenor`)}
onChangeText={text => {
setSearch(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
returnKeyType="search"
clearButtonMode="while-editing"
inputRef={textInputRef}
maxLength={50}
/>
</TextField.Root>
</View>
)
}, [t.atoms.bg, _])
return (
<FlatList_INTERNAL
ref={listRef}
key={gtMobile ? '3 cols' : '2 cols'}
data={flattenedData}
renderItem={renderItem}
numColumns={gtMobile ? 3 : 2}
columnWrapperStyle={a.gap_sm}
contentContainerStyle={a.px_lg}
ListHeaderComponent={
<>
{listHeader}
{!hasData && (
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
onRetry={refetch}
onGoBack={onGoBack}
emptyType="results"
sideBorders={false}
topBorder={false}
errorTitle={_(msg`Failed to load GIFs`)}
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
emptyMessage={
isSearching
? _(msg`No search results found for "${search}".`)
: _(
msg`No featured GIFs found. There may be an issue with Tenor.`,
)
}
/>
)}
</>
}
stickyHeaderIndices={[0]}
onEndReached={onEndReached}
onEndReachedThreshold={4}
keyExtractor={(item: Gif) => item.id}
keyboardDismissMode="on-drag"
ListFooterComponent={
hasData ? (
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
style={{borderTopWidth: 0}}
/>
) : null
}
/>
)
}
function ModalError({details, close}: {details?: string; close: () => void}) {
const {_} = useLingui()
return (
<ScrollView
style={[a.flex_1, a.gap_md]}
centerContent
contentContainerStyle={a.px_lg}>
<ErrorScreen
title={_(msg`Oh no!`)}
message={_(
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
)}
details={details}
/>
<Button
label={_(msg`Close dialog`)}
onPress={close}
color="primary"
size="medium"
variant="solid">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</ScrollView>
)
}
@@ -0,0 +1,53 @@
import React, {useCallback} from 'react'
import {Image} from 'expo-image'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {Gif} from '#/state/queries/tenor'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button} from '../Button'
export function GifPreview({
gif,
onSelectGif,
}: {
gif: Gif
onSelectGif: (gif: Gif) => void
}) {
const {gtTablet} = useBreakpoints()
const {_} = useLingui()
const t = useTheme()
const onPress = useCallback(() => {
logEvent('composer:gif:select', {})
onSelectGif(gif)
}, [onSelectGif, gif])
return (
<Button
label={_(msg`Select GIF "${gif.title}"`)}
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
onPress={onPress}>
{({pressed}) => (
<Image
style={[
a.flex_1,
a.mb_sm,
a.rounded_sm,
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
t.atoms.bg_contrast_25,
]}
source={{
uri: gif.media_formats.tinygif.url,
}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
+16 -49
View File
@@ -1,11 +1,15 @@
import React, {useCallback, useMemo, useRef, useState} from 'react'
import React, {
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {TextInput, View} from 'react-native'
import {Image} from 'expo-image'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection'
import {
@@ -23,16 +27,23 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {ListFooter, ListMaybePlaceholder} from '../Lists'
import {GifPreview} from './GifSelect.shared'
export function GifSelectDialog({
control,
controlRef,
onClose,
onSelectGif: onSelectGifProp,
}: {
control: Dialog.DialogControlProps
controlRef: React.RefObject<{open: () => void}>
onClose: () => void
onSelectGif: (gif: Gif) => void
}) {
const control = Dialog.useDialogControl()
useImperativeHandle(controlRef, () => ({
open: () => control.open(),
}))
const onSelectGif = useCallback(
(gif: Gif) => {
control.close(() => onSelectGifProp(gif))
@@ -233,50 +244,6 @@ function GifList({
)
}
function GifPreview({
gif,
onSelectGif,
}: {
gif: Gif
onSelectGif: (gif: Gif) => void
}) {
const {gtTablet} = useBreakpoints()
const {_} = useLingui()
const t = useTheme()
const onPress = useCallback(() => {
logEvent('composer:gif:select', {})
onSelectGif(gif)
}, [onSelectGif, gif])
return (
<Button
label={_(msg`Select GIF "${gif.title}"`)}
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
onPress={onPress}>
{({pressed}) => (
<Image
style={[
a.flex_1,
a.mb_sm,
a.rounded_sm,
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
t.atoms.bg_contrast_25,
]}
source={{
uri: gif.media_formats.tinygif.url,
}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
function DialogError({details}: {details?: string}) {
const {_} = useLingui()
const control = Dialog.useDialogContext()
+1 -1
View File
@@ -14,7 +14,7 @@ let MessageItemEmbed = ({
return (
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds embed={embed} />
<PostEmbeds embed={embed} allowNestedQuotes />
</View>
)
}
@@ -16,6 +16,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useListConvosQuery} from '#/state/queries/messages/list-converations'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
@@ -55,9 +56,11 @@ type Item =
export function SearchablePeopleList({
title,
onSelectChat,
showRecentConvos,
}: {
title: string
onSelectChat: (did: string) => void
showRecentConvos?: boolean
}) {
const t = useTheme()
const {_} = useLingui()
@@ -75,6 +78,7 @@ export function SearchablePeopleList({
isFetching,
} = useActorAutocompleteQuery(searchText, true, 12)
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
const {data: convos} = useListConvosQuery({enabled: showRecentConvos})
const items = useMemo(() => {
let _items: Item[] = []
@@ -103,7 +107,65 @@ export function SearchablePeopleList({
})
}
} else {
if (follows) {
const placeholders: Item[] = Array(10)
.fill(0)
.map((_, i) => ({
type: 'placeholder',
key: i + '',
}))
if (showRecentConvos) {
if (convos && follows) {
const usedDids = new Set()
for (const page of convos.pages) {
for (const convo of page.convos) {
const profiles = convo.members.filter(
m => m.did !== currentAccount?.did,
)
for (const profile of profiles) {
if (usedDids.has(profile.did)) continue
usedDids.add(profile.did)
_items.push({
type: 'profile',
key: profile.did,
enabled: true,
profile,
})
}
}
}
let followsItems: typeof _items = []
for (const page of follows.pages) {
for (const profile of page.follows) {
if (usedDids.has(profile.did)) continue
followsItems.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
}
// only sort follows
followsItems = followsItems.sort(a => {
// @ts-ignore
return a.enabled ? -1 : 1
})
// then append
_items.push(...followsItems)
} else {
_items.push(...placeholders)
}
} else if (follows) {
for (const page of follows.pages) {
for (const profile of page.follows) {
_items.push({
@@ -120,19 +182,21 @@ export function SearchablePeopleList({
return a.enabled ? -1 : 1
})
} else {
Array(10)
.fill(0)
.forEach((_, i) => {
_items.push({
type: 'placeholder',
key: i + '',
})
})
_items.push(...placeholders)
}
}
return _items
}, [_, searchText, results, isError, currentAccount?.did, follows])
}, [
_,
searchText,
results,
isError,
currentAccount?.did,
follows,
convos,
showRecentConvos,
])
if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
@@ -331,11 +395,13 @@ function ProfileCard({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
style={[t.atoms.text, a.font_bold, a.leading_snug]}
style={[t.atoms.text, a.font_bold, a.leading_tight]}
numberOfLines={1}>
{displayName}
</Text>
<Text style={t.atoms.text_contrast_high} numberOfLines={2}>
<Text
style={[a.leading_tight, t.atoms.text_contrast_high]}
numberOfLines={2}>
{!enabled ? <Trans>{handle} can't be messaged</Trans> : handle}
</Text>
</View>
@@ -15,8 +15,24 @@ export function SendViaChatDialog({
control: Dialog.DialogControlProps
onSelectChat: (chatId: string) => void
}) {
const {_} = useLingui()
return (
<Dialog.Outer
control={control}
testID="sendViaChatChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
</Dialog.Outer>
)
}
function SendViaChatDialogInner({
control,
onSelectChat,
}: {
control: Dialog.DialogControlProps
onSelectChat: (chatId: string) => void
}) {
const {_} = useLingui()
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
onSelectChat(data.convo.id)
@@ -39,14 +55,10 @@ export function SendViaChatDialog({
)
return (
<Dialog.Outer
control={control}
testID="sendViaChatChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SearchablePeopleList
title={_(msg`Send post to...`)}
onSelectChat={onCreateChat}
/>
</Dialog.Outer>
<SearchablePeopleList
title={_(msg`Send post to...`)}
onSelectChat={onCreateChat}
showRecentConvos
/>
)
}
+1 -1
View File
@@ -1,5 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.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.618V5Zm0 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',
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',
})
+3 -1
View File
@@ -3,8 +3,9 @@ import {
launchImageLibraryAsync,
MediaTypeOptions,
} from 'expo-image-picker'
import {getDataUriSize} from './util'
import * as Toast from 'view/com/util/Toast'
import {getDataUriSize} from './util'
export async function openPicker(opts?: ImagePickerOptions) {
const response = await launchImageLibraryAsync({
@@ -12,6 +13,7 @@ export async function openPicker(opts?: ImagePickerOptions) {
mediaTypes: MediaTypeOptions.Images,
quality: 1,
...opts,
legacy: true,
})
if (response.assets && response.assets.length > 4) {
+1 -1
View File
@@ -13,7 +13,7 @@ export type LogEvents = {
withPassword: boolean
}
'account:loggedOut': {
logContext: 'SwitchAccount' | 'Settings' | 'Deactivated'
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
}
'notifications:openApp': {}
'notifications:request': {
+1
View File
@@ -3,3 +3,4 @@ export type Gate =
| 'request_notifications_permission_after_onboarding_v2'
| 'show_avi_follow_button'
| 'show_follow_back_label_v2'
| 'show_notification_badge_mobile_web'
+76
View File
@@ -23,6 +23,7 @@ export const embedPlayerSources = [
'vimeo',
'giphy',
'tenor',
'flickr',
] as const
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
@@ -42,6 +43,7 @@ export type EmbedPlayerType =
| 'vimeo_video'
| 'giphy_gif'
| 'tenor_gif'
| 'flickr_album'
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
youtube: 'YouTube',
@@ -53,6 +55,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
spotify: 'Spotify',
appleMusic: 'Apple Music',
soundcloud: 'SoundCloud',
flickr: 'Flickr',
}
export interface EmbedPlayerParams {
@@ -375,6 +378,79 @@ export function parseEmbedPlayerFromUrl(
}
}
}
// this is a standard flickr path! we can use the embedder for albums and groups, so validate the path
if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') {
let i = urlp.pathname.length - 1
while (i > 0 && urlp.pathname.charAt(i) === '/') {
--i
}
const path_components = urlp.pathname.slice(1, i + 1).split('/')
if (path_components.length === 4) {
// discard username - it's not relevant
const [photos, _, albums, id] = path_components
if (photos === 'photos' && albums === 'albums') {
// this at least has the shape of a valid photo-album URL!
return {
type: 'flickr_album',
source: 'flickr',
playerUri: `https://embedr.flickr.com/photosets/${id}`,
}
}
}
if (path_components.length === 3) {
const [groups, id, pool] = path_components
if (groups === 'groups' && pool === 'pool') {
return {
type: 'flickr_album',
source: 'flickr',
playerUri: `https://embedr.flickr.com/groups/${id}`,
}
}
}
// not an album or a group pool, don't know what to do with this!
return undefined
}
// link shortened flickr path
if (urlp.hostname === 'flic.kr') {
const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
let [_, type, idBase58Enc] = urlp.pathname.split('/')
let id = 0n
for (const char of idBase58Enc) {
const nextIdx = b58alph.indexOf(char)
if (nextIdx >= 0) {
id = id * 58n + BigInt(nextIdx)
} else {
// not b58 encoded, ergo not a valid link to embed
return undefined
}
}
switch (type) {
case 'go':
const formattedGroupId = `${id}`
return {
type: 'flickr_album',
source: 'flickr',
playerUri: `https://embedr.flickr.com/groups/${formattedGroupId.slice(
0,
-2,
)}@N${formattedGroupId.slice(-2)}`,
}
case 's':
return {
type: 'flickr_album',
source: 'flickr',
playerUri: `https://embedr.flickr.com/photosets/${id}`,
}
default:
// we don't know what this is so we can't embed it
return undefined
}
}
}
export function getPlayerAspect({
+21
View File
@@ -3,6 +3,7 @@ import psl from 'psl'
import TLDs from 'tlds'
import {BSKY_SERVICE} from 'lib/constants'
import {isInvalidHandle} from 'lib/strings/handles'
export const BSKY_APP_HOST = 'https://bsky.app'
const BSKY_TRUSTED_HOSTS = [
@@ -83,6 +84,10 @@ export function toShareUrl(url: string): string {
return url
}
export function toBskyAppUrl(url: string): string {
return new URL(url, BSKY_APP_HOST).toString()
}
export function isBskyAppUrl(url: string): boolean {
return url.startsWith('https://bsky.app/')
}
@@ -183,6 +188,22 @@ export function feedUriToHref(url: string): string {
}
}
export function postUriToRelativePath(
uri: string,
options?: {handle?: string},
): string | undefined {
try {
const {hostname, rkey} = new AtUri(uri)
const handleOrDid =
options?.handle && !isInvalidHandle(options.handle)
? options.handle
: hostname
return `/profile/${handleOrDid}/post/${rkey}`
} catch {
return undefined
}
}
/**
* Checks if the label in the post text matches the host of the link facet.
*
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+191 -179
View File
@@ -1,19 +1,31 @@
import React from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {isSessionDeactivated, useAgent, useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {
type SessionAccount,
useAgent,
useSession,
useSessionApi,
} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {ScrollView} from '#/view/com/util/Views'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {AccountList} from '#/components/AccountList'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
import {Text} from '#/components/Typography'
const COL_WIDTH = 400
@@ -21,199 +33,199 @@ export function Deactivated() {
const {_} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch()
const {currentAccount, accounts} = useSession()
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const hasOtherAccounts = accounts.length > 1
const setMinimalShellMode = useSetMinimalShellMode()
const {logout} = useSessionApi()
const agent = useAgent()
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | undefined>()
const queryClient = useQueryClient()
const [isProcessing, setProcessing] = React.useState(false)
const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>(
undefined,
)
const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>(
undefined,
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(true)
}, [setMinimalShellMode]),
)
const checkStatus = React.useCallback(async () => {
setProcessing(true)
try {
const res = await agent.com.atproto.temp.checkSignupQueue()
if (res.data.activated) {
// ready to go, exchange the access token for a usable one and kick off onboarding
await agent.refreshSession()
if (!isSessionDeactivated(agent.session?.accessJwt)) {
onboardingDispatch({type: 'start'})
}
} else {
// not ready, update UI
setEstimatedTime(msToString(res.data.estimatedTimeMs))
if (typeof res.data.placeInQueue !== 'undefined') {
setPlaceInQueue(Math.max(res.data.placeInQueue, 1))
}
const onSelectAccount = React.useCallback(
(account: SessionAccount) => {
if (account.did !== currentAccount?.did) {
onPressSwitchAccount(account, 'SwitchAccount')
}
} catch (e: any) {
logger.error('Failed to check signup queue', {err: e.toString()})
} finally {
setProcessing(false)
}
}, [
setProcessing,
setEstimatedTime,
setPlaceInQueue,
onboardingDispatch,
agent,
])
React.useEffect(() => {
checkStatus()
const interval = setInterval(checkStatus, 60e3)
return () => clearInterval(interval)
}, [checkStatus])
const checkBtn = (
<Button
variant="solid"
color="primary"
size="large"
label={_(msg`Check my status`)}
onPress={checkStatus}
disabled={isProcessing}>
<ButtonText>
<Trans>Check my status</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
},
[currentAccount, onPressSwitchAccount],
)
const onPressAddAccount = React.useCallback(() => {
setShowLoggedOut(true)
}, [setShowLoggedOut])
const onPressLogout = React.useCallback(() => {
if (isWeb) {
// We're switching accounts, which remounts the entire app.
// On mobile, this gets us Home, but on the web we also need reset the URL.
// We can't change the URL via a navigate() call because the navigator
// itself is about to unmount, and it calls pushState() too late.
// So we change the URL ourselves. The navigator will pick it up on remount.
history.pushState(null, '', '/')
}
logout('Deactivated')
}, [logout])
const handleActivate = React.useCallback(async () => {
try {
setPending(true)
await agent.com.atproto.server.activateAccount()
await queryClient.resetQueries()
await agent.resumeSession(agent.session!)
} catch (e: any) {
switch (e.message) {
case 'Bad token scope':
setError(
_(
msg`You're logged in with an App Password. Please log in with your main password to continue deactivating your account.`,
),
)
break
default:
setError(_(msg`Something went wrong, please try again`))
break
}
logger.error(e, {
context: 'Failed to activate account',
})
} finally {
setPending(false)
}
}, [_, agent, setPending, setError, queryClient])
return (
<View
aria-modal
role="dialog"
aria-role="dialog"
aria-label={_(msg`You're in line`)}
accessibilityLabel={_(msg`You're in line`)}
accessibilityHint=""
style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
<View style={[a.h_full_vh, a.flex_1, t.atoms.bg]}>
<ScrollView
style={[a.h_full, a.w_full]}
contentContainerStyle={{borderWidth: 0}}>
<View
style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}>
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
<View
style={[a.w_full, a.justify_center, a.align_center, a.my_4xl]}>
<Logo width={120} />
</View>
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
<Trans>You're in line</Trans>
</Text>
<P style={[t.atoms.text_contrast_medium]}>
<Trans>
There's been a rush of new users to Bluesky! We'll activate your
account as soon as we can.
</Trans>
</P>
<View
style={[
a.rounded_sm,
a.px_2xl,
a.py_4xl,
a.mt_2xl,
t.atoms.bg_contrast_50,
]}>
{typeof placeInQueue === 'number' && (
<Text
style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}>
{placeInQueue}
</Text>
)}
<P style={[a.text_center]}>
{typeof placeInQueue === 'number' ? (
<Trans>left to go.</Trans>
) : (
<Trans>You are in line.</Trans>
)}{' '}
{estimatedTime ? (
<Trans>
We estimate {estimatedTime} until your account is ready.
</Trans>
) : (
<Trans>
We will let you know when your account is ready.
</Trans>
)}
</P>
</View>
{isWeb && gtMobile && (
<View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}>
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('Deactivated')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
{checkBtn}
</View>
)}
</View>
<View style={{height: 200}} />
</View>
</ScrollView>
{(!isWeb || !gtMobile) && (
<View
style={[
a.align_center,
gtMobile ? a.px_5xl : a.px_xl,
a.px_2xl,
{
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
paddingTop: isWeb ? 64 : insets.top,
paddingBottom: isWeb ? 64 : insets.bottom,
},
]}>
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
{checkBtn}
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('Deactivated')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
<View style={[a.flex_row, a.justify_center]}>
<View style={[a.w_full, {maxWidth: COL_WIDTH}]}>
<View
style={[a.w_full, a.justify_center, a.align_center, a.pb_5xl]}>
<Logo width={40} />
</View>
<View style={[a.gap_xs, a.pb_3xl]}>
<Text style={[a.text_xl, a.font_bold, a.leading_snug]}>
<Trans>Welcome back!</Trans>
</Text>
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
You previously deactivated @{currentAccount?.handle}.
</Trans>
</Text>
<Text style={[a.text_sm, a.leading_snug, a.pb_md]}>
<Trans>
You can reactivate your account to continue logging in. Your
profile and posts will be visible to other users.
</Trans>
</Text>
<View style={[a.gap_sm]}>
<Button
label={_(msg`Reactivate your account`)}
size="medium"
variant="solid"
color="primary"
onPress={handleActivate}>
<ButtonText>
<Trans>Yes, reactivate my account</Trans>
</ButtonText>
{pending && <ButtonIcon icon={Loader} position="right" />}
</Button>
<Button
label={_(msg`Cancel reactivation and log out`)}
size="medium"
variant="solid"
color="secondary"
onPress={onPressLogout}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
{error && (
<View
style={[
a.flex_row,
a.gap_sm,
a.mt_md,
a.p_md,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo size="md" fill={t.palette.negative_400} />
<Text style={[a.flex_1, a.leading_snug]}>{error}</Text>
</View>
)}
</View>
<View style={[a.pb_3xl]}>
<Divider />
</View>
{hasOtherAccounts ? (
<>
<Text
style={[
t.atoms.text_contrast_medium,
a.pb_md,
a.leading_snug,
]}>
<Trans>Or, log into one of your other accounts.</Trans>
</Text>
<AccountList
onSelectAccount={onSelectAccount}
onSelectOther={onPressAddAccount}
otherLabel={_(msg`Add account`)}
pendingDid={pendingDid}
/>
</>
) : (
<>
<Text
style={[
t.atoms.text_contrast_medium,
a.pb_md,
a.leading_snug,
]}>
<Trans>Or, continue with another account.</Trans>
</Text>
<Button
label={_(msg`Log in or sign up`)}
size="medium"
variant="solid"
color="secondary"
onPress={() => setShowLoggedOut(true)}>
<ButtonText>
<Trans>Log in or sign up</Trans>
</ButtonText>
</Button>
</>
)}
</View>
</View>
</View>
)}
</ScrollView>
</View>
)
}
function msToString(ms: number | undefined): string | undefined {
if (ms && ms > 0) {
const estimatedTimeMins = Math.ceil(ms / 60e3)
if (estimatedTimeMins > 59) {
const estimatedTimeHrs = Math.round(estimatedTimeMins / 60)
if (estimatedTimeHrs > 6) {
// dont even bother
return undefined
}
// hours
return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, {
one: 'hour',
other: 'hours',
})}`
}
// minutes
return `${estimatedTimeMins} ${plural(estimatedTimeMins, {
one: 'minute',
other: 'minutes',
})}`
}
return undefined
}
@@ -312,25 +312,19 @@ export function MessagesList({
})
if (postLinkFacet) {
// remove the post link from the text
rt.delete(
postLinkFacet.index.byteStart,
postLinkFacet.index.byteEnd,
)
const isAtStart = postLinkFacet.index.byteStart === 0
const isAtEnd =
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
// re-trim the text, now that we've removed the post link
//
// if the post link is at the start of the text, we don't want to leave a leading space
// so trim on both sides
if (postLinkFacet.index.byteStart === 0) {
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
} else {
// otherwise just trim the end
rt = new RichText(
{text: rt.text.trimEnd()},
{cleanNewlines: true},
// remove the post link from the text
if (isAtStart || isAtEnd) {
rt.delete(
postLinkFacet.index.byteStart,
postLinkFacet.index.byteEnd,
)
}
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
}
}
} catch (error) {
+63 -14
View File
@@ -2,6 +2,7 @@ import React, {useCallback, useState} from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {
AppBskyActorDefs,
AppBskyEmbedRecord,
ChatBskyConvoDefs,
moderateProfile,
ModerationOpts,
@@ -9,6 +10,11 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
postUriToRelativePath,
toBskyAppUrl,
toShortUrl,
} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -95,21 +101,64 @@ function ChatListItemReady({
const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
let lastMessage = _(msg`No messages yet`)
let lastMessageSentAt: string | null = null
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
if (convo.lastMessage.sender?.did === currentAccount?.did) {
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
} else {
lastMessage = convo.lastMessage.text
const {lastMessage, lastMessageSentAt} = React.useMemo(() => {
let lastMessage = _(msg`No messages yet`)
let lastMessageSentAt: string | null = null
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did
if (convo.lastMessage.text) {
if (isFromMe) {
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
} else {
lastMessage = convo.lastMessage.text
}
} else if (convo.lastMessage.embed) {
const defaultEmbeddedContentMessage = _(
msg`(contains embedded content)`,
)
if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) {
const embed = convo.lastMessage.embed
if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
const record = embed.record
const path = postUriToRelativePath(record.uri, {
handle: record.author.handle,
})
const href = path ? toBskyAppUrl(path) : undefined
const short = href
? toShortUrl(href)
: defaultEmbeddedContentMessage
if (isFromMe) {
lastMessage = _(msg`You: ${short}`)
} else {
lastMessage = short
}
}
} else {
if (isFromMe) {
lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`)
} else {
lastMessage = defaultEmbeddedContentMessage
}
}
}
lastMessageSentAt = convo.lastMessage.sentAt
}
lastMessageSentAt = convo.lastMessage.sentAt
}
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
lastMessage = isDeletedAccount
? _(msg`Conversation deleted`)
: _(msg`Message deleted`)
}
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
lastMessage = isDeletedAccount
? _(msg`Conversation deleted`)
: _(msg`Message deleted`)
}
return {
lastMessage,
lastMessageSentAt,
}
}, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount])
const [showActions, setShowActions] = useState(false)
@@ -102,6 +102,7 @@ export function StepProfile() {
mediaTypes: MediaTypeOptions.Images,
quality: 1,
...opts,
legacy: true,
})
return (response.assets ?? [])
+11 -9
View File
@@ -81,15 +81,17 @@ let ProfileHeaderShell = ({
{children}
<View
style={[a.px_lg, a.pb_sm]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
{isMe ? (
<LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
) : (
<ProfileHeaderAlerts moderation={moderation} />
)}
</View>
{!isPlaceholderProfile && (
<View
style={[a.px_lg, a.pb_sm]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
{isMe ? (
<LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
) : (
<ProfileHeaderAlerts moderation={moderation} />
)}
</View>
)}
{!isDesktop && !hideBackButton && (
<TouchableWithoutFeedback
@@ -0,0 +1,130 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useAgent, useSessionApi} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DialogOuterProps} from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
export function DeactivateAccountDialog({
control,
}: {
control: DialogOuterProps['control']
}) {
return (
<Prompt.Outer control={control}>
<DeactivateAccountDialogInner control={control} />
</Prompt.Outer>
)
}
function DeactivateAccountDialogInner({
control,
}: {
control: DialogOuterProps['control']
}) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {_} = useLingui()
const agent = useAgent()
const {logout} = useSessionApi()
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | undefined>()
const handleDeactivate = React.useCallback(async () => {
try {
setPending(true)
await agent.com.atproto.server.deactivateAccount({})
control.close(() => {
logout('Deactivated')
})
} catch (e: any) {
switch (e.message) {
case 'Bad token scope':
setError(
_(
msg`You're logged in with an App Password. Please log in with your main password to continue deactivating your account.`,
),
)
break
default:
setError(_(msg`Something went wrong, please try again`))
break
}
logger.error(e, {
context: 'Failed to deactivate account',
})
} finally {
setPending(false)
}
}, [agent, control, logout, _, setPending])
return (
<>
<Prompt.TitleText>{_(msg`Deactivate account`)}</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
Your profile, posts, feeds, and lists will no longer be visible to
other Bluesky users. You can reactivate your account at any time by
logging in.
</Trans>
</Prompt.DescriptionText>
<View style={[a.pb_xl]}>
<Divider />
<View style={[a.gap_sm, a.pt_lg, a.pb_xl]}>
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>
There is no time limit for account deactivation, come back any
time.
</Trans>
</Text>
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>
If you're trying to change your handle or email, do so before you
deactivate.
</Trans>
</Text>
</View>
<Divider />
</View>
<Prompt.Actions>
<Button
variant="solid"
color="negative"
size={gtMobile ? 'small' : 'medium'}
label={_(msg`Yes, deactivate`)}
onPress={handleDeactivate}>
<ButtonText>{_(msg`Yes, deactivate`)}</ButtonText>
{pending && <ButtonIcon icon={Loader} position="right" />}
</Button>
<Prompt.Cancel />
</Prompt.Actions>
{error && (
<View
style={[
a.flex_row,
a.gap_sm,
a.mt_md,
a.p_md,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo size="md" fill={t.palette.negative_400} />
<Text style={[a.flex_1, a.leading_snug]}>{error}</Text>
</View>
)}
</>
)
}
+219
View File
@@ -0,0 +1,219 @@
import React from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {isSignupQueued, useAgent, useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {ScrollView} from '#/view/com/util/Views'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
const COL_WIDTH = 400
export function SignupQueued() {
const {_} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch()
const {logout} = useSessionApi()
const agent = useAgent()
const [isProcessing, setProcessing] = React.useState(false)
const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>(
undefined,
)
const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>(
undefined,
)
const checkStatus = React.useCallback(async () => {
setProcessing(true)
try {
const res = await agent.com.atproto.temp.checkSignupQueue()
if (res.data.activated) {
// ready to go, exchange the access token for a usable one and kick off onboarding
await agent.refreshSession()
if (!isSignupQueued(agent.session?.accessJwt)) {
onboardingDispatch({type: 'start'})
}
} else {
// not ready, update UI
setEstimatedTime(msToString(res.data.estimatedTimeMs))
if (typeof res.data.placeInQueue !== 'undefined') {
setPlaceInQueue(Math.max(res.data.placeInQueue, 1))
}
}
} catch (e: any) {
logger.error('Failed to check signup queue', {err: e.toString()})
} finally {
setProcessing(false)
}
}, [
setProcessing,
setEstimatedTime,
setPlaceInQueue,
onboardingDispatch,
agent,
])
React.useEffect(() => {
checkStatus()
const interval = setInterval(checkStatus, 60e3)
return () => clearInterval(interval)
}, [checkStatus])
const checkBtn = (
<Button
variant="solid"
color="primary"
size="large"
label={_(msg`Check my status`)}
onPress={checkStatus}
disabled={isProcessing}>
<ButtonText>
<Trans>Check my status</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
)
return (
<View
aria-modal
role="dialog"
aria-role="dialog"
aria-label={_(msg`You're in line`)}
accessibilityLabel={_(msg`You're in line`)}
accessibilityHint=""
style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
<ScrollView
style={[a.h_full, a.w_full]}
contentContainerStyle={{borderWidth: 0}}>
<View
style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}>
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
<View
style={[a.w_full, a.justify_center, a.align_center, a.my_4xl]}>
<Logo width={120} />
</View>
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
<Trans>You're in line</Trans>
</Text>
<P style={[t.atoms.text_contrast_medium]}>
<Trans>
There's been a rush of new users to Bluesky! We'll activate your
account as soon as we can.
</Trans>
</P>
<View
style={[
a.rounded_sm,
a.px_2xl,
a.py_4xl,
a.mt_2xl,
t.atoms.bg_contrast_50,
]}>
{typeof placeInQueue === 'number' && (
<Text
style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}>
{placeInQueue}
</Text>
)}
<P style={[a.text_center]}>
{typeof placeInQueue === 'number' ? (
<Trans>left to go.</Trans>
) : (
<Trans>You are in line.</Trans>
)}{' '}
{estimatedTime ? (
<Trans>
We estimate {estimatedTime} until your account is ready.
</Trans>
) : (
<Trans>
We will let you know when your account is ready.
</Trans>
)}
</P>
</View>
{isWeb && gtMobile && (
<View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}>
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
{checkBtn}
</View>
)}
</View>
<View style={{height: 200}} />
</View>
</ScrollView>
{(!isWeb || !gtMobile) && (
<View
style={[
a.align_center,
gtMobile ? a.px_5xl : a.px_xl,
{
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
},
]}>
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
{checkBtn}
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
</View>
</View>
)}
</View>
)
}
function msToString(ms: number | undefined): string | undefined {
if (ms && ms > 0) {
const estimatedTimeMins = Math.ceil(ms / 60e3)
if (estimatedTimeMins > 59) {
const estimatedTimeHrs = Math.round(estimatedTimeMins / 60)
if (estimatedTimeHrs > 6) {
// dont even bother
return undefined
}
// hours
return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, {
one: 'hour',
other: 'hours',
})}`
}
// minutes
return `${estimatedTimeMins} ${plural(estimatedTimeMins, {
one: 'minute',
other: 'minutes',
})}`
}
return undefined
}
+8 -1
View File
@@ -17,7 +17,13 @@ const accountSchema = z.object({
emailAuthFactor: z.boolean().optional(),
refreshJwt: z.string().optional(), // optional because it can expire
accessJwt: z.string().optional(), // optional because it can expire
deactivated: z.boolean().optional(),
signupQueued: z.boolean().optional(),
active: z.boolean().optional(), // optional for backwards compat
/**
* Known values: takendown, suspended, deactivated
* @see https://github.com/bluesky-social/atproto/blob/5441fbde9ed3b22463e91481ec80cb095643e141/lexicons/com/atproto/server/getSession.json
*/
status: z.string().optional(),
pdsUrl: z.string().optional(),
})
export type PersistedAccount = z.infer<typeof accountSchema>
@@ -65,6 +71,7 @@ export const schema = z.object({
spotify: z.enum(externalEmbedOptions).optional(),
appleMusic: z.enum(externalEmbedOptions).optional(),
soundcloud: z.enum(externalEmbedOptions).optional(),
flickr: z.enum(externalEmbedOptions).optional(),
})
.optional(),
mutedThreads: z.array(z.string()), // should move to server
+13 -2
View File
@@ -1,7 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {Schema, schema} from '#/state/persisted/schema'
import {logger} from '#/logger'
import {Schema, schema} from '#/state/persisted/schema'
const BSKY_STORAGE = 'BSKY_STORAGE'
@@ -13,8 +13,19 @@ export async function write(value: Schema) {
export async function read(): Promise<Schema | undefined> {
const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
const objData = rawData ? JSON.parse(rawData) : undefined
if (schema.safeParse(objData).success) {
const parsed = schema.safeParse(objData)
if (parsed.success) {
return objData
} else {
const errors =
parsed.error?.errors?.map(e => ({
code: e.code,
// @ts-ignore exists on some types
expected: e?.expected,
path: e.path,
})) || []
logger.error(`persisted store: data failed validation on read`, {errors})
return undefined
}
}
@@ -26,10 +26,15 @@ import {useAgent, useSession} from '#/state/session'
export const RQKEY = ['convo-list']
type RQPageParam = string | undefined
export function useListConvosQuery() {
export function useListConvosQuery({
enabled,
}: {
enabled?: boolean
} = {}) {
const agent = useAgent()
return useInfiniteQuery({
enabled,
queryKey: RQKEY,
queryFn: async ({pageParam}) => {
const {data} = await agent.api.chat.bsky.convo.listConvos(
+13 -5
View File
@@ -17,7 +17,7 @@
*/
import {useEffect, useRef} from 'react'
import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {
InfiniteData,
QueryClient,
@@ -30,7 +30,11 @@ import {useMutedThreads} from '#/state/muted-threads'
import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
import {embedViewRecordToPostView, getEmbeddedPost} from '../util'
import {
didOrHandleUriMatches,
embedViewRecordToPostView,
getEmbeddedPost,
} from '../util'
import {FeedPage} from './types'
import {useUnreadNotificationsApi} from './unread'
import {fetchPage} from './util'
@@ -142,6 +146,8 @@ export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<AppBskyFeedDefs.PostView, void> {
const atUri = new AtUri(uri)
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
queryKey: [RQKEY_ROOT],
})
@@ -149,14 +155,16 @@ export function* findAllPostsInQueryData(
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const item of page.items) {
if (item.subject?.uri === uri) {
if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
yield item.subject
}
const quotedPost = getEmbeddedPost(item.subject?.embed)
if (quotedPost?.uri === uri) {
yield embedViewRecordToPostView(quotedPost)
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
yield embedViewRecordToPostView(quotedPost!)
}
}
}
+1 -1
View File
@@ -145,7 +145,7 @@ async function fetchSubjects(
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>()
for (const notif of groupedNotifs) {
if (notif.subjectUri && !notif.subjectUri.includes('feed.generator')) {
if (notif.subjectUri?.includes('app.bsky.feed.post')) {
uris.add(notif.subjectUri)
}
}
+33 -13
View File
@@ -35,7 +35,11 @@ import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners'
import {useModerationOpts} from '../preferences/moderation-opts'
import {usePreferencesQuery} from './preferences'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
import {
didOrHandleUriMatches,
embedViewRecordToPostView,
getEmbeddedPost,
} from './util'
type ActorDid = string
type AuthorFilter =
@@ -448,6 +452,8 @@ export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<AppBskyFeedDefs.PostView, undefined> {
const atUri = new AtUri(uri)
const queryDatas = queryClient.getQueriesData<
InfiniteData<FeedPageUnselected>
>({
@@ -459,24 +465,38 @@ export function* findAllPostsInQueryData(
}
for (const page of queryData?.pages) {
for (const item of page.feed) {
if (item.post.uri === uri) {
if (didOrHandleUriMatches(atUri, item.post)) {
yield item.post
}
const quotedPost = getEmbeddedPost(item.post.embed)
if (quotedPost?.uri === uri) {
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
yield embedViewRecordToPostView(quotedPost)
}
if (
AppBskyFeedDefs.isPostView(item.reply?.parent) &&
item.reply?.parent?.uri === uri
) {
yield item.reply.parent
if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
if (didOrHandleUriMatches(atUri, item.reply.parent)) {
yield item.reply.parent
}
const parentQuotedPost = getEmbeddedPost(item.reply.parent.embed)
if (
parentQuotedPost &&
didOrHandleUriMatches(atUri, parentQuotedPost)
) {
yield embedViewRecordToPostView(parentQuotedPost)
}
}
if (
AppBskyFeedDefs.isPostView(item.reply?.root) &&
item.reply?.root?.uri === uri
) {
yield item.reply.root
if (AppBskyFeedDefs.isPostView(item.reply?.root)) {
if (didOrHandleUriMatches(atUri, item.reply.root)) {
yield item.reply.root
}
const rootQuotedPost = getEmbeddedPost(item.reply.root.embed)
if (rootQuotedPost && didOrHandleUriMatches(atUri, rootQuotedPost)) {
yield embedViewRecordToPostView(rootQuotedPost)
}
}
}
}
+14 -11
View File
@@ -4,6 +4,7 @@ import {
AppBskyFeedDefs,
AppBskyFeedGetPostThread,
AppBskyFeedPost,
AtUri,
ModerationDecision,
ModerationOpts,
} from '@atproto/api'
@@ -24,7 +25,11 @@ import {
findAllPostsInQueryData as findAllPostsInFeedQueryData,
findAllProfilesInQueryData as findAllProfilesInFeedQueryData,
} from './post-feed'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
import {
didOrHandleUriMatches,
embedViewRecordToPostView,
getEmbeddedPost,
} from './util'
const RQKEY_ROOT = 'post-thread'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
@@ -91,14 +96,10 @@ export function usePostThreadQuery(uri: string | undefined) {
},
enabled: !!uri,
placeholderData: () => {
if (!uri) {
return undefined
}
{
const post = findPostInQueryData(queryClient, uri)
if (post) {
return post
}
if (!uri) return
const post = findPostInQueryData(queryClient, uri)
if (post) {
return post
}
return undefined
},
@@ -271,6 +272,8 @@ export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<ThreadNode, void> {
const atUri = new AtUri(uri)
const queryDatas = queryClient.getQueriesData<ThreadNode>({
queryKey: [RQKEY_ROOT],
})
@@ -279,7 +282,7 @@ export function* findAllPostsInQueryData(
continue
}
for (const item of traverseThread(queryData)) {
if (item.uri === uri) {
if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) {
const placeholder = threadNodeToPlaceholderThread(item)
if (placeholder) {
yield placeholder
@@ -287,7 +290,7 @@ export function* findAllPostsInQueryData(
}
const quotedPost =
item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
if (quotedPost?.uri === uri) {
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
yield embedViewRecordToPlaceholderThread(quotedPost)
}
}
+11 -3
View File
@@ -2,6 +2,7 @@ import {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedSearchPosts,
AtUri,
} from '@atproto/api'
import {
InfiniteData,
@@ -11,7 +12,11 @@ import {
} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
import {
didOrHandleUriMatches,
embedViewRecordToPostView,
getEmbeddedPost,
} from './util'
const searchPostsQueryKeyRoot = 'search-posts'
const searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [
@@ -62,17 +67,20 @@ export function* findAllPostsInQueryData(
>({
queryKey: [searchPostsQueryKeyRoot],
})
const atUri = new AtUri(uri)
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const post of page.posts) {
if (post.uri === uri) {
if (didOrHandleUriMatches(atUri, post)) {
yield post
}
const quotedPost = getEmbeddedPost(post.embed)
if (quotedPost?.uri === uri) {
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
yield embedViewRecordToPostView(quotedPost)
}
}
+19
View File
@@ -1,8 +1,10 @@
import {
AppBskyActorDefs,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
} from '@atproto/api'
import {InfiniteData, QueryClient, QueryKey} from '@tanstack/react-query'
@@ -22,6 +24,23 @@ export function truncateAndInvalidate<T = any>(
queryClient.invalidateQueries({queryKey})
}
// Given an AtUri, this function will check if the AtUri matches a
// hit regardless of whether the AtUri uses a DID or handle as a host.
//
// AtUri should be the URI that is being searched for, while currentUri
// is the URI that is being checked. currentAuthor is the author
// of the currentUri that is being checked.
export function didOrHandleUriMatches(
atUri: AtUri,
record: {uri: string; author: AppBskyActorDefs.ProfileViewBasic},
) {
if (atUri.host.startsWith('did:')) {
return atUri.href === record.uri
}
return atUri.host === record.author.handle && record.uri.endsWith(atUri.rkey)
}
export function getEmbeddedPost(
v: unknown,
): AppBskyEmbedRecord.ViewRecord | undefined {
+119 -29
View File
@@ -28,6 +28,7 @@ describe('session', () => {
const agent = new BskyAgent({service: 'https://alice.com'})
agent.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -50,7 +51,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-1",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -59,6 +60,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-1",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -87,7 +90,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": undefined,
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -96,6 +99,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -114,6 +119,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -136,7 +142,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-1",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -145,6 +151,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-1",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -159,6 +167,7 @@ describe('session', () => {
const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
accessJwt: 'bob-access-jwt-1',
@@ -183,7 +192,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "bob-access-jwt-1",
"deactivated": false,
"active": true,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -192,10 +201,12 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-1",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "alice-access-jwt-1",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -204,6 +215,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-1",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -218,6 +231,7 @@ describe('session', () => {
const agent3 = new BskyAgent({service: 'https://alice.com'})
agent3.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
accessJwt: 'alice-access-jwt-2',
@@ -242,7 +256,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -251,10 +265,12 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "bob-access-jwt-1",
"deactivated": false,
"active": true,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -263,6 +279,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-1",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -277,6 +295,7 @@ describe('session', () => {
const agent4 = new BskyAgent({service: 'https://jay.com'})
agent4.session = {
active: true,
did: 'jay-did',
handle: 'jay.test',
accessJwt: 'jay-access-jwt-1',
@@ -299,7 +318,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "jay-access-jwt-1",
"deactivated": false,
"active": true,
"did": "jay-did",
"email": undefined,
"emailAuthFactor": false,
@@ -308,10 +327,12 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "jay-refresh-jwt-1",
"service": "https://jay.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -320,10 +341,12 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "bob-access-jwt-1",
"deactivated": false,
"active": true,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -332,6 +355,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-1",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -364,7 +389,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": undefined,
"deactivated": false,
"active": true,
"did": "jay-did",
"email": undefined,
"emailAuthFactor": false,
@@ -373,10 +398,12 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://jay.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": undefined,
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -385,10 +412,12 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": undefined,
"deactivated": false,
"active": true,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -397,6 +426,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -415,6 +446,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -446,7 +478,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": undefined,
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -455,6 +487,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -469,6 +503,7 @@ describe('session', () => {
const agent2 = new BskyAgent({service: 'https://alice.com'})
agent2.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-2',
@@ -490,7 +525,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -499,6 +534,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -517,6 +554,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -561,6 +599,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -568,6 +607,7 @@ describe('session', () => {
}
const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
accessJwt: 'bob-access-jwt-1',
@@ -601,7 +641,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "bob-access-jwt-1",
"deactivated": false,
"active": true,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -610,6 +650,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-1",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -637,6 +679,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -653,6 +696,7 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe('alice-did')
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
accessJwt: 'alice-access-jwt-2',
@@ -681,7 +725,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": "alice@foo.bar",
"emailAuthFactor": false,
@@ -690,6 +734,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -703,6 +749,7 @@ describe('session', () => {
`)
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
accessJwt: 'alice-access-jwt-3',
@@ -731,7 +778,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-3",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": "alice@foo.baz",
"emailAuthFactor": true,
@@ -740,6 +787,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-3",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -753,6 +802,7 @@ describe('session', () => {
`)
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
accessJwt: 'alice-access-jwt-4',
@@ -781,7 +831,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-4",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": "alice@foo.baz",
"emailAuthFactor": false,
@@ -790,6 +840,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-4",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -808,6 +860,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -824,6 +877,7 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe('alice-did')
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
accessJwt: 'alice-access-jwt-2',
@@ -854,6 +908,7 @@ describe('session', () => {
expect(lastState === state).toBe(true)
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
accessJwt: 'alice-access-jwt-3',
@@ -877,6 +932,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -885,6 +941,7 @@ describe('session', () => {
const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
accessJwt: 'bob-access-jwt-1',
@@ -909,6 +966,7 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe('bob-did')
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
accessJwt: 'alice-access-jwt-2',
@@ -937,7 +995,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "bob-access-jwt-1",
"deactivated": false,
"active": true,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -946,10 +1004,12 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-1",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": "alice@foo.bar",
"emailAuthFactor": false,
@@ -958,6 +1018,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -971,6 +1033,7 @@ describe('session', () => {
`)
agent2.session = {
active: true,
did: 'bob-did',
handle: 'bob-updated.test',
accessJwt: 'bob-access-jwt-2',
@@ -997,7 +1060,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "bob-access-jwt-2",
"deactivated": false,
"active": true,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1006,10 +1069,12 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-2",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": "alice@foo.bar",
"emailAuthFactor": false,
@@ -1018,6 +1083,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -1060,6 +1127,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -1068,6 +1136,7 @@ describe('session', () => {
const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
accessJwt: 'bob-access-jwt-1',
@@ -1094,6 +1163,7 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe('bob-did')
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-2',
@@ -1119,6 +1189,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -1156,7 +1227,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-1",
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1165,6 +1236,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-1",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -1183,6 +1256,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -1218,7 +1292,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": undefined,
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1227,6 +1301,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -1245,6 +1321,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -1280,7 +1357,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": undefined,
"deactivated": false,
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1289,6 +1366,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -1307,6 +1386,7 @@ describe('session', () => {
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
@@ -1314,6 +1394,7 @@ describe('session', () => {
}
const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
accessJwt: 'bob-access-jwt-1',
@@ -1336,6 +1417,7 @@ describe('session', () => {
const anotherTabAgent1 = new BskyAgent({service: 'https://jay.com'})
anotherTabAgent1.session = {
active: true,
did: 'jay-did',
handle: 'jay.test',
accessJwt: 'jay-access-jwt-1',
@@ -1343,6 +1425,7 @@ describe('session', () => {
}
const anotherTabAgent2 = new BskyAgent({service: 'https://alice.com'})
anotherTabAgent2.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
accessJwt: 'bob-access-jwt-2',
@@ -1371,7 +1454,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "jay-access-jwt-1",
"deactivated": false,
"active": true,
"did": "jay-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1380,10 +1463,12 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "jay-refresh-jwt-1",
"service": "https://jay.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "bob-access-jwt-2",
"deactivated": false,
"active": true,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1392,6 +1477,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -1406,6 +1493,7 @@ describe('session', () => {
const anotherTabAgent3 = new BskyAgent({service: 'https://clarence.com'})
anotherTabAgent3.session = {
active: true,
did: 'clarence-did',
handle: 'clarence.test',
accessJwt: 'clarence-access-jwt-2',
@@ -1429,7 +1517,7 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "clarence-access-jwt-2",
"deactivated": false,
"active": true,
"did": "clarence-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1438,6 +1526,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "clarence-refresh-jwt-2",
"service": "https://clarence.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
+11 -4
View File
@@ -16,7 +16,7 @@ import {
configureModerationForGuest,
} from './moderation'
import {SessionAccount} from './types'
import {isSessionDeactivated, isSessionExpired} from './util'
import {isSessionExpired, isSignupQueued} from './util'
export function createPublicAgent() {
configureModerationForGuest() // Side effect but only relevant for tests
@@ -46,12 +46,17 @@ export async function createAgentAndResume(
emailConfirmed: storedAccount.emailConfirmed,
handle: storedAccount.handle,
refreshJwt: storedAccount.refreshJwt ?? '',
/**
* @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188
*/
active: storedAccount.active ?? true,
status: storedAccount.status,
}
if (isSessionExpired(storedAccount)) {
await networkRetry(1, () => agent.resumeSession(prevSession))
} else {
agent.session = prevSession
if (!storedAccount.deactivated) {
if (!storedAccount.signupQueued) {
// Intentionally not awaited to unblock the UI:
networkRetry(3, () => agent.resumeSession(prevSession)).catch(
(e: any) => {
@@ -135,7 +140,7 @@ export async function createAgentAndCreateAccount(
const account = agentToSessionAccountOrThrow(agent)
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
const moderation = configureModerationForAccount(agent, account)
if (!account.deactivated) {
if (!account.signupQueued) {
/*dont await*/ agent.upsertProfile(_existing => {
return {
displayName: '',
@@ -234,7 +239,9 @@ export function agentToSessionAccount(
emailAuthFactor: agent.session.emailAuthFactor || false,
refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt,
deactivated: isSessionDeactivated(agent.session.accessJwt),
signupQueued: isSignupQueued(agent.session.accessJwt),
active: agent.session.active,
status: agent.session.status as SessionAccount['status'],
pdsUrl: agent.pdsUrl?.toString(),
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ import {
} from './agent'
import {getInitialState, reducer} from './reducer'
export {isSessionDeactivated} from './util'
export {isSignupQueued} from './util'
export type {SessionAccount} from '#/state/session/types'
import {SessionApiContext, SessionStateContext} from '#/state/session/types'
+3 -2
View File
@@ -10,11 +10,12 @@ export function readLastActiveAccount() {
return accounts.find(a => a.did === currentAccount?.did)
}
export function isSessionDeactivated(accessJwt: string | undefined) {
export function isSignupQueued(accessJwt: string | undefined) {
if (accessJwt) {
const sessData = jwtDecode(accessJwt)
return (
hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated'
hasProp(sessData, 'scope') &&
sessData.scope === 'com.atproto.signupQueued'
)
}
return false
+6 -5
View File
@@ -1,9 +1,9 @@
import React from 'react'
import {
Easing,
cancelAnimation,
SharedValue,
useSharedValue,
withTiming,
withSpring,
} from 'react-native-reanimated'
type StateContext = SharedValue<number>
@@ -22,9 +22,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const setMode = React.useCallback(
(v: boolean) => {
'worklet'
mode.value = withTiming(v ? 1 : 0, {
duration: 400,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
// Cancel any existing animation
cancelAnimation(mode)
mode.value = withSpring(v ? 1 : 0, {
overshootClamping: true,
})
},
[mode],
+134 -40
View File
@@ -9,6 +9,7 @@ import React, {
import {
ActivityIndicator,
Keyboard,
LayoutChangeEvent,
StyleSheet,
TouchableOpacity,
View,
@@ -106,7 +107,9 @@ export const ComposePost = observer(function ComposePost({
text: initText,
imageUris: initImageUris,
cancelRef,
isModalReady,
}: Props & {
isModalReady: boolean
cancelRef?: React.RefObject<CancelRef>
}) {
const {currentAccount} = useSession()
@@ -116,7 +119,7 @@ export const ComposePost = observer(function ComposePost({
const {closeComposer} = useComposerControls()
const {track} = useAnalytics()
const pal = usePalette('default')
const {isTabletOrDesktop, isMobile} = useWebMediaQueries()
const {isMobile} = useWebMediaQueries()
const {_} = useLingui()
const requireAltTextEnabled = useRequireAltTextEnabled()
const langPrefs = useLanguagePrefs()
@@ -153,6 +156,7 @@ export const ComposePost = observer(function ComposePost({
const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
const gallery = useMemo(
() => new GalleryModel(initImageUris),
[initImageUris],
@@ -170,33 +174,16 @@ export const ComposePost = observer(function ComposePost({
[insets, isKeyboardVisible],
)
const hasScrolled = useSharedValue(0)
const scrollHandler = useAnimatedScrollHandler({
onScroll: event => {
hasScrolled.value = withTiming(event.contentOffset.y > 0 ? 1 : 0)
},
})
const topBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderColor: interpolateColor(
hasScrolled.value,
[0, 1],
['transparent', t.atoms.border_contrast_medium.borderColor],
),
}
})
const onPressCancel = useCallback(() => {
if (graphemeLength > 0 || !gallery.isEmpty) {
if (graphemeLength > 0 || !gallery.isEmpty || extGif) {
closeAllDialogs()
if (Keyboard) {
Keyboard.dismiss()
}
Keyboard.dismiss()
discardPromptControl.open()
} else {
onClose()
}
}, [
extGif,
graphemeLength,
gallery.isEmpty,
closeAllDialogs,
@@ -395,23 +382,26 @@ export const ComposePost = observer(function ComposePost({
[setExtLink],
)
const {
scrollHandler,
onScrollViewContentSizeChange,
onScrollViewLayout,
topBarAnimatedStyle,
bottomBarAnimatedStyle,
} = useAnimatedBorders()
return (
<>
<KeyboardAvoidingView
testID="composePostView"
behavior="padding"
style={a.flex_1}
keyboardVerticalOffset={replyTo ? 120 : isAndroid ? 180 : 150}>
keyboardVerticalOffset={replyTo ? 115 : isAndroid ? 180 : 162}>
<View
style={[a.flex_1, viewStyles]}
aria-modal
accessibilityViewIsModal>
<Animated.View
style={[
styles.topbar,
topBarAnimatedStyle,
isWeb && isTabletOrDesktop && styles.topbarDesktop,
]}>
<Animated.View style={topBarAnimatedStyle}>
<View style={styles.topbarInner}>
<TouchableOpacity
testID="composerDiscardButton"
@@ -509,7 +499,9 @@ export const ComposePost = observer(function ComposePost({
<Animated.ScrollView
onScroll={scrollHandler}
style={styles.scrollView}
keyboardShouldPersistTaps="always">
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
<View
@@ -526,7 +518,11 @@ export const ComposePost = observer(function ComposePost({
ref={textInput}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
autoFocus={true}
// fixes autofocus on android
key={
isAndroid ? (isModalReady ? 'ready' : 'animating') : 'static'
}
autoFocus={isAndroid ? isModalReady : true}
setRichText={setRichText}
onPhotoPasted={onPhotoPasted}
onPressPublish={onPressPublish}
@@ -575,7 +571,11 @@ export const ComposePost = observer(function ComposePost({
<KeyboardStickyView
offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}>
{replyTo ? null : (
<ThreadgateBtn threadgate={threadgate} onChange={setThreadgate} />
<ThreadgateBtn
threadgate={threadgate}
onChange={setThreadgate}
style={bottomBarAnimatedStyle}
/>
)}
<View
style={[
@@ -625,15 +625,108 @@ export function useComposerCancelRef() {
return useRef<CancelRef>(null)
}
function useAnimatedBorders() {
const t = useTheme()
const hasScrolledTop = useSharedValue(0)
const hasScrolledBottom = useSharedValue(0)
const contentOffset = useSharedValue(0)
const scrollViewHeight = useSharedValue(Infinity)
const contentHeight = useSharedValue(0)
/**
* Make sure to run this on the UI thread!
*/
const showHideBottomBorder = useCallback(
({
newContentHeight,
newContentOffset,
newScrollViewHeight,
}: {
newContentHeight?: number
newContentOffset?: number
newScrollViewHeight?: number
}) => {
'worklet'
if (typeof newContentHeight === 'number')
contentHeight.value = Math.floor(newContentHeight)
if (typeof newContentOffset === 'number')
contentOffset.value = Math.floor(newContentOffset)
if (typeof newScrollViewHeight === 'number')
scrollViewHeight.value = Math.floor(newScrollViewHeight)
hasScrolledBottom.value = withTiming(
contentHeight.value - contentOffset.value - 5 > scrollViewHeight.value
? 1
: 0,
)
},
[contentHeight, contentOffset, scrollViewHeight, hasScrolledBottom],
)
const scrollHandler = useAnimatedScrollHandler({
onScroll: event => {
'worklet'
hasScrolledTop.value = withTiming(event.contentOffset.y > 0 ? 1 : 0)
showHideBottomBorder({
newContentOffset: event.contentOffset.y,
newContentHeight: event.contentSize.height,
newScrollViewHeight: event.layoutMeasurement.height,
})
},
})
const onScrollViewContentSizeChange = useCallback(
(_width: number, height: number) => {
'worklet'
showHideBottomBorder({
newContentHeight: height,
})
},
[showHideBottomBorder],
)
const onScrollViewLayout = useCallback(
(evt: LayoutChangeEvent) => {
'worklet'
showHideBottomBorder({
newScrollViewHeight: evt.nativeEvent.layout.height,
})
},
[showHideBottomBorder],
)
const topBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderBottomWidth: hairlineWidth,
borderColor: interpolateColor(
hasScrolledTop.value,
[0, 1],
['transparent', t.atoms.border_contrast_medium.borderColor],
),
}
})
const bottomBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderTopWidth: hairlineWidth,
borderColor: interpolateColor(
hasScrolledBottom.value,
[0, 1],
['transparent', t.atoms.border_contrast_medium.borderColor],
),
}
})
return {
scrollHandler,
onScrollViewContentSizeChange,
onScrollViewLayout,
topBarAnimatedStyle,
bottomBarAnimatedStyle,
}
}
const styles = StyleSheet.create({
topbar: {
borderBottomWidth: StyleSheet.hairlineWidth,
},
topbarDesktop: {
paddingTop: 10,
paddingBottom: 10,
height: 50,
},
topbarInner: {
flexDirection: 'row',
alignItems: 'center',
@@ -698,7 +791,8 @@ const styles = StyleSheet.create({
bottomBar: {
flexDirection: 'row',
paddingVertical: 4,
paddingLeft: 8,
// should be 8 but due to visual alignment we have to fudge it
paddingLeft: 7,
paddingRight: 16,
alignItems: 'center',
borderTopWidth: hairlineWidth,
@@ -1,4 +1,4 @@
import React, {useCallback} from 'react'
import React, {useCallback, useRef} from 'react'
import {Keyboard} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -7,7 +7,6 @@ import {logEvent} from '#/lib/statsig/statsig'
import {Gif} from '#/state/queries/tenor'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {GifSelectDialog} from '#/components/dialogs/GifSelect'
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
@@ -19,14 +18,14 @@ type Props = {
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
const {_} = useLingui()
const control = useDialogControl()
const ref = useRef<{open: () => void}>(null)
const t = useTheme()
const onPressSelectGif = useCallback(async () => {
logEvent('composer:gif:open', {})
Keyboard.dismiss()
control.open()
}, [control])
ref.current?.open()
}, [])
return (
<>
@@ -44,7 +43,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
</Button>
<GifSelectDialog
control={control}
controlRef={ref}
onClose={onClose}
onSelectGif={onSelectGif}
/>
@@ -1,5 +1,6 @@
import React from 'react'
import {Keyboard, View} from 'react-native'
import {Keyboard, StyleProp, ViewStyle} from 'react-native'
import Animated, {AnimatedStyle} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -16,9 +17,11 @@ import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
export function ThreadgateBtn({
threadgate,
onChange,
style,
}: {
threadgate: ThreadgateSetting[]
onChange: (v: ThreadgateSetting[]) => void
style?: StyleProp<AnimatedStyle<ViewStyle>>
}) {
const {track} = useAnalytics()
const {_} = useLingui()
@@ -46,7 +49,7 @@ export function ThreadgateBtn({
: _(msg`Some people can reply`)
return (
<View style={[a.flex_row, a.py_xs, a.px_sm, t.atoms.bg]}>
<Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}>
<Button
variant="solid"
color="secondary"
@@ -59,6 +62,6 @@ export function ThreadgateBtn({
/>
<ButtonText>{label}</ButtonText>
</Button>
</View>
</Animated.View>
)
}
+9 -13
View File
@@ -7,23 +7,22 @@ import {
View,
ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {cleanError} from '#/lib/strings/errors'
import {useTheme} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {isNative, isWeb} from '#/platform/detection'
import {hydrateFeedGenerator} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {EmptyState} from 'view/com/util/EmptyState'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text'
import {FeedSourceCardLoaded} from './FeedSourceCard'
const LOADING = {_reactKey: '__loading__'}
@@ -52,7 +51,6 @@ export const ProfileFeedgens = React.forwardRef<
{did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
ref,
) {
const pal = usePalette('default')
const {_} = useLingui()
const theme = useTheme()
const [isPTRing, setIsPTRing] = React.useState(false)
@@ -138,13 +136,11 @@ export const ProfileFeedgens = React.forwardRef<
({item, index}: ListRenderItemInfo<any>) => {
if (item === EMPTY) {
return (
<View
<EmptyState
icon="hashtag"
message={_(msg`You have no feeds.`)}
testID="listsEmpty"
style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
<Text style={pal.textLight}>
<Trans>You have no feeds.</Trans>
</Text>
</View>
/>
)
} else if (item === ERROR_ITEM) {
return (
@@ -170,13 +166,13 @@ export const ProfileFeedgens = React.forwardRef<
preferences={preferences}
style={styles.item}
showLikes
hideTopBorder={index === 0}
hideTopBorder={index === 0 && !isWeb}
/>
)
}
return null
},
[error, refetch, onPressRetryLoadMore, pal, preferences, _],
[error, refetch, onPressRetryLoadMore, preferences, _],
)
React.useEffect(() => {
@@ -1,30 +1,36 @@
import React, {useState} from 'react'
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
import {Image} from 'expo-image'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, {
runOnJS,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useAnimatedReaction,
useSharedValue,
withDecay,
withSpring,
} from 'react-native-reanimated'
import {GestureDetector, Gesture} from 'react-native-gesture-handler'
import {Image} from 'expo-image'
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import useImageDimensions from '../../hooks/useImageDimensions'
import {
createTransform,
readTransform,
applyRounding,
createTransform,
prependPan,
prependPinch,
prependTransform,
readTransform,
TransformMatrix,
} from '../../transforms'
import type {ImageSource, Dimensions as ImageDimensions} from '../../@types'
const SCREEN = Dimensions.get('window')
const windowDim = Dimensions.get('window')
const screenDim = Dimensions.get('screen')
const statusBarHeight = windowDim.height - screenDim.height
const SCREEN = {
width: windowDim.width,
height: windowDim.height + statusBarHeight,
}
const MIN_DOUBLE_TAP_SCALE = 2
const MAX_ORIGINAL_IMAGE_ZOOM = 2
+9 -10
View File
@@ -9,7 +9,8 @@ import {
ViewStyle,
} from 'react-native'
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
@@ -17,11 +18,10 @@ import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {EmptyState} from 'view/com/util/EmptyState'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List} from '../util/List'
import {Text} from '../util/text/Text'
import {ListCard} from './ListCard'
import hairlineWidth = StyleSheet.hairlineWidth
const LOADING = {_reactKey: '__loading__'}
const EMPTY = {_reactKey: '__empty__'}
@@ -42,6 +42,7 @@ export function MyLists({
}) {
const pal = usePalette('default')
const {track} = useAnalytics()
const {_} = useLingui()
const [isPTRing, setIsPTRing] = React.useState(false)
const {data, isFetching, isFetched, isError, error, refetch} =
useMyListsQuery(filter)
@@ -83,14 +84,12 @@ export function MyLists({
({item, index}: {item: any; index: number}) => {
if (item === EMPTY) {
return (
<View
<EmptyState
key={item._reactKey}
icon="list-ul"
message={_(msg`You have no lists.`)}
testID="listsEmpty"
style={[{padding: 18, borderTopWidth: hairlineWidth}, pal.border]}>
<Text style={pal.textLight}>
<Trans>You have no lists.</Trans>
</Text>
</View>
/>
)
} else if (item === ERROR_ITEM) {
return (
@@ -118,7 +117,7 @@ export function MyLists({
/>
)
},
[error, onRefresh, renderItem, pal],
[error, onRefresh, renderItem, _],
)
if (inline) {
+10 -12
View File
@@ -7,22 +7,21 @@ import {
View,
ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {cleanError} from '#/lib/strings/errors'
import {useTheme} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {isNative, isWeb} from '#/platform/detection'
import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {EmptyState} from 'view/com/util/EmptyState'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text'
import {ListCard} from './ListCard'
const LOADING = {_reactKey: '__loading__'}
@@ -49,7 +48,6 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
{did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
ref,
) {
const pal = usePalette('default')
const theme = useTheme()
const {track} = useAnalytics()
const {_} = useLingui()
@@ -142,11 +140,11 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
({item, index}: ListRenderItemInfo<any>) => {
if (item === EMPTY) {
return (
<View testID="listsEmpty" style={[{padding: 18}, pal.border]}>
<Text style={pal.textLight}>
<Trans>You have no lists.</Trans>
</Text>
</View>
<EmptyState
icon="list-ul"
message={_(msg`You have no lists.`)}
testID="listsEmpty"
/>
)
} else if (item === ERROR_ITEM) {
return (
@@ -172,11 +170,11 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
list={item}
testID={`list-${item.name}`}
style={styles.item}
noBorder={index === 0}
noBorder={index === 0 && !isWeb}
/>
)
},
[error, refetch, onPressRetryLoadMore, pal, _],
[error, refetch, onPressRetryLoadMore, _],
)
React.useEffect(() => {
+53 -1
View File
@@ -18,7 +18,13 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors'
import {colors, gradients, s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {isAndroid} from 'platform/detection'
import {isAndroid, isWeb} from 'platform/detection'
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
import {atoms as a, useTheme as useNewTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {InlineLinkText} from '#/components/Link'
import {Text as NewText} from '#/components/Typography'
import {resetToTab} from '../../../Navigation'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text'
@@ -30,6 +36,7 @@ export const snapPoints = isAndroid ? ['90%'] : ['55%']
export function Component({}: {}) {
const pal = usePalette('default')
const theme = useTheme()
const t = useNewTheme()
const {currentAccount} = useSession()
const agent = useAgent()
const {removeAccount} = useSessionApi()
@@ -41,6 +48,7 @@ export function Component({}: {}) {
const [password, setPassword] = React.useState<string>('')
const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
const [error, setError] = React.useState<string>('')
const deactivateAccountControl = useDialogControl()
const onPressSendEmail = async () => {
setError('')
setIsProcessing(true)
@@ -168,6 +176,50 @@ export function Component({}: {}) {
</TouchableOpacity>
</>
)}
<View style={[!isWeb && a.px_xl]}>
<View
style={[
a.w_full,
a.flex_row,
a.gap_sm,
a.mt_lg,
a.p_lg,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo
size="md"
style={[
a.relative,
{
top: -1,
},
]}
/>
<NewText style={[a.leading_snug, a.flex_1]}>
<Trans>
You can also temporarily deactivate your account instead,
and reactivate it at any time.
</Trans>{' '}
<InlineLinkText
label={_(
msg`Click here for more information on deactivating your account`,
)}
to="#"
onPress={e => {
e.preventDefault()
deactivateAccountControl.open()
return false
}}>
<Trans>Click here for more information.</Trans>
</InlineLinkText>
</NewText>
</View>
</View>
<DeactivateAccountDialog control={deactivateAccountControl} />
</>
) : (
<>
+30 -29
View File
@@ -6,28 +6,30 @@ import {
View,
} from 'react-native'
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import {MyLists} from '../lists/MyLists'
import {Button} from '../util/forms/Button'
import * as Toast from '../util/Toast'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb, isAndroid, isMobileWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {useModalControls} from '#/state/modals'
import {
useDangerousListMembershipsQuery,
getMembership,
ListMembersip,
useDangerousListMembershipsQuery,
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import {cleanError} from '#/lib/strings/errors'
import {useSession} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {s} from 'lib/styles'
import {isAndroid, isMobileWeb, isWeb} from 'platform/detection'
import {MyLists} from '../lists/MyLists'
import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
export const snapPoints = ['fullscreen']
@@ -61,12 +63,23 @@ export function Component({
return [pal.border, {height: screenHeight / 1.5}]
}
return [pal.border, {flex: 1}]
return [pal.border, {flex: 1, borderTopWidth: hairlineWidth}]
}, [pal.border, screenHeight])
return (
<View testID="userAddRemoveListsModal" style={s.hContentRegion}>
<Text style={[styles.title, pal.text]}>
<Text
style={[
{
textAlign: 'center',
fontWeight: 'bold',
fontSize: 20,
marginBottom: 12,
paddingHorizontal: 12,
},
pal.text,
]}
numberOfLines={1}>
<Trans>Update {displayName} in Lists</Trans>
</Text>
<MyLists
@@ -175,9 +188,7 @@ function ListItem({
style={[
styles.listItem,
pal.border,
{
borderTopWidth: index === 0 ? 0 : 1,
},
index !== 0 && {borderTopWidth: hairlineWidth},
]}>
<View style={styles.listItemAvi}>
<UserAvatar size={40} avatar={list.avatar} type="list" />
@@ -229,16 +240,6 @@ const styles = StyleSheet.create({
container: {
paddingHorizontal: isWeb ? 0 : 16,
},
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 10,
},
list: {
flex: 1,
borderTopWidth: 1,
},
btns: {
position: 'relative',
flexDirection: 'row',
@@ -247,7 +248,7 @@ const styles = StyleSheet.create({
gap: 10,
paddingTop: 10,
paddingBottom: isAndroid ? 10 : 0,
borderTopWidth: 1,
borderTopWidth: hairlineWidth,
},
footerBtn: {
paddingHorizontal: 24,
+6 -1
View File
@@ -129,7 +129,11 @@ export function Feed({
)
} else if (item === LOADING_ITEM) {
return (
<View style={[pal.border, {borderTopWidth: hairlineWidth}]}>
<View
style={[
pal.border,
!isTabletOrMobile && {borderTopWidth: hairlineWidth},
]}>
<NotificationFeedLoadingPlaceholder />
</View>
)
@@ -185,6 +189,7 @@ export function Feed({
desktopFixedHeight
initialNumToRender={initialNumToRender}
windowSize={11}
sideBorders={false}
/>
</View>
)
+6 -11
View File
@@ -40,7 +40,6 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
import {PostAlerts} from '../../../components/moderation/PostAlerts'
import {PostHider} from '../../../components/moderation/PostHider'
import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
import {AviFollowButton} from '../posts/AviFollowButton'
import {WhoCanReply} from '../threadgate/WhoCanReply'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Link, TextLink} from '../util/Link'
@@ -472,16 +471,12 @@ let PostThreadItemLoaded = ({
{/* If we are in threaded mode, the avatar is rendered in PostMeta */}
{!isThreadedChild && (
<View style={styles.layoutAvi}>
<AviFollowButton author={post.author} moderation={moderation}>
<PreviewableUserAvatar
size={38}
profile={post.author}
moderation={moderation.ui('avatar')}
type={
post.author.associated?.labeler ? 'labeler' : 'user'
}
/>
</AviFollowButton>
<PreviewableUserAvatar
size={38}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
/>
{showChildReplyLine && (
<View
+2 -2
View File
@@ -97,8 +97,8 @@ export function AviFollowButton({
}),
a.absolute,
{
bottom: 0,
right: 0,
bottom: -1,
right: -1,
borderWidth: 1,
borderColor: t.atoms.bg.backgroundColor,
},
+5 -1
View File
@@ -1 +1,5 @@
export {Fragment as AviFollowButton} from 'react'
import React from 'react'
export function AviFollowButton({children}: {children: React.ReactNode}) {
return children
}
+1 -9
View File
@@ -32,7 +32,6 @@ import {
import {useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useTheme} from 'lib/ThemeContext'
import {List, ListRef} from '../util/List'
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
@@ -102,7 +101,6 @@ let Feed = ({
const checkForNewRef = React.useRef<(() => void) | null>(null)
const lastFetchRef = React.useRef<number>(Date.now())
const [feedType, feedUri] = feed.split('|')
const {isTabletOrMobile} = useWebMediaQueries()
const opts = React.useMemo(
() => ({enabled, ignoreFilterFor}),
@@ -314,15 +312,9 @@ let Feed = ({
// -prf
return <DiscoverFallbackHeader />
}
return (
<FeedSlice
slice={item}
hideTopBorder={index === 0 && isTabletOrMobile}
/>
)
return <FeedSlice slice={item} hideTopBorder={index === 0 && !isWeb} />
},
[
isTabletOrMobile,
renderEmptyState,
feed,
error,
+7 -8
View File
@@ -43,6 +43,7 @@ import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar'
import {AviFollowButton} from './AviFollowButton'
import hairlineWidth = StyleSheet.hairlineWidth
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
interface FeedItemProps {
record: AppBskyFeedPost.Record
@@ -251,13 +252,10 @@ let FeedItemInner = ({
)}`,
)}
onBeforePress={onOpenReposter}>
<FontAwesomeIcon
icon="retweet"
style={{
marginRight: 4,
color: pal.colors.textLight,
minWidth: 16,
}}
<Repost
style={{color: pal.colors.textLight, marginRight: 3}}
width={14}
height={14}
/>
<Text
type="sm-bold"
@@ -463,9 +461,10 @@ const styles = StyleSheet.create({
},
includeReason: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
marginBottom: 2,
marginLeft: -20,
marginLeft: -18,
},
layout: {
flexDirection: 'row',
+6 -3
View File
@@ -8,6 +8,7 @@ import {
import {Text} from './text/Text'
import {UserGroupIcon} from 'lib/icons'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
export function EmptyState({
testID,
@@ -22,7 +23,9 @@ export function EmptyState({
}) {
const pal = usePalette('default')
return (
<View testID={testID} style={[styles.container, pal.border, style]}>
<View
testID={testID}
style={[styles.container, isWeb && pal.border, style]}>
<View style={styles.iconContainer}>
{icon === 'user-group' ? (
<UserGroupIcon size="64" style={styles.icon} />
@@ -48,9 +51,9 @@ export function EmptyState({
const styles = StyleSheet.create({
container: {
paddingVertical: 20,
paddingVertical: 24,
paddingHorizontal: 36,
borderTopWidth: 1,
borderTopWidth: isWeb ? 1 : undefined,
},
iconContainer: {
flexDirection: 'row',
+1
View File
@@ -25,6 +25,7 @@ export type ListProps<ItemT> = Omit<
onRefresh?: () => void
onItemSeen?: (item: ItemT) => void
containWeb?: boolean
desktopFixedHeight?: number | boolean
sideBorders?: boolean
// Web only prop to disable a perf optimization (which would otherwise be on).
disableContentVisibility?: boolean
+1 -1
View File
@@ -22,7 +22,7 @@ export type ListProps<ItemT> = Omit<
refreshing?: boolean
onRefresh?: () => void
onItemSeen?: (item: ItemT) => void
desktopFixedHeight: any // TODO: Better types.
desktopFixedHeight?: number | boolean
containWeb?: boolean
sideBorders?: boolean
disableContentVisibility?: boolean
+7 -1
View File
@@ -1,6 +1,10 @@
import React, {useCallback, useEffect} from 'react'
import {NativeScrollEvent} from 'react-native'
import {interpolate, useSharedValue} from 'react-native-reanimated'
import {
cancelAnimation,
interpolate,
useSharedValue,
} from 'react-native-reanimated'
import EventEmitter from 'eventemitter3'
import {ScrollProvider} from '#/lib/ScrollContext'
@@ -117,6 +121,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
const newValue = clamp(startMode.value + dProgress, 0, 1)
if (newValue !== mode.value) {
// Manually adjust the value. This won't be (and shouldn't be) animated.
// Cancel any any existing animation
cancelAnimation(mode)
mode.value = newValue
}
} else {
+12 -9
View File
@@ -32,14 +32,17 @@ interface AddedProps {
desktopFixedHeight?: boolean | number
}
export const CenteredView = React.forwardRef(function CenteredView({
style,
sideBorders,
topBorder,
...props
}: React.PropsWithChildren<
ViewProps & {sideBorders?: boolean; topBorder?: boolean}
>) {
export const CenteredView = React.forwardRef(function CenteredView(
{
style,
sideBorders,
topBorder,
...props
}: React.PropsWithChildren<
ViewProps & {sideBorders?: boolean; topBorder?: boolean}
>,
ref: React.Ref<View>,
) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
if (!isMobile) {
@@ -58,7 +61,7 @@ export const CenteredView = React.forwardRef(function CenteredView({
})
style = addStyle(style, pal.border)
}
return <View style={style} {...props} />
return <View ref={ref} style={style} {...props} />
})
export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl<ItemT>(
@@ -108,6 +108,7 @@ let RepostButton = ({
</Text>
</Button>
<Button
testID="quoteBtn"
style={[a.justify_start, a.px_md]}
label={_(msg`Quote post`)}
onPress={() => {
+26 -21
View File
@@ -2,7 +2,6 @@ import React from 'react'
import {
StyleProp,
StyleSheet,
TextStyle,
TouchableOpacity,
View,
ViewStyle,
@@ -32,7 +31,7 @@ import {InfoCircleIcon} from 'lib/icons'
import {makeProfileLink} from 'lib/routes/links'
import {precacheProfile} from 'state/queries/profile'
import {ComposerOptsQuote} from 'state/shell/composer'
import {atoms as a, flatten} from '#/alf'
import {atoms as a} from '#/alf'
import {RichText} from '#/components/RichText'
import {ContentHider} from '../../../../components/moderation/ContentHider'
import {PostAlerts} from '../../../../components/moderation/PostAlerts'
@@ -46,12 +45,12 @@ export function MaybeQuoteEmbed({
embed,
onOpen,
style,
textStyle,
allowNestedQuotes,
}: {
embed: AppBskyEmbedRecord.View
onOpen?: () => void
style?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle>
allowNestedQuotes?: boolean
}) {
const pal = usePalette('default')
if (
@@ -65,7 +64,7 @@ export function MaybeQuoteEmbed({
postRecord={embed.record.value}
onOpen={onOpen}
style={style}
textStyle={textStyle}
allowNestedQuotes={allowNestedQuotes}
/>
)
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
@@ -95,13 +94,13 @@ function QuoteEmbedModerated({
postRecord,
onOpen,
style,
textStyle,
allowNestedQuotes,
}: {
viewRecord: AppBskyEmbedRecord.ViewRecord
postRecord: AppBskyFeedPost.Record
onOpen?: () => void
style?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle>
allowNestedQuotes?: boolean
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
@@ -126,7 +125,7 @@ function QuoteEmbedModerated({
moderation={moderation}
onOpen={onOpen}
style={style}
textStyle={textStyle}
allowNestedQuotes={allowNestedQuotes}
/>
)
}
@@ -136,13 +135,13 @@ export function QuoteEmbed({
moderation,
onOpen,
style,
textStyle,
allowNestedQuotes,
}: {
quote: ComposerOptsQuote
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle>
allowNestedQuotes?: boolean
}) {
const queryClient = useQueryClient()
const pal = usePalette('default')
@@ -161,16 +160,20 @@ export function QuoteEmbed({
const embed = React.useMemo(() => {
const e = quote.embeds?.[0]
if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) {
if (allowNestedQuotes) {
return e
} else if (
AppBskyEmbedRecordWithMedia.isView(e) &&
(AppBskyEmbedImages.isView(e.media) ||
AppBskyEmbedExternal.isView(e.media))
) {
return e.media
} else {
if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) {
return e
} else if (
AppBskyEmbedRecordWithMedia.isView(e) &&
(AppBskyEmbedImages.isView(e.media) ||
AppBskyEmbedExternal.isView(e.media))
) {
return e.media
}
}
}, [quote.embeds])
}, [quote.embeds, allowNestedQuotes])
const onBeforePress = React.useCallback(() => {
precacheProfile(queryClient, quote.author)
@@ -178,9 +181,11 @@ export function QuoteEmbed({
}, [queryClient, quote.author, onOpen])
return (
<ContentHider modui={moderation?.ui('contentList')}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[styles.container, pal.borderDark, style]}
childContainerStyle={[a.pt_sm]}>
<Link
style={[styles.container, pal.borderDark, style]}
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
href={itemHref}
title={itemTitle}
@@ -201,7 +206,7 @@ export function QuoteEmbed({
{richText ? (
<RichText
value={richText}
style={[a.text_md, flatten(textStyle)]}
style={a.text_md}
numberOfLines={20}
disableLinks
/>
+4 -9
View File
@@ -4,7 +4,6 @@ import {
StyleProp,
StyleSheet,
Text,
TextStyle,
View,
ViewStyle,
} from 'react-native'
@@ -42,13 +41,13 @@ export function PostEmbeds({
moderation,
onOpen,
style,
quoteTextStyle,
allowNestedQuotes,
}: {
embed?: Embed
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp<ViewStyle>
quoteTextStyle?: StyleProp<TextStyle>
allowNestedQuotes?: boolean
}) {
const pal = usePalette('default')
const {openLightbox} = useLightboxControls()
@@ -63,11 +62,7 @@ export function PostEmbeds({
moderation={moderation}
onOpen={onOpen}
/>
<MaybeQuoteEmbed
embed={embed.record}
onOpen={onOpen}
textStyle={quoteTextStyle}
/>
<MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} />
</View>
)
}
@@ -98,8 +93,8 @@ export function PostEmbeds({
<MaybeQuoteEmbed
embed={embed}
style={style}
textStyle={quoteTextStyle}
onOpen={onOpen}
allowNestedQuotes={allowNestedQuotes}
/>
)
}
+6 -6
View File
@@ -52,12 +52,12 @@ export function ListsScreen({}: Props) {
<View style={s.hContentRegion} testID="listsScreen">
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [
pal.border,
{borderLeftWidth: hairlineWidth, borderRightWidth: hairlineWidth},
]
}>
style={[
pal.border,
isMobile
? {borderBottomWidth: hairlineWidth}
: {borderLeftWidth: hairlineWidth, borderRightWidth: hairlineWidth},
]}>
<View style={{flex: 1}}>
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
<Trans>User Lists</Trans>
+6 -2
View File
@@ -29,6 +29,7 @@ import {colors, s} from 'lib/styles'
import {TextLink} from 'view/com/util/Link'
import {ListMethods} from 'view/com/util/List'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {CenteredView} from 'view/com/util/Views'
import {Feed} from '../com/notifications/Feed'
import {FAB} from '../com/util/fab/FAB'
import {MainScrollProvider} from '../com/util/MainScrollProvider'
@@ -145,7 +146,10 @@ export function NotificationsScreen({}: Props) {
}, [isDesktop, pal, hasNew])
return (
<View testID="notificationsScreen" style={s.hContentRegion}>
<CenteredView
testID="notificationsScreen"
style={s.hContentRegion}
sideBorders={true}>
<ViewHeader
title={_(msg`Notifications`)}
canGoBack={false}
@@ -173,6 +177,6 @@ export function NotificationsScreen({}: Props) {
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
</View>
</CenteredView>
)
}
+22 -33
View File
@@ -1,28 +1,26 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers'
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
import {ComposePrompt} from 'view/com/composer/Prompt'
import {s} from 'lib/styles'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {clamp} from 'lodash'
import {isWeb} from '#/platform/detection'
import {
RQKEY as POST_THREAD_RQKEY,
ThreadNode,
} from '#/state/queries/post-thread'
import {clamp} from 'lodash'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {useSetMinimalShellMode} from '#/state/shell'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {ErrorMessage} from '../com/util/error/ErrorMessage'
import {CenteredView} from '../com/util/Views'
import {useComposerControls} from '#/state/shell/composer'
import {useSession} from '#/state/session'
import {isWeb} from '#/platform/detection'
import {useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers'
import {s} from 'lib/styles'
import {ComposePrompt} from 'view/com/composer/Prompt'
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
export function PostThreadScreen({route}: Props) {
@@ -35,7 +33,6 @@ export function PostThreadScreen({route}: Props) {
const {name, rkey} = route.params
const {isMobile} = useWebMediaQueries()
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const {data: resolvedUri, error: uriError} = useResolveUriQuery(uri)
const [canReply, setCanReply] = React.useState(false)
useFocusEffect(
@@ -45,12 +42,10 @@ export function PostThreadScreen({route}: Props) {
)
const onPressReply = React.useCallback(() => {
if (!resolvedUri) {
if (!uri) {
return
}
const thread = queryClient.getQueryData<ThreadNode>(
POST_THREAD_RQKEY(resolvedUri.uri),
)
const thread = queryClient.getQueryData<ThreadNode>(POST_THREAD_RQKEY(uri))
if (thread?.type !== 'post') {
return
}
@@ -64,25 +59,19 @@ export function PostThreadScreen({route}: Props) {
},
onPost: () =>
queryClient.invalidateQueries({
queryKey: POST_THREAD_RQKEY(resolvedUri.uri || ''),
queryKey: POST_THREAD_RQKEY(uri),
}),
})
}, [openComposer, queryClient, resolvedUri])
}, [openComposer, queryClient, uri])
return (
<View style={s.hContentRegion}>
<View style={s.flex1}>
{uriError ? (
<CenteredView>
<ErrorMessage message={String(uriError)} />
</CenteredView>
) : (
<PostThreadComponent
uri={resolvedUri?.uri}
onPressReply={onPressReply}
onCanReply={setCanReply}
/>
)}
<PostThreadComponent
uri={uri}
onPressReply={onPressReply}
onCanReply={setCanReply}
/>
</View>
{isMobile && canReply && hasSession && (
<Animated.View
+7 -8
View File
@@ -32,6 +32,7 @@ import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
import {atoms as a, useTheme} from '#/alf'
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
import hairlineWidth = StyleSheet.hairlineWidth
const HITSLOP_TOP = {
top: 20,
@@ -92,7 +93,7 @@ export function SavedFeeds({}: Props) {
<ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder />
<ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}>
{noSavedFeedsOfAnyType && (
<View style={[pal.border, {borderBottomWidth: 1}]}>
<View style={[pal.border, {borderBottomWidth: hairlineWidth}]}>
<NoSavedFeedsOfAnyType />
</View>
)}
@@ -134,7 +135,7 @@ export function SavedFeeds({}: Props) {
)}
{noFollowingFeed && (
<View style={[pal.border, {borderBottomWidth: 1}]}>
<View style={[pal.border, {borderBottomWidth: hairlineWidth}]}>
<NoFollowingFeed />
</View>
)}
@@ -298,9 +299,10 @@ function ListItem({
<FeedSourceCard
key={feedUri}
feedUri={feedUri}
style={[styles.noTopBorder, isPinned && {paddingRight: 8}]}
style={[isPinned && {paddingRight: 8}]}
showMinimalPlaceholder
showSaveBtn={!isPinned}
hideTopBorder={true}
/>
)}
{isPinned ? (
@@ -435,15 +437,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 14,
paddingTop: 20,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomWidth: hairlineWidth,
},
itemContainer: {
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
},
noTopBorder: {
borderTopWidth: 0,
borderBottomWidth: hairlineWidth,
},
footerText: {
paddingHorizontal: 26,
+199 -7
View File
@@ -1,12 +1,16 @@
import React from 'react'
import {
ActivityIndicator,
Image,
ImageStyle,
Platform,
Pressable,
StyleProp,
StyleSheet,
TextInput,
View,
} from 'react-native'
import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler'
import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
import {
FontAwesomeIcon,
@@ -18,9 +22,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {useAnalytics} from '#/lib/analytics/analytics'
import {createHitslop} from '#/lib/constants'
import {HITSLOP_10} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {MagnifyingGlassIcon} from '#/lib/icons'
import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {augmentSearchQuery} from '#/lib/strings/helpers'
import {s} from '#/lib/styles'
@@ -46,6 +52,7 @@ import {Pager} from '#/view/com/pager/Pager'
import {TabBar} from '#/view/com/pager/TabBar'
import {Post} from '#/view/com/post/Post'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {Link} from '#/view/com/util/Link'
import {List} from '#/view/com/util/List'
import {Text} from '#/view/com/util/text/Text'
import {CenteredView, ScrollView} from '#/view/com/util/Views'
@@ -488,6 +495,9 @@ export function SearchScreen(
const [showAutocomplete, setShowAutocomplete] = React.useState(false)
const [searchHistory, setSearchHistory] = React.useState<string[]>([])
const [selectedProfiles, setSelectedProfiles] = React.useState<
AppBskyActorDefs.ProfileViewBasic[]
>([])
useFocusEffect(
useNonReactiveCallback(() => {
@@ -504,6 +514,10 @@ export function SearchScreen(
if (history !== null) {
setSearchHistory(JSON.parse(history))
}
const profiles = await AsyncStorage.getItem('selectedProfiles')
if (profiles !== null) {
setSelectedProfiles(JSON.parse(profiles))
}
} catch (e: any) {
logger.error('Failed to load search history', {message: e})
}
@@ -562,6 +576,30 @@ export function SearchScreen(
[searchHistory, setSearchHistory],
)
const updateSelectedProfiles = React.useCallback(
async (profile: AppBskyActorDefs.ProfileViewBasic) => {
let newProfiles = [
profile,
...selectedProfiles.filter(p => p.did !== profile.did),
]
if (newProfiles.length > 5) {
newProfiles = newProfiles.slice(0, 5)
}
setSelectedProfiles(newProfiles)
try {
await AsyncStorage.setItem(
'selectedProfiles',
JSON.stringify(newProfiles),
)
} catch (e: any) {
logger.error('Failed to save selected profiles', {message: e})
}
},
[selectedProfiles, setSelectedProfiles],
)
const navigateToItem = React.useCallback(
(item: string) => {
scrollToTopWeb()
@@ -598,6 +636,16 @@ export function SearchScreen(
[navigateToItem],
)
const handleProfileClick = React.useCallback(
(profile: AppBskyActorDefs.ProfileViewBasic) => {
// Slight delay to avoid updating during push nav animation.
setTimeout(() => {
updateSelectedProfiles(profile)
}, 400)
},
[updateSelectedProfiles],
)
const onSoftReset = React.useCallback(() => {
if (isWeb) {
// Empty params resets the URL to be /search rather than /search?q=
@@ -629,6 +677,22 @@ export function SearchScreen(
[searchHistory],
)
const handleRemoveProfile = React.useCallback(
(profileToRemove: AppBskyActorDefs.ProfileViewBasic) => {
const updatedProfiles = selectedProfiles.filter(
profile => profile.did !== profileToRemove.did,
)
setSelectedProfiles(updatedProfiles)
AsyncStorage.setItem(
'selectedProfiles',
JSON.stringify(updatedProfiles),
).catch(e => {
logger.error('Failed to update selected profiles', {message: e})
})
},
[selectedProfiles],
)
return (
<View style={isWeb ? null : {flex: 1}}>
<CenteredView
@@ -689,12 +753,16 @@ export function SearchScreen(
searchText={searchText}
onSubmit={onSubmit}
onResultPress={onAutocompleteResultPress}
onProfileClick={handleProfileClick}
/>
) : (
<SearchHistory
searchHistory={searchHistory}
selectedProfiles={selectedProfiles}
onItemClick={handleHistoryItemClick}
onProfileClick={handleProfileClick}
onRemoveItemClick={handleRemoveHistoryItem}
onRemoveProfileClick={handleRemoveProfile}
/>
)}
</View>
@@ -814,12 +882,14 @@ let AutocompleteResults = ({
searchText,
onSubmit,
onResultPress,
onProfileClick,
}: {
isAutocompleteFetching: boolean
autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined
searchText: string
onSubmit: () => void
onResultPress: () => void
onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void
}): React.ReactNode => {
const moderationOpts = useModerationOpts()
const {_} = useLingui()
@@ -850,7 +920,10 @@ let AutocompleteResults = ({
key={item.did}
profile={item}
moderation={moderateProfile(item, moderationOpts)}
onPress={onResultPress}
onPress={() => {
onProfileClick(item)
onResultPress()
}}
/>
))}
<View style={{height: 200}} />
@@ -861,17 +934,32 @@ let AutocompleteResults = ({
}
AutocompleteResults = React.memo(AutocompleteResults)
function truncateText(text: string, maxLength: number) {
if (text.length > maxLength) {
return text.substring(0, maxLength) + '...'
}
return text
}
function SearchHistory({
searchHistory,
selectedProfiles,
onItemClick,
onProfileClick,
onRemoveItemClick,
onRemoveProfileClick,
}: {
searchHistory: string[]
selectedProfiles: AppBskyActorDefs.ProfileViewBasic[]
onItemClick: (item: string) => void
onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void
onRemoveItemClick: (item: string) => void
onRemoveProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void
}) {
const {isTabletOrDesktop} = useWebMediaQueries()
const {isTabletOrDesktop, isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const {_} = useLingui()
return (
<CenteredView
sideBorders={isTabletOrDesktop}
@@ -880,12 +968,70 @@ function SearchHistory({
height: isWeb ? '100vh' : undefined,
}}>
<View style={styles.searchHistoryContainer}>
{(searchHistory.length > 0 || selectedProfiles.length > 0) && (
<Text style={[pal.text, styles.searchHistoryTitle]}>
<Trans>Recent Searches</Trans>
</Text>
)}
{selectedProfiles.length > 0 && (
<View
style={[
styles.selectedProfilesContainer,
isMobile && styles.selectedProfilesContainerMobile,
]}>
<RNGHScrollView
keyboardShouldPersistTaps="handled"
horizontal={true}
style={styles.profilesRow}
contentContainerStyle={{
borderWidth: 0,
}}>
{selectedProfiles.slice(0, 5).map((profile, index) => (
<View
key={index}
style={[
styles.profileItem,
isMobile && styles.profileItemMobile,
]}>
<Link
href={makeProfileLink(profile)}
title={profile.handle}
asAnchor
anchorNoUnderline
onBeforePress={() => onProfileClick(profile)}
style={styles.profilePressable}>
<Image
source={{uri: profile.avatar}}
style={styles.profileAvatar as StyleProp<ImageStyle>}
accessibilityIgnoresInvertColors
/>
<Text style={[pal.text, styles.profileName]}>
{truncateText(profile.displayName || '', 12)}
</Text>
</Link>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Remove profile`)}
accessibilityHint={_(
msg`Remove profile from search history`,
)}
onPress={() => onRemoveProfileClick(profile)}
hitSlop={createHitslop(6)}
style={styles.profileRemoveBtn}>
<FontAwesomeIcon
icon="xmark"
size={14}
style={pal.textLight as FontAwesomeIconStyle}
/>
</Pressable>
</View>
))}
</RNGHScrollView>
</View>
)}
{searchHistory.length > 0 && (
<View style={styles.searchHistoryContent}>
<Text style={[pal.text, styles.searchHistoryTitle]}>
<Trans>Recent Searches</Trans>
</Text>
{searchHistory.map((historyItem, index) => (
{searchHistory.slice(0, 5).map((historyItem, index) => (
<View
key={index}
style={[
@@ -982,11 +1128,57 @@ const styles = StyleSheet.create({
width: '100%',
paddingHorizontal: 12,
},
selectedProfilesContainer: {
marginTop: 10,
paddingHorizontal: 12,
height: 80,
},
selectedProfilesContainerMobile: {
height: 100,
},
profilesRow: {
flexDirection: 'row',
flexWrap: 'nowrap',
},
profileItem: {
alignItems: 'center',
marginRight: 15,
width: 78,
},
profileItemMobile: {
width: 70,
},
profilePressable: {
alignItems: 'center',
},
profileAvatar: {
width: 60,
height: 60,
borderRadius: 45,
},
profileName: {
fontSize: 12,
textAlign: 'center',
marginTop: 5,
},
profileRemoveBtn: {
position: 'absolute',
top: 0,
right: 5,
backgroundColor: 'white',
borderRadius: 10,
width: 18,
height: 18,
alignItems: 'center',
justifyContent: 'center',
},
searchHistoryContent: {
padding: 10,
paddingHorizontal: 10,
borderRadius: 8,
},
searchHistoryTitle: {
fontWeight: 'bold',
paddingVertical: 12,
paddingHorizontal: 10,
},
})
+32 -3
View File
@@ -60,6 +60,7 @@ import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {ScrollView} from 'view/com/util/Views'
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
import {useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
@@ -103,10 +104,10 @@ function SettingsAccountCard({
/>
</View>
<View style={[s.flex1]}>
<Text type="md-bold" style={pal.text}>
<Text type="md-bold" style={pal.text} numberOfLines={1}>
{profile?.displayName || account.handle}
</Text>
<Text type="sm" style={pal.textLight}>
<Text type="sm" style={pal.textLight} numberOfLines={1}>
{account.handle}
</Text>
</View>
@@ -307,6 +308,11 @@ export function SettingsScreen({}: Props) {
Toast.show(_(msg`Legacy storage cleared, you need to restart the app now.`))
}, [_])
const deactivateAccountControl = useDialogControl()
const onPressDeactivateAccount = React.useCallback(() => {
deactivateAccountControl.open()
}, [deactivateAccountControl])
const {mutate: onPressDeleteChatDeclaration} = useDeleteActorDeclaration()
return (
@@ -381,7 +387,7 @@ export function SettingsScreen({}: Props) {
{!currentAccount.emailConfirmed && <EmailConfirmationNotice />}
<View style={[s.flexRow, styles.heading]}>
<Text type="xl-bold" style={pal.text}>
<Text type="xl-bold" style={pal.text} numberOfLines={1}>
<Trans>Signed in as</Trans>
</Text>
<View style={s.flex1} />
@@ -791,6 +797,29 @@ export function SettingsScreen({}: Props) {
<Trans>Export My Data</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[pal.view, styles.linkCard]}
onPress={onPressDeactivateAccount}
accessible={true}
accessibilityRole="button"
accessibilityLabel={_(msg`Deactivate account`)}
accessibilityHint={_(
msg`Opens modal for account deactivation confirmation`,
)}>
<View style={[styles.iconContainer, dangerBg]}>
<FontAwesomeIcon
icon={'users-slash'}
style={dangerText as FontAwesomeIconStyle}
size={18}
/>
</View>
<Text type="lg" style={dangerText}>
<Trans>Deactivate my account</Trans>
</Text>
</TouchableOpacity>
<DeactivateAccountDialog control={deactivateAccountControl} />
<TouchableOpacity
style={[pal.view, styles.linkCard]}
onPress={onPressDeleteAccount}
+11 -1
View File
@@ -1,4 +1,4 @@
import React, {useLayoutEffect} from 'react'
import React, {useLayoutEffect, useState} from 'react'
import {Modal, View} from 'react-native'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {RootSiblingParent} from 'react-native-root-siblings'
@@ -24,8 +24,16 @@ export const Composer = observer(function ComposerImpl({}: {
const t = useTheme()
const state = useComposerState()
const ref = useComposerCancelRef()
const [isModalReady, setIsModalReady] = useState(false)
const open = !!state
const [prevOpen, setPrevOpen] = useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (!open) {
setIsModalReady(false)
}
}
return (
<Modal
@@ -34,10 +42,12 @@ export const Composer = observer(function ComposerImpl({}: {
visible={open}
presentationStyle="formSheet"
animationType="slide"
onShow={() => setIsModalReady(true)}
onRequestClose={() => ref.current?.onPressCancel()}>
<View style={[t.atoms.bg, a.flex_1]}>
<Providers open={open}>
<ComposePost
isModalReady={isModalReady}
cancelRef={ref}
replyTo={state?.replyTo}
onPost={state?.onPost}
+12 -20
View File
@@ -1,22 +1,20 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import Animated, {FadeIn, FadeInDown, FadeOut} from 'react-native-reanimated'
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useComposerState} from 'state/shell/composer'
import {
EmojiPicker,
EmojiPickerState,
} from 'view/com/composer/text-input/web/EmojiPicker.web'
import {useBreakpoints, useTheme} from '#/alf'
import {ComposePost} from '../com/composer/Composer'
const BOTTOM_BAR_HEIGHT = 61
export function Composer({}: {winHeight: number}) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const state = useComposerState()
const isActive = !!state
useWebBodyScrollLock(isActive)
@@ -49,22 +47,16 @@ export function Composer({}: {winHeight: number}) {
}
return (
<Animated.View
style={styles.mask}
aria-modal
accessibilityViewIsModal
entering={FadeIn.duration(100)}
exiting={FadeOut}>
<Animated.View
entering={FadeInDown.duration(150)}
exiting={FadeOut}
<View style={styles.mask} aria-modal accessibilityViewIsModal>
<View
style={[
styles.container,
isMobile && styles.containerMobile,
pal.view,
pal.border,
!gtMobile && styles.containerMobile,
t.atoms.bg,
t.atoms.border_contrast_medium,
]}>
<ComposePost
isModalReady={true}
replyTo={state.replyTo}
quote={state.quote}
onPost={state.onPost}
@@ -72,9 +64,9 @@ export function Composer({}: {winHeight: number}) {
openPicker={onOpenPicker}
text={state.text}
/>
</Animated.View>
</View>
<EmojiPicker state={pickerState} close={onClosePicker} />
</Animated.View>
</View>
)
}
@@ -94,12 +86,12 @@ const styles = StyleSheet.create({
maxWidth: 600,
width: '100%',
paddingVertical: 0,
paddingHorizontal: 2,
borderRadius: 8,
marginBottom: 0,
borderWidth: 1,
// @ts-ignore web only
maxHeight: 'calc(100% - (40px * 2))',
overflow: 'hidden',
},
containerMobile: {
borderRadius: 0,
+35 -8
View File
@@ -16,6 +16,9 @@ import {s} from '#/lib/styles'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {useGate} from 'lib/statsig/statsig'
import {useUnreadMessageCount} from 'state/queries/messages/list-converations'
import {useUnreadNotifications} from 'state/queries/notifications/unread'
import {Button} from '#/view/com/util/forms/Button'
import {Text} from '#/view/com/util/text/Text'
import {Logo} from '#/view/icons/Logo'
@@ -46,11 +49,15 @@ export function BottomBarWeb() {
const {hasSession, currentAccount} = useSession()
const pal = usePalette('default')
const safeAreaInsets = useSafeAreaInsets()
const gate = useGate()
const {footerMinimalShellTransform} = useMinimalShellMode()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
const iconWidth = 26
const unreadMessageCount = useUnreadMessageCount()
const notificationCountStr = useUnreadNotifications()
const showSignIn = React.useCallback(() => {
closeAllActiveElements()
requestSwitchToAccount({requestedAccount: 'none'})
@@ -103,10 +110,20 @@ export function BottomBarWeb() {
{({isActive}) => {
const Icon = isActive ? MessageFilled : Message
return (
<Icon
width={iconWidth - 1}
style={[styles.ctrlIcon, pal.text, styles.messagesIcon]}
/>
<>
<Icon
width={iconWidth - 1}
style={[styles.ctrlIcon, pal.text, styles.messagesIcon]}
/>
{unreadMessageCount.count > 0 &&
gate('show_notification_badge_mobile_web') && (
<View style={styles.notificationCount}>
<Text style={styles.notificationCountLabel}>
{unreadMessageCount.numUnread}
</Text>
</View>
)}
</>
)
}}
</NavItem>
@@ -114,10 +131,20 @@ export function BottomBarWeb() {
{({isActive}) => {
const Icon = isActive ? BellFilled : Bell
return (
<Icon
width={iconWidth}
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
/>
<>
<Icon
width={iconWidth}
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
/>
{notificationCountStr !== '' &&
gate('show_notification_badge_mobile_web') && (
<View style={styles.notificationCount}>
<Text style={styles.notificationCountLabel}>
{notificationCountStr}
</Text>
</View>
)}
</>
)
}}
</NavItem>
@@ -32,6 +32,7 @@ import {
import {isWeb} from 'platform/detection'
import {Deactivated} from '#/screens/Deactivated'
import {Onboarding} from '#/screens/Onboarding'
import {SignupQueued} from '#/screens/SignupQueued'
import {LoggedOut} from '../com/auth/LoggedOut'
import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
import {DesktopLeftNav} from './desktop/LeftNav'
@@ -102,12 +103,15 @@ function NativeStackNavigator({
if ((!PWI_ENABLED || activeRouteRequiresAuth) && !hasSession) {
return <LoggedOut />
}
if (hasSession && currentAccount?.deactivated) {
return <Deactivated />
if (hasSession && currentAccount?.signupQueued) {
return <SignupQueued />
}
if (showLoggedOut) {
return <LoggedOut onDismiss={() => setShowLoggedOut(false)} />
}
if (currentAccount?.status === 'deactivated') {
return <Deactivated />
}
if (onboardingState.isActive) {
return <Onboarding />
}
+8 -102
View File
@@ -34,10 +34,10 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
"@atproto/api@^0.12.14":
version "0.12.14"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.14.tgz#81252fd166ec8fe950056531e690d563437720fa"
integrity sha512-ZPh/afoRjFEQDQgMZW2FQiG5CDUifY7SxBqI0zVJUwed8Zi6fqYzGYM8fcDvD8yJfflRCqRxUE72g5fKiA1zAQ==
"@atproto/api@^0.12.16":
version "0.12.16"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.16.tgz#f5b5e06d75d379dafe79521d727ed8ad5516d3fc"
integrity sha512-v3lA/m17nkawDXiqgwXyaUSzJPeXJBMH8QKOoYxcDqN+8yG9LFlGe2ecGarXcbGQjYT0GJTAAW3Y/AaCOEwuLg==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0"
@@ -3425,32 +3425,6 @@
xcode "^3.0.1"
xml2js "0.6.0"
"@expo/config-plugins@~5.0.3":
version "5.0.4"
resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-5.0.4.tgz#216fea6558fe66615af1370de55193f4181cb23e"
integrity sha512-vzUcVpqOMs3h+hyRdhGwk+eGIOhXa5xYdd92yO17RMNHav3v/+ekMbs7XA2c3lepMO8Yd4/5hqmRw9ZTL6jGzg==
dependencies:
"@expo/config-types" "^47.0.0"
"@expo/json-file" "8.2.36"
"@expo/plist" "0.0.18"
"@expo/sdk-runtime-versions" "^1.0.0"
"@react-native/normalize-color" "^2.0.0"
chalk "^4.1.2"
debug "^4.3.1"
find-up "~5.0.0"
getenv "^1.0.0"
glob "7.1.6"
resolve-from "^5.0.0"
semver "^7.3.5"
slash "^3.0.0"
xcode "^3.0.1"
xml2js "0.4.23"
"@expo/config-types@^47.0.0":
version "47.0.0"
resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-47.0.0.tgz#99eeabe0bba7a776e0f252b78beb0c574692c38d"
integrity sha512-r0pWfuhkv7KIcXMUiNACJmJKKwlTBGMw9VZHNdppS8/0Nve8HZMTkNRFQzTHW1uH3pBj8jEXpyw/2vSWDHex9g==
"@expo/config-types@^50.0.0-alpha.1":
version "50.0.0"
resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-50.0.0.tgz#b534d3ec997ec60f8af24f6ad56244c8afc71a0b"
@@ -3478,23 +3452,6 @@
slugify "^1.3.4"
sucrase "3.34.0"
"@expo/config@~7.0.0":
version "7.0.3"
resolved "https://registry.yarnpkg.com/@expo/config/-/config-7.0.3.tgz#c9c634e76186de25e296485e51418f1e52966e6e"
integrity sha512-joVtB5o+NF40Tmsdp65UzryRtbnCuMbXkVO4wJnNJO4aaK0EYLdHCYSewORVqNcDfGN0LphQr8VTG2npbd9CJA==
dependencies:
"@babel/code-frame" "~7.10.4"
"@expo/config-plugins" "~5.0.3"
"@expo/config-types" "^47.0.0"
"@expo/json-file" "8.2.36"
getenv "^1.0.0"
glob "7.1.6"
require-from-string "^2.0.2"
resolve-from "^5.0.0"
semver "7.3.2"
slugify "^1.3.4"
sucrase "^3.20.0"
"@expo/config@~8.5.0":
version "8.5.0"
resolved "https://registry.yarnpkg.com/@expo/config/-/config-8.5.0.tgz#c618e016c3272335e33fec02fb7fd67e4dcc3342"
@@ -3641,15 +3598,6 @@
semver "^7.6.0"
tempy "0.3.0"
"@expo/json-file@8.2.36":
version "8.2.36"
resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-8.2.36.tgz#62a505cb7f30a34d097386476794680a3f7385ff"
integrity sha512-tOZfTiIFA5KmMpdW9KF7bc6CFiGjb0xnbieJhTGlHrLL+ps2G0OkqmuZ3pFEXBOMnJYUVpnSy++52LFxvpa5ZQ==
dependencies:
"@babel/code-frame" "~7.10.4"
json5 "^1.0.1"
write-file-atomic "^2.3.0"
"@expo/json-file@^8.2.37":
version "8.2.37"
resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-8.2.37.tgz#9c02d3b42134907c69cc0a027b18671b69344049"
@@ -3751,15 +3699,6 @@
split "^1.0.1"
sudo-prompt "9.1.1"
"@expo/plist@0.0.18":
version "0.0.18"
resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.0.18.tgz#9abcde78df703a88f6d9fa1a557ee2f045d178b0"
integrity sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w==
dependencies:
"@xmldom/xmldom" "~0.7.0"
base64-js "^1.2.3"
xmlbuilder "^14.0.0"
"@expo/plist@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.1.0.tgz#eabc95f951d14e10c87fd0443ee01d567371f058"
@@ -8593,7 +8532,7 @@
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz#a1337ca426aa61cef9fe15b5b28e340a72f6fa99"
integrity sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==
"@xmldom/xmldom@~0.7.0", "@xmldom/xmldom@~0.7.7":
"@xmldom/xmldom@~0.7.7":
version "0.7.13"
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.13.tgz#ff34942667a4e19a9f4a0996a76814daac364cf3"
integrity sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==
@@ -12075,15 +12014,7 @@ expo-clipboard@^6.0.3:
resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-6.0.3.tgz#dfea74d4a004dce59ecefd063d6fb9f1c356a03f"
integrity sha512-RIKDsuHkYfaspifbFpVC8sBVFKR05L7Pj7mU2/XkbrW9m01OBNvdpGraXEMsTFCx97xMGsZpEw9pPquL4j4xVg==
expo-constants@^13.0.2:
version "13.2.4"
resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-13.2.4.tgz#eab4a553f074b2c60ad7a158d3b82e3484a94606"
integrity sha512-Zobau8EuTk2GgafwkfGnWM6CmSLB7X8qnQXVuXe0nd3v92hfQUmRWGhJwH88uxXj3LrfqctM6PaJ8taG1vxfBw==
dependencies:
"@expo/config" "~7.0.0"
uuid "^3.3.2"
expo-constants@~16.0.0, expo-constants@~16.0.1:
expo-constants@16.0.1, expo-constants@^13.0.2, expo-constants@~16.0.0:
version "16.0.1"
resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-16.0.1.tgz#1285e29c85513c6e88e118289e2baab72596d3f7"
integrity sha512-s6aTHtglp926EsugWtxN7KnpSsE9FCEjb7CgEjQQ78Gpu4btj4wB+IXot2tlqNwqv+x7xFe5veoPGfJDGF/kVg==
@@ -12125,14 +12056,7 @@ expo-dev-menu@5.0.14:
expo-dev-menu-interface "1.8.3"
semver "^7.5.4"
expo-device@~4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-4.1.1.tgz#5de94144113ffb7fa0f37fa3d70e452113954c10"
integrity sha512-It0SGtKcvzQSf+Co6zdPdB63zZvG2/rDolB1lqswMNKj03Y7KVU41s5tcQCqNczj7tmeN3CJy7A8YhYGKdb7gA==
dependencies:
ua-parser-js "^0.7.19"
expo-device@~6.0.2:
expo-device@6.0.2, expo-device@~4.1.1, expo-device@~6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-6.0.2.tgz#9bc3eccd16509c2819c225cc2ca8f7c3e3bdd11e"
integrity sha512-sCt91CuTmAuMXX4SlFOn4lIos2UIr8vb0jDstDDZXys6kErcj0uynC7bQAMreU5uRUTKMAl4MAMpKt9ufCXPBw==
@@ -15557,7 +15481,7 @@ json-stable-stringify-without-jsonify@^1.0.1:
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
json5@^1.0.1, json5@^1.0.2:
json5@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==
@@ -21326,11 +21250,6 @@ typescript@^5.3.3:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
ua-parser-js@^0.7.19:
version "0.7.37"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832"
integrity sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==
ua-parser-js@^0.7.33:
version "0.7.35"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.35.tgz#8bda4827be4f0b1dda91699a29499575a1f1d307"
@@ -21624,11 +21543,6 @@ utrie@^1.0.2:
dependencies:
base64-arraybuffer "^1.0.2"
uuid@^3.3.2:
version "3.4.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uuid@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b"
@@ -22381,14 +22295,6 @@ xml-name-validator@^4.0.0:
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835"
integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==
xml2js@0.4.23:
version "0.4.23"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==
dependencies:
sax ">=0.6.0"
xmlbuilder "~11.0.0"
xml2js@0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.0.tgz#07afc447a97d2bd6507a1f76eeadddb09f7a8282"