Compare commits

...

64 Commits

Author SHA1 Message Date
Eric Bailey 1d942e7615 WIP fonts 2025-04-23 18:20:07 -05:00
Samuel Newman 70dbc94766 Modernise change email flow (#8106)
* use new verify email dialog in 2fa flow

* alf change email flow

* Fallback change email dialog

* Update ChangeEmailDialog.tsx

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

* Update ChangeEmailDialog.tsx

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

* Update ChangeEmailDialog.tsx

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

* Update ChangeEmailDialog.tsx

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

* Update ChangeEmailDialog.tsx

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

* Update ChangeEmailDialog.tsx

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

* Update ChangeEmailDialog.tsx

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

* Update ChangeEmailDialog.tsx

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

* Update Email2FAToggle.tsx

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

* don't use existing email as default value

* increase max width of email dialogs

* Use ALF verify email dialog for reminder (#5924)

* use new verify email dialog for reminder

* style tweaks, improve web

* add a lil toast

* Apply suggestions from code review

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

* Ditch close and push up image

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>

* delete old change/verify email modals (#8122)

(cherry picked from commit fceb655b3b)

* Translate email placeholder

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

* Align copy

* Clean up error handling

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2025-04-23 11:22:08 -05:00
Samuel Newman 118d385b10 make handles lowercase (#8233) 2025-04-23 17:16:46 +03:00
Samuel Newman 54e5ea0aff rm vision-camera (#8273) 2025-04-23 17:14:41 +03:00
natalie 555dd6a4db Sanitize handle in header (#8272)
* Sanitize handle in header

* use prefix param correctly
2025-04-23 05:54:41 -07:00
pfrazee 1fe94ba290 Nightly source-language update 2025-04-23 02:37:50 +00:00
hailey e27134c7f1 bump 1.102.0 (#8271) 2025-04-22 11:54:17 -07:00
Samuel Newman b1550f9c02 fix first sheet height change not registering (#8267) 2025-04-22 11:49:20 -07:00
Samuel Newman dba0bd8379 move dev mode hook (#8270) 2025-04-22 11:13:13 -07:00
Mathieu Acthernoene a770f5635b Edge to edge support (#7497) 2025-04-22 19:16:50 +03:00
pfrazee 6e80b340c8 Nightly source-language update 2025-04-22 02:37:34 +00:00
Eric Bailey 0a7734f9ed Refactor search history (#8260) 2025-04-21 19:16:59 -05:00
Eric Bailey 8a602bf9f6 Tweak alignment (#8258) 2025-04-21 18:08:06 -05:00
Eric Bailey 3f47c8cf06 Improve display name in Settings (#8256) 2025-04-21 17:05:14 -05:00
Eric Bailey aa3ee8db4e Align handling (#8255) 2025-04-21 16:59:46 -05:00
Eric Bailey 8fdefb7e94 Add check to composer user autocomplete (#8253)
* Add check to composer autocomplete web

* Add check to composer autocomplete mobile
2025-04-21 16:32:29 -05:00
Eric Bailey 4aaf81fedd Show date in verifications dialog (#8257) 2025-04-21 15:41:15 -05:00
Eric Bailey b3c3ee5541 Tweak dialog copy (#8254) 2025-04-21 15:34:20 -05:00
Samuel Newman 13884b0a1c fix composer reply line height (#8241) 2025-04-21 15:29:46 -05:00
Eric Bailey 873ba16d2d Use normal spaces around check (#8252) 2025-04-21 14:58:15 -05:00
Eric Bailey d5dc065a83 Make whole verifier clickable, no nested interactives (#8249) 2025-04-21 12:08:05 -05:00
Eric Bailey 0278c7a0b4 Make notifications clickable (#8251) 2025-04-21 12:07:55 -05:00
Samuel Newman a091def512 rm other close (#8250) 2025-04-21 09:53:01 -07:00
Eric Bailey b800334ebc Center handle in verifier dialog (#8247)
* Center handle in verifier dialog

* do the same for the other dialog

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-04-21 11:28:35 -05:00
Samuel Newman 9363ff6f48 rm double close (#8246) 2025-04-21 11:00:38 -05:00
Paul Frazee 35daa5a343 Release 1.101.0 (#8229)
* Update tests

* Localization import from crowdin
2025-04-18 21:19:01 -07:00
surfdude29 03c50f07a1 change UK country code to GB in additional-moderation-authorities.ts (#8211) 2025-04-18 20:04:57 -07:00
pfrazee de7b63e67d Nightly source-language update 2025-04-19 02:19:33 +00:00
Eric Bailey 0ac15920a4 Verification (#8226)
* WIP

* Alignment with icon

* Add create/remove prompts

* Fill out check dialog a bit

* Reorg

* Handle was verified state

* Add warning to edit profile

* Add warning to handle dialog

* Decent alignment in posts on all platforms

* Refactor alignment for posts, chatlist, hover card

* Disable on profile

* Convo header

* Compute simple verification state

* Add other icon, rename, integrate

* Swap in simple state for profile edits

* Clean up utility hooks

* Add verifications UI to dialog

* Add edu nux

* Revert change

* Fix wrapping of check on profile

* Rename

* Fix gap under PostMeta

* Update check dialogs

* Handle takendown verifiers in check dialog

* alf composer reply to

* Refactor verification state

* Add create/remove mutations, non-functional for now

* Fix up post-rebase

* Add check to first author noty

* Do cache updates after mutations

* DRY up hook, add to profile updates too

* Add to drawer

* Update account list

* Adapt to new types

* Hook up mutations

* Use profile shadow in feeds

* Add to settings

* Shadow currentAccountProfile

* Add invalid state to verifications

* Fix alignment and overflow in Settings and Drawer

* Re-integrate post rebase

* Remove debug code

* Update copy

* Add unverified notification support

* Remove link

* Make sure dialog closes

* Update URL

* Add settings screen

* Integrate new setting into verification states

* Add metrics, bump package, fix bad import

* NUX fixes

* Update copy

* Fixes

* Update types

* fix search autocomplete

* fix lint

* add display name warning to new dialog

* update default prefs

* Add parsing support for notifications

* Bump pkg

* Tweak noty styles

* Adjust check alignment

* Tweak check alignment

* Fix badge for verifier

* Modify copy

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2025-04-18 19:15:32 -07:00
pfrazee f1e44ee12e Nightly source-language update 2025-04-18 02:36:32 +00:00
Samuel Newman cf6c8dc331 Clear image cache button (#8190) 2025-04-17 23:41:15 +03:00
Samuel Newman 719d7b7a57 Use SearchablePeopleList for add user to list dialog, replace old modal (#8212)
* move to dialogs dir

* make searchable people list more generic

* new list-add-remove-users dialog

* update header text

* fix header on android

* delete old modal

* reduce spacing on items
2025-04-17 11:11:46 -05:00
pfrazee 4f316538fb Nightly source-language update 2025-04-17 02:37:35 +00:00
Eric Bailey faab49bd9e ALF search cards (#8210) 2025-04-16 18:42:41 -05:00
Samuel Newman fb8ab9f26d ALF Notifications screen (#8208)
* alf notifs page

* Stop propagation

* Ok safari wants preventDefault too

* Fix lint

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-04-16 14:14:33 -05:00
pfrazee fa0b1bc1bb Nightly source-language update 2025-04-16 02:37:56 +00:00
Eric Bailey 16462b08a7 Standarize on shields (#8204) 2025-04-15 15:30:03 -05:00
Samuel Newman c5a22ffd97 [Embed] Fix 3-image grid on Safari (#8198)
* fix 3-image grid for safari

* match 4-grid aspect ratio to app
2025-04-15 07:33:36 -07:00
Eric Bailey f46336e341 Replace old ProfileCard with new (#8195)
* Replace usages of old ProfileCard

* Replace Pills with Labels component

* Replace impl of ProfileCardWithFollowButton

* Remove never-used LikesDialog

* Handle missing mod opts

* Add missing profile hover

* use modern button in listmembers

* remove follow button from muted accounts list

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-04-15 06:13:20 -07:00
pfrazee 32c2b69b84 Nightly source-language update 2025-04-15 02:38:00 +00:00
Eric Bailey 9ae0cf60af Don't re-use profile-specific components in Settings (#8193) 2025-04-14 17:18:06 -05:00
Samuel Newman 2ff5f2fb9b bump version (v1.101) (#8189) 2025-04-14 22:42:32 +03:00
Samuel Newman 62c4293adc Fix end of feed border thickness (#8187) 2025-04-14 10:15:47 -07:00
Samuel Newman ff7407c8e2 Use consistent hosting provider height (#8186) 2025-04-14 10:14:09 -07:00
Samuel Newman d899d09e60 [Embed] Fix up post embeds, and other tweaks (#8185)
* fix eslint

* Update .eslintrc.cjs

* fix 3 and 4 image aspect ratios

* round embeds more

* fix line height
2025-04-14 10:13:35 -07:00
Samuel Newman 2e73464fd1 change chat settings icon to a gear icon (#8192) 2025-04-14 10:13:09 -07:00
Samuel Newman f8bd850465 Fix labeler header scroll and loading/error states (#8088)
* add forwardRef to Layout.Content

* lift scrollview up out of inner component

* fix scrolling on android (#8188)
2025-04-14 11:00:39 -05:00
Eric Bailey 238b00d193 Configure moderation 2025-04-10 17:05:11 -05:00
Paul Frazee bf69672674 Update translations from crowdin (#8172) 2025-04-09 16:25:16 -07:00
Eric Bailey 5371114609 Add translate metrics (#8171)
* Add translate metrics

* Format
2025-04-09 15:00:40 -07:00
surfdude29 4c0213b3e9 update crowdin links to point to public page (#8100) 2025-04-09 12:28:06 -07:00
Eric Bailey 02a05d9948 Pass in Sentry vars to prod android build (#8170) 2025-04-09 11:29:37 -07:00
pfrazee 4c142a86b4 Nightly source-language update 2025-04-09 02:37:13 +00:00
Paul Frazee f3153b8acb Release 1.100 (#8168)
* Tests fix

* Move the hosting provider text in account creation into the inside of the button to improve translation handling (close #8166)

* Pull latest translations

* Revert the 'you are creating an account' change entirely; it doesnt work
2025-04-08 18:16:04 -07:00
Paul Frazee a59fb91a99 Increase suggested follows in the new user progress guide modal (#8162)
* Increase number of accounts suggested in the follow dialog progress guide

* Increase the bottom pad on the dialog flatlist component

* Format

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-04-08 18:16:13 -05:00
Eric Bailey c8568e3004 [Explore] Reduced experience (#8160)
* Only show suggested users for non-english users

* Fall back to searching for users for non-english speakers

* Disable other queries if full experience is disabled

* Bump package

* If no content langs, use full exp
2025-04-08 17:08:09 -05:00
Eric Bailey 09111ef272 Tweak performance settings on Android (#8158) 2025-04-08 11:20:03 -07:00
pfrazee b9ed94c7f4 Nightly source-language update 2025-04-08 02:36:28 +00:00
Eric Bailey 872fcd1d20 Update patches (#8151)
* Update patches after #8133

* Bump deps

* Revert @react-native-picker/picker downgrade

* Migrate RN patch
2025-04-07 18:29:41 -07:00
Eric Bailey d496b5bdbf Add in new suggested accounts endpoint into FollowDialog (#8147)
* Add in new suggested accounts endpoint into FollowDialog

* Fix lint

---------

Co-authored-by: Hailey <me@haileyok.com>
2025-04-07 18:29:04 -07:00
Eric Bailey 4879a07701 Rm sticky headers (#8154) 2025-04-07 17:41:20 -07:00
Eric Bailey 28876a4b23 Slice 'For You' results (#8152) 2025-04-07 17:40:31 -07:00
surfdude29 1e8a96e297 Align copy in Your interests toasts (#8138)
* update copy in toasts

* prettier
2025-04-07 18:42:29 -05:00
Eric Bailey 815a1e4c44 Center the selected tab in the sugg follows tabbar (#8146)
Co-authored-by: Hailey <me@haileyok.com>
2025-04-07 16:34:05 -07:00
212 changed files with 65657 additions and 46115 deletions
+1
View File
@@ -34,6 +34,7 @@ module.exports = {
'P',
'Admonition',
'Admonition.Admonition',
'Span',
],
impliedTextProps: [],
suggestedTextWrappers: {
+1 -1
View File
@@ -139,7 +139,7 @@ jobs:
- 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
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} 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
-1
View File
@@ -17,7 +17,6 @@ appId: xyz.blueskyweb.app
- inputText: "Post with an image"
- tapOn:
id: "openGalleryBtn"
- tapOn: "Allow Full Access"
- tapOn: "Content warnings"
- tapOn: "Porn"
- tapOn:
+1
View File
@@ -28,6 +28,7 @@ appId: xyz.blueskyweb.app
- hideKeyboard
- tapOn:
id: "nextBtn"
- tapOn: "Not now"
- inputText: "e2e-test"
- tapOn:
id: "nextBtn"
+1
View File
@@ -23,4 +23,5 @@ appId: xyz.blueskyweb.app
id: "loginPasswordInput"
- inputText: "hunter2"
- pressKey: Enter
- tapOn: "Not now"
- assertVisible: "Following"
+8 -16
View File
@@ -1,8 +1,6 @@
const pkg = require('./package.json')
const DARK_SPLASH_ANDROID_BACKGROUND = '#0f141b'
module.exports = function (config) {
module.exports = function (_config) {
/**
* App version number. Should be incremented as part of a release cycle.
*/
@@ -140,12 +138,10 @@ module.exports = function (config) {
},
androidStatusBar: {
barStyle: 'light-content',
backgroundColor: '#00000000',
},
// Dark nav bar in light mode is better than light nav bar in dark mode
androidNavigationBar: {
barStyle: 'light-content',
backgroundColor: DARK_SPLASH_ANDROID_BACKGROUND,
},
android: {
icon: './assets/app-icons/android_icon_default_light.png',
@@ -197,6 +193,10 @@ module.exports = function (config) {
plugins: [
'expo-video',
'expo-localization',
[
'react-native-edge-to-edge',
{android: {enforceNavigationBarContrast: false}},
],
USE_SENTRY && [
'@sentry/react-native/expo',
{
@@ -240,7 +240,7 @@ module.exports = function (config) {
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidStylesAccentColorPlugin.js',
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
'./plugins/withAndroidDayNightThemePlugin.js',
'./plugins/withAndroidNoJitpackPlugin.js',
'./plugins/withNoBundleCompression.js',
'./plugins/shareExtension/withShareExtensions.js',
@@ -259,6 +259,8 @@ module.exports = function (config) {
'./assets/fonts/inter/Inter-SemiBoldItalic.otf',
'./assets/fonts/inter/Inter-ExtraBold.otf',
'./assets/fonts/inter/Inter-ExtraBoldItalic.otf',
// Verification
'./assets/fonts/verification/Verification.ttf',
],
},
],
@@ -357,16 +359,6 @@ module.exports = function (config) {
},
],
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
[
'react-native-vision-camera',
{
enableLocation: false,
cameraPermissionText: 'Bluesky needs access to your camera.',
enableMicrophonePermission: true,
microphonePermissionText:
'Bluesky needs access to your microphone.',
},
],
].filter(Boolean),
extra: {
eas: {
Binary file not shown.
@@ -0,0 +1 @@
{ "Verification-verifiedCheck": 59905, "Verification-verifiedCheckGray": 59906, "Verification-verifierCheck": 59907, "Verification-verifierCheckGray": 59908}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M20.494 2.13a1 1 0 0 1 .375 1.364l-4.658 8.2.263.095c1.52.554 2.737 2.062 2.484 3.864-.336 2.393-1.358 4.12-3.245 6.047a1 1 0 0 1-1.102.222l-9.5-4a1 1 0 0 1-.166-1.754c1.458-.971 2.623-1.923 3.498-3.3 1.057-1.662 3.154-2.854 5.281-2.08l.58.212 4.827-8.494a1 1 0 0 1 1.363-.375ZM13.04 12.669c-.983-.358-2.19.142-2.91 1.273-.737 1.16-1.628 2.054-2.601 2.828l1.708.72a.2.2 0 0 0 .177-.011l2.13-1.218a.2.2 0 0 1 .29.237l-.551 1.653a.2.2 0 0 0 .112.247l3.353 1.413c1.359-1.494 1.994-2.76 2.23-4.435.094-.675-.359-1.405-1.188-1.707l-2.75-1ZM4.407 7.184a.5.5 0 0 1-.224.224l-1.29.645a.5.5 0 0 0 0 .894l1.29.645a.5.5 0 0 1 .224.224l.645 1.29a.5.5 0 0 0 .894 0l.645-1.29a.5.5 0 0 1 .224-.224l1.29-.645a.5.5 0 0 0 0-.894l-1.29-.645a.5.5 0 0 1-.224-.224l-.645-1.29a.5.5 0 0 0-.894 0l-.645 1.29ZM9.559 3.72a.36.36 0 0 0 .16-.16l.46-.921a.357.357 0 0 1 .64 0l.46.921q.054.106.16.16l.921.46a.357.357 0 0 1 0 .64l-.921.46a.36.36 0 0 0-.16.16l-.46.921a.357.357 0 0 1-.64 0l-.46-.921a.36.36 0 0 0-.16-.16l-.921-.46a.357.357 0 0 1 0-.64l.921-.46Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.633-3.274a1 1 0 0 1 .141 1.407l-4.5 5.5a1 1 0 0 1-1.481.074l-2-2a1 1 0 1 1 1.414-1.414l1.219 1.219 3.8-4.645a1 1 0 0 1 1.407-.141Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 384 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.325 2.054a1 1 0 0 0-.65 0l-8 2.75A1 1 0 0 0 3 5.75v6.162c0 2.807 1.149 4.83 2.813 6.405 1.572 1.488 3.632 2.6 5.555 3.636l.157.085a1 1 0 0 0 .95 0l.157-.085c1.923-1.037 3.983-2.148 5.556-3.636C19.85 16.742 21 14.719 21 11.912V5.75a1 1 0 0 0-.675-.946l-8-2.75ZM5 11.912V6.464l7-2.407 7 2.407v5.448c0 2.166-.851 3.687-2.188 4.952-1.276 1.209-2.964 2.158-4.812 3.157-1.848-1-3.536-1.948-4.813-3.157C5.851 15.6 5 14.078 5 11.912Zm10.207-1.205a1 1 0 0 0-1.414-1.414L11 12.086l-.793-.793a1 1 0 0 0-1.414 1.414l1.5 1.5a1 1 0 0 0 1.414 0l3.5-3.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 685 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 2a1 1 0 0 1 1 1c0 3.188.669 5.256 1.882 6.536C16.084 10.805 18.01 11.5 21 11.5a1 1 0 1 1 0 2c-2.99 0-4.916.695-6.118 1.964C13.67 16.744 13 18.812 13 22a1 1 0 1 1-2 0c0-3.188-.669-5.256-1.882-6.536C7.916 14.195 5.99 13.5 3 13.5a1 1 0 1 1 0-2c2.99 0 4.916-.695 6.118-1.964C10.33 8.256 11 6.188 11 3a1 1 0 0 1 1-1Zm0 6.734a7.6 7.6 0 0 1-1.43 2.178A7.3 7.3 0 0 1 8.349 12.5a7.3 7.3 0 0 1 2.22 1.588A7.6 7.6 0 0 1 12 16.267a7.6 7.6 0 0 1 1.43-2.179 7.3 7.3 0 0 1 2.221-1.588 7.3 7.3 0 0 1-2.22-1.588A7.6 7.6 0 0 1 12 8.734Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 665 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#208BFE"/><path fill="#fff" fill-rule="evenodd" d="M18.311 7.421a1.437 1.437 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L6.42 12.74a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 359 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#208BFE" d="M8.792 1.54a4.11 4.11 0 0 1 6.416 0 4.13 4.13 0 0 0 3.146 1.54c2.616.04 4.544 2.5 4 5.1a4.28 4.28 0 0 0 .777 3.462c1.6 2.104.912 5.17-1.427 6.36a4.21 4.21 0 0 0-2.177 2.774c-.62 2.584-3.408 3.948-5.781 2.83a4.1 4.1 0 0 0-3.492 0c-2.373 1.118-5.16-.246-5.78-2.83a4.21 4.21 0 0 0-2.178-2.775c-2.34-1.19-3.028-4.256-1.427-6.36a4.28 4.28 0 0 0 .777-3.46c-.544-2.602 1.384-5.06 4-5.1a4.13 4.13 0 0 0 3.146-1.54Z"/><path fill="#fff" fill-rule="evenodd" d="M17.659 8.399a1.36 1.36 0 0 1 0 1.925l-6.224 6.223a1.36 1.36 0 0 1-1.925 0L6.4 13.435a1.361 1.361 0 1 1 1.925-1.925l2.149 2.15 5.26-5.261a1.36 1.36 0 0 1 1.925 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

-21
View File
@@ -1,21 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "simple-import-sort"],
"extends": [
"eslint:recommended",
"preact",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"rules": {
"simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn",
'no-else-return': 'off'
},
"parserOptions": {
"sourceType": "module",
"ecmaVersion": "latest",
"project": "./bskyembed/tsconfig.json"
}
}
+22
View File
@@ -0,0 +1,22 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'simple-import-sort'],
extends: [
'eslint:recommended',
'preact',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
rules: {
'simple-import-sort/imports': 'warn',
'simple-import-sort/exports': 'warn',
'no-else-return': 'off',
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
}
+20 -18
View File
@@ -78,7 +78,7 @@ export function Embed({
return (
<Link
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-lg p-2 gap-1.5 w-full flex flex-col">
className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-xl p-2 gap-1.5 w-full flex flex-col">
<div className="flex gap-1.5 items-center">
<div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
<img
@@ -207,7 +207,7 @@ export function Embed({
function Info({children}: {children: ComponentChildren}) {
return (
<div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
<div className="w-full rounded-xl border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
<img src={infoIcon} className="w-4 h-4 shrink-0 mt-0.5" />
<p className="text-sm text-textLight dark:text-textDimmed">{children}</p>
</div>
@@ -231,12 +231,12 @@ function ImageEmbed({
<img
src={content.images[0].thumb}
alt={content.images[0].alt}
className="w-full rounded-lg overflow-hidden object-cover h-auto max-h-[1000px]"
className="w-full rounded-xl overflow-hidden object-cover h-auto max-h-[1000px]"
/>
)
case 2:
return (
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
{content.images.map((image, i) => (
<img
key={i}
@@ -249,19 +249,21 @@ function ImageEmbed({
)
case 3:
return (
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
<img
src={content.images[0].thumb}
alt={content.images[0].alt}
className="flex-[3] object-cover rounded-sm"
/>
<div className="flex flex-col gap-1 flex-[2]">
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
<div className="flex-1 aspect-square">
<img
src={content.images[0].thumb}
alt={content.images[0].alt}
className="w-full h-full object-cover rounded-sm"
/>
</div>
<div className="flex flex-col gap-1 flex-1">
{content.images.slice(1).map((image, i) => (
<img
key={i}
src={image.thumb}
alt={image.alt}
className="w-full h-full object-cover rounded-sm"
className="flex-1 object-cover rounded-sm min-h-0"
/>
))}
</div>
@@ -269,13 +271,13 @@ function ImageEmbed({
)
case 4:
return (
<div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
<div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden">
{content.images.map((image, i) => (
<img
key={i}
src={image.thumb}
alt={image.alt}
className="aspect-square w-full object-cover rounded-sm"
className="aspect-[3/2] w-full object-cover rounded-sm"
/>
))}
</div>
@@ -308,7 +310,7 @@ function ExternalEmbed({
return (
<Link
href={content.external.uri}
className="w-full rounded-lg overflow-hidden border dark:border-slate-600 flex flex-col items-stretch"
className="w-full rounded-xl overflow-hidden border dark:border-slate-600 flex flex-col items-stretch"
disableTracking>
{content.external.thumb && (
<img
@@ -345,7 +347,7 @@ function GenericWithImageEmbed({
return (
<Link
href={href}
className="w-full rounded-lg border dark:border-slate-600 py-2 px-3 flex flex-col gap-2">
className="w-full rounded-xl border dark:border-slate-600 py-2 px-3 flex flex-col gap-2">
<div className="flex gap-2.5 items-center">
{image ? (
<img
@@ -383,7 +385,7 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
return (
<div
className="w-full overflow-hidden rounded-lg aspect-square relative"
className="w-full overflow-hidden rounded-xl aspect-square relative"
style={{aspectRatio: `${aspectRatio} / 1`}}>
<img
src={content.thumbnail}
@@ -412,7 +414,7 @@ function StarterPackEmbed({
return (
<Link
href={starterPackHref}
className="w-full rounded-lg overflow-hidden border dark:border-slate-600 flex flex-col items-stretch">
className="w-full rounded-xl overflow-hidden border dark:border-slate-600 flex flex-col items-stretch">
<img src={imageUri} className="aspect-[1.91/1] object-cover" />
<div className="py-3 px-4">
<div className="flex space-x-2 items-center">
+1 -1
View File
@@ -173,7 +173,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
}
return (
<p className="min-[300px]:text-lg leading-6 break-word break-words whitespace-pre-wrap">
<p className="min-[300px]:text-lg leading-6 min-[300px]:leading-6 break-word break-words whitespace-pre-wrap">
{richText}
</p>
)
+1
View File
@@ -263,6 +263,7 @@ func serve(cctx *cli.Context) error {
e.GET("/moderation/modlists", server.WebGeneric)
e.GET("/moderation/muted-accounts", server.WebGeneric)
e.GET("/moderation/blocked-accounts", server.WebGeneric)
e.GET("/moderation/verification-settings", server.WebGeneric)
e.GET("/settings", server.WebGeneric)
e.GET("/settings/language", server.WebGeneric)
e.GET("/settings/app-passwords", server.WebGeneric)
+1
View File
@@ -13,6 +13,7 @@
<!-- Hello Humans! API docs at https://atproto.com -->
<link rel="preload" as="font" type="font/woff2" href="{{ staticCDNHost }}/static/media/InterVariable.c504db5c06caaf7cdfba.woff2" crossorigin>
<link rel="preload" as="font" type="font/ttf" href="/static/media/Verification.a57fd7e1809cc90127c3.ttf" crossorigin>
<style>
/**
+4 -4
View File
@@ -1,11 +1,11 @@
# Internationalization
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/u/projects/1) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
## Tools
- We use Crowdin to manage translations.
- Bluesky Crowdin: https://bluesky.crowdin.com/u/projects/1
- Bluesky Crowdin: https://bluesky.crowdin.com/bluesky-social
- Introduction to Crowdin: https://support.crowdin.com/for-translators/
- We use Lingui to implement translations. You can find the documentation [here](https://lingui.dev/).
@@ -15,7 +15,7 @@ Much of the app is translated by community contributions. (We <3 our translators
### Using Crowdin
[Crowdin](https://bluesky.crowdin.com/u/projects/1) is our primary tool for managing translations. There are two roles:
[Crowdin](https://bluesky.crowdin.com/bluesky-social) is our primary tool for managing translations. There are two roles:
- **Proof-readers**. Can create new translations and approve submitted translations.
- **Translators**. Can create new translations.
@@ -32,7 +32,7 @@ Please treat everyone with respect. Proof-readers are given final say on transla
### Adding a new language
You can request a new language be added to the project by clicking **Request New Language** on Crowdin or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
You can request a new language be added to the project by clicking **Request New Language** on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
Please only request a new language when you are certain you will be able to contribute a substantive portion of translations for the language.
+2 -1
View File
@@ -144,7 +144,8 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
func updateLayout() {
if self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier,
// Allow updates either when identifiers match OR when prevLayoutDetentIdentifier is nil (first real content update)
if (self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier || self.prevLayoutDetentIdentifier == nil),
let contentHeight = self.innerView?.subviews.first?.frame.size.height {
self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
preventExpansion: self.preventExpansion)
@@ -1,18 +1,21 @@
import * as React from 'react'
import {
Dimensions,
LayoutChangeEvent,
NativeSyntheticEvent,
type LayoutChangeEvent,
type NativeSyntheticEvent,
Platform,
StyleProp,
type StyleProp,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {isIOS} from '#/platform/detection'
import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
import {
type BottomSheetState,
type BottomSheetViewProps,
} from './BottomSheet.types'
import {BottomSheetPortalProvider} from './BottomSheetPortal'
import {Context as PortalContext} from './BottomSheetPortal'
@@ -1,4 +1,4 @@
import {BottomSheetViewProps} from './BottomSheet.types'
import {type BottomSheetViewProps} from './BottomSheet.types'
export function BottomSheetNativeComponent(_: BottomSheetViewProps) {
throw new Error('BottomSheetNativeComponent is not available on web')
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.100.0",
"version": "1.102.0",
"private": true,
"engines": {
"node": ">=20"
@@ -58,12 +58,13 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.14.20",
"@atproto/api": "^0.15.3",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.4.2",
"@expo/vector-icons": "^14.1.0",
"@expo/webpack-config": "^19.0.0",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
@@ -86,7 +87,7 @@
"@radix-ui/react-focus-scope": "^1.1.2",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-menu/menu": "^1.1.7",
"@react-native-picker/picker": "2.9.0",
"@react-native-picker/picker": "2.10.3",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/drawer": "^6.6.15",
"@react-navigation/native": "^6.1.17",
@@ -141,12 +142,10 @@
"expo-linking": "~7.0.5",
"expo-localization": "~16.0.1",
"expo-media-library": "~17.0.6",
"expo-navigation-bar": "~4.0.9",
"expo-notifications": "~0.29.14",
"expo-screen-orientation": "~8.0.4",
"expo-sharing": "~13.0.1",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.9",
"expo-task-manager": "~12.0.6",
"expo-updates": "~0.27.4",
@@ -178,12 +177,13 @@
"react-native-compressor": "1.10.3",
"react-native-date-picker": "^5.0.7",
"react-native-drawer-layout": "^4.1.1",
"react-native-edge-to-edge": "^1.6.0",
"react-native-emoji-popup": "^0.1.2",
"react-native-gesture-handler": "2.20.2",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "^0.41.6",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.14.5",
"react-native-keyboard-controller": "^1.17.1",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.5.1",
"react-native-picker-select": "^9.3.1",
@@ -197,8 +197,8 @@
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.2",
"react-native-vector-icons": "^10.2.0",
"react-native-view-shot": "^4.0.3",
"react-native-vision-camera": "^4.6.3",
"react-native-web": "~0.19.13",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.12.5",
@@ -223,8 +223,8 @@
"@lingui/cli": "^4.14.1",
"@lingui/macro": "^4.14.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native/eslint-config": "^0.76.2",
"@react-native/typescript-config": "^0.76.1",
"@react-native/eslint-config": "^0.76.9",
"@react-native/typescript-config": "^0.76.9",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^12.8.1",
@@ -273,7 +273,7 @@
},
"resolutions": {
"@expo/image-utils": "0.6.3",
"@react-native/babel-preset": "0.76.1",
"@react-native/babel-preset": "0.76.9",
"@react-native/normalize-colors": "0.76.1",
"@types/react": "^18",
"**/expo-constants": "17.0.3",
@@ -1,8 +1,8 @@
diff --git a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
index b928b8f..7175cf6 100644
index 64e6efe..e61485e 100644
--- a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
+++ b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
@@ -99,12 +99,12 @@ class MediaLibraryModule : Module() {
@@ -111,12 +111,12 @@ class MediaLibraryModule : Module() {
}
AsyncFunction("createAssetAsync") { localUri: String, promise: Promise ->
@@ -1,5 +1,5 @@
diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle
index 5dd0c61..bf536dd 100644
index 13bffbb..5ebbede 100644
--- a/node_modules/expo-notifications/android/build.gradle
+++ b/node_modules/expo-notifications/android/build.gradle
@@ -46,6 +46,7 @@ dependencies {
@@ -41,7 +41,7 @@ diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Scro
index 93af874..106f8ec 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
@@ -66,28 +66,51 @@ - (void)preserveContentOffsetWithBlock:(void (^)())block
@@ -66,28 +66,51 @@
* ScrollView, we force it to be centered, but when you zoom or the content otherwise
* becomes larger than the ScrollView, there is no padding around the content but it
* can still fill the whole view.
@@ -103,7 +103,7 @@ index 93af874..106f8ec 100644
- (BOOL)touchesShouldCancelInContentView:(UIView *)view
{
if ([_overridingDelegate respondsToSelector:@selector(touchesShouldCancelInContentView:)]) {
@@ -257,6 +280,10 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
@@ -257,6 +280,10 @@
}
}
@@ -131,7 +131,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 53bfd04..ff1b1ed 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -23,6 +23,7 @@ @implementation RCTRefreshControl {
@@ -23,6 +23,7 @@
UIColor *_titleColor;
CGFloat _progressViewOffset;
BOOL _hasMovedToWindow;
@@ -139,7 +139,7 @@ index 53bfd04..ff1b1ed 100644
}
- (instancetype)init
@@ -58,6 +59,12 @@ - (void)layoutSubviews
@@ -58,6 +59,12 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
_isInitialRender = false;
}
@@ -152,7 +152,7 @@ index 53bfd04..ff1b1ed 100644
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
@@ -221,4 +228,50 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
}
}
@@ -207,7 +207,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 40aaf9c..1c60164 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -22,11 +22,12 @@ - (UIView *)view
@@ -22,11 +22,12 @@ RCT_EXPORT_MODULE()
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
@@ -222,10 +222,10 @@ index 40aaf9c..1c60164 100644
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
index e9ce48c..84a6fca 100644
index 6f41b5c..9b4f77f 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
@@ -159,26 +159,8 @@ - (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view
@@ -159,26 +159,8 @@
return !shouldDisableScrollInteraction;
}
@@ -252,7 +252,7 @@ index e9ce48c..84a6fca 100644
super.contentOffset = CGPointMake(
RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
@@ -427,6 +409,11 @@ - (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
@@ -433,6 +415,11 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
// Does nothing
}
@@ -264,7 +264,7 @@ index e9ce48c..84a6fca 100644
- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
{
[super insertReactSubview:view atIndex:atIndex];
@@ -443,6 +430,8 @@ - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
@@ -449,6 +436,8 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
_contentView = view;
RCTApplyTransformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
[_scrollView addSubview:view];
@@ -273,7 +273,7 @@ index e9ce48c..84a6fca 100644
}
}
@@ -652,9 +641,46 @@ -(void)delegateMethod : (UIScrollView *)scrollView \
@@ -658,9 +647,46 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
}
RCT_SCROLL_EVENT_HANDLER(scrollViewWillBeginDecelerating, onMomentumScrollBegin)
@@ -321,7 +321,7 @@ index e9ce48c..84a6fca 100644
- (void)addScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
{
[_scrollListeners addObject:scrollListener];
@@ -933,6 +959,7 @@ - (void)updateContentSizeIfNeeded
@@ -939,6 +965,7 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
CGSize contentSize = self.contentSize;
if (!CGSizeEqualToSize(_scrollView.contentSize, contentSize)) {
_scrollView.contentSize = contentSize;
@@ -329,7 +329,7 @@ index e9ce48c..84a6fca 100644
}
}
@@ -1055,6 +1082,22 @@ -(type)getter \
@@ -1061,6 +1088,22 @@ RCT_SET_AND_PRESERVE_OFFSET(setShowsHorizontalScrollIndicator, showsHorizontalSc
RCT_SET_AND_PRESERVE_OFFSET(setShowsVerticalScrollIndicator, showsVerticalScrollIndicator, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setZoomScale, zoomScale, CGFloat);
@@ -356,7 +356,7 @@ diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManag
index cd1e7eb..c1d0172 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
@@ -83,6 +83,7 @@ - (UIView *)view
@@ -83,6 +83,7 @@ RCT_EXPORT_VIEW_PROPERTY(showsVerticalScrollIndicator, BOOL)
RCT_EXPORT_VIEW_PROPERTY(scrollEventThrottle, NSTimeInterval)
RCT_EXPORT_VIEW_PROPERTY(zoomScale, CGFloat)
RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets)
+27
View File
@@ -0,0 +1,27 @@
// Based on https://github.com/expo/expo/pull/33957
// Could be removed once the app has been updated to Expo 53
const {withAndroidStyles} = require('@expo/config-plugins')
module.exports = function withAndroidDayNightThemePlugin(appConfig) {
const cleanupList = new Set([
'colorPrimary',
'android:editTextBackground',
'android:textColor',
'android:editTextStyle',
])
return withAndroidStyles(appConfig, config => {
config.modResults.resources.style = config.modResults.resources.style
?.map(style => {
if (style.$.name === 'AppTheme' && style.item != null) {
style.item = style.item.filter(item => !cleanupList.has(item.$.name))
}
return style
})
.filter(style => {
return style.$.name !== 'ResetEditText'
})
return config
})
}
@@ -1,28 +0,0 @@
const {withStringsXml, AndroidConfig} = require('@expo/config-plugins')
module.exports = function withAndroidSplashScreenStatusBarTranslucentPlugin(
appConfig,
) {
return withStringsXml(appConfig, function (decoratedAppConfig) {
try {
decoratedAppConfig.modResults = AndroidConfig.Strings.setStringItem(
[
{
_: 'true',
$: {
name: 'expo_splash_screen_status_bar_translucent',
translatable: 'false',
},
},
],
decoratedAppConfig.modResults,
)
} catch (e) {
console.error(
`withAndroidSplashScreenStatusBarTranslucentPlugin failed`,
e,
)
}
return decoratedAppConfig
})
}
@@ -12,7 +12,7 @@ module.exports = function withAndroidStylesAccentColorPlugin(appConfig) {
decoratedAppConfig.modResults,
{
add: true,
parent: AndroidConfig.Styles.getAppThemeLightNoActionBarGroup(),
parent: AndroidConfig.Styles.getAppThemeGroup(),
name: 'colorAccent',
value: '@color/colorPrimary',
},
+2 -5
View File
@@ -46,14 +46,13 @@ import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
type SessionAccount,
useSession,
useSessionApi,
} from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LightStatusBarProvider} from '#/state/shell/light-status-bar'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
@@ -219,9 +218,7 @@ function App() {
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<LightStatusBarProvider>
<InnerApp />
</LightStatusBarProvider>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
+5 -5
View File
@@ -35,14 +35,13 @@ import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
type SessionAccount,
useSession,
useSessionApi,
} from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LightStatusBarProvider} from '#/state/shell/light-status-bar'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
@@ -62,6 +61,7 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {loadVerificationCheckFonts} from '#/components/verification/VerificationCheck'
/**
* Begin geolocation ASAP
@@ -75,6 +75,7 @@ function InnerApp() {
const theme = useColorModeTheme()
const {_} = useLingui()
const hasCheckedReferrer = useStarterPackEntry()
const [verificationChecksFontLoaded] = loadVerificationCheckFonts()
// init
useEffect(() => {
@@ -104,6 +105,7 @@ function InnerApp() {
// wait for session to resume
if (!isReady || !hasCheckedReferrer) return null
if (!verificationChecksFontLoaded) return null
return (
<Alf theme={theme}>
@@ -193,9 +195,7 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<LightStatusBarProvider>
<InnerApp />
</LightStatusBarProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
+47 -34
View File
@@ -33,7 +33,6 @@ import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
import {bskyTitle} from '#/lib/strings/headings'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {
@@ -70,6 +69,7 @@ import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
@@ -79,33 +79,35 @@ import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed'
import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers'
import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {ProfileSearchScreen} from '#/screens/Profile/ProfileSearch'
import {SearchScreen} from '#/screens/Search'
import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings'
import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings'
import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
import {SettingsScreen} from '#/screens/Settings/Settings'
import {SettingsInterests} from '#/screens/Settings/SettingsInterests'
import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
import {
StarterPackScreen,
StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard'
import TopicScreen from '#/screens/Topic'
import {VideoFeed} from '#/screens/VideoFeed'
import {useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {ProfileSearchScreen} from './screens/Profile/ProfileSearch'
import {AboutSettingsScreen} from './screens/Settings/AboutSettings'
import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings'
import {AccountSettingsScreen} from './screens/Settings/AccountSettings'
import {AppPasswordsScreen} from './screens/Settings/AppPasswords'
import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings'
import {ExternalMediaPreferencesScreen} from './screens/Settings/ExternalMediaPreferences'
import {FollowingFeedPreferencesScreen} from './screens/Settings/FollowingFeedPreferences'
import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings'
import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings'
import {SettingsScreen} from './screens/Settings/Settings'
import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences'
import TopicScreen from './screens/Topic'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -167,6 +169,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="ModerationVerificationSettings"
getComponent={() => ModerationVerificationSettings}
options={{
title: title(msg`Verification Settings`),
requireAuth: true,
}}
/>
<Stack.Screen
name="Settings"
getComponent={() => SettingsScreen}
@@ -727,36 +737,39 @@ const LINKING = {
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
const {currentAccount} = useSession()
const {openModal} = useModalControls()
const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
const verifyEmailDialogControl = useDialogControl()
function onReady() {
prevLoggedRouteName.current = getCurrentRouteName()
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
openModal({name: 'verify-email', showReminder: true})
verifyEmailDialogControl.open()
snoozeEmailConfirmationPrompt()
}
}
return (
<NavigationContainer
ref={navigationRef}
linking={LINKING}
theme={theme}
onStateChange={() => {
logger.metric('router:navigate', {
from: prevLoggedRouteName.current,
})
prevLoggedRouteName.current = getCurrentRouteName()
}}
onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime()
onReady()
logger.metric('router:navigate', {})
}}>
{children}
</NavigationContainer>
<>
<NavigationContainer
ref={navigationRef}
linking={LINKING}
theme={theme}
onStateChange={() => {
logger.metric('router:navigate', {
from: prevLoggedRouteName.current,
})
prevLoggedRouteName.current = getCurrentRouteName()
}}
onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime()
onReady()
logger.metric('router:navigate', {})
}}>
{children}
</NavigationContainer>
<VerifyEmailDialog control={verifyEmailDialogControl} reminder />
</>
)
}
-21
View File
@@ -1,21 +0,0 @@
import * as NavigationBar from 'expo-navigation-bar'
import * as SystemUI from 'expo-system-ui'
import {isAndroid} from '#/platform/detection'
import {Theme} from '../types'
export function setNavigationBar(themeType: 'theme' | 'lightbox', t: Theme) {
if (isAndroid) {
if (themeType === 'theme') {
NavigationBar.setBackgroundColorAsync(t.atoms.bg.backgroundColor)
NavigationBar.setBorderColorAsync(t.atoms.bg.backgroundColor)
NavigationBar.setButtonStyleAsync(t.name !== 'light' ? 'light' : 'dark')
SystemUI.setBackgroundColorAsync(t.atoms.bg.backgroundColor)
} else {
NavigationBar.setBackgroundColorAsync('black')
NavigationBar.setBorderColorAsync('black')
NavigationBar.setButtonStyleAsync('light')
SystemUI.setBackgroundColorAsync('black')
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import * as SystemUI from 'expo-system-ui'
import {isAndroid} from '#/platform/detection'
import {Theme} from '../types'
export function setSystemUITheme(themeType: 'theme' | 'lightbox', t: Theme) {
if (isAndroid) {
if (themeType === 'theme') {
SystemUI.setBackgroundColorAsync(t.atoms.bg.backgroundColor)
} else {
SystemUI.setBackgroundColorAsync('black')
}
}
}
+42 -25
View File
@@ -1,6 +1,6 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -12,6 +12,8 @@ import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {Button} from './Button'
import {Text} from './Typography'
@@ -74,11 +76,13 @@ export function AccountList({
]}>
<Text
style={[
a.align_baseline,
a.font_bold,
a.flex_1,
a.flex_row,
a.py_sm,
{paddingLeft: 48},
a.leading_tight,
t.atoms.text_contrast_medium,
{paddingLeft: 56},
]}>
{otherLabel ?? <Trans>Other account</Trans>}
</Text>
@@ -105,6 +109,7 @@ function AccountItem({
}) {
const t = useTheme()
const {_} = useLingui()
const verification = useSimpleVerificationState({profile})
const onPress = useCallback(() => {
onSelect(account)
@@ -114,7 +119,7 @@ function AccountItem({
<Button
testID={`chooseAccountBtn-${account.handle}`}
key={account.did}
style={[a.flex_1]}
style={[a.w_full]}
onPress={onPress}
label={
isCurrentAccount
@@ -127,33 +132,45 @@ function AccountItem({
a.flex_1,
a.flex_row,
a.align_center,
{height: 48},
a.px_md,
a.gap_sm,
{height: 56},
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
]}>
<View style={a.p_md}>
<UserAvatar
avatar={profile?.avatar}
size={24}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
<Text emoji style={[a.font_bold]}>
{sanitizeDisplayName(
profile?.displayName || profile?.handle || account.handle,
<UserAvatar
avatar={profile?.avatar}
size={36}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={[a.flex_1, a.gap_2xs, a.pr_2xl]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text
emoji
style={[a.font_bold, a.leading_tight]}
numberOfLines={1}>
{sanitizeDisplayName(
profile?.displayName || profile?.handle || account.handle,
)}
</Text>
{verification.showBadge && (
<View>
<VerificationCheck
width={12}
verifier={verification.role === 'verifier'}
/>
</View>
)}
</Text>{' '}
<Text emoji style={[t.atoms.text_contrast_medium]}>
{sanitizeHandle(account.handle)}
</View>
<Text style={[a.leading_tight, t.atoms.text_contrast_medium]}>
{sanitizeHandle(account.handle, '@')}
</Text>
</Text>
</View>
{isCurrentAccount ? (
<Check
size="sm"
style={[{color: t.palette.positive_600}, a.mr_md]}
/>
<Check size="sm" style={[{color: t.palette.positive_600}]} />
) : (
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
<Chevron size="sm" style={[t.atoms.text]} />
)}
</View>
)}
+1 -1
View File
@@ -556,7 +556,7 @@ export function Outer({
// pure vibes based
const TOP_INSET = insets.top + 80
const BOTTOM_INSET_IOS = insets.bottom + 20
const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn
const BOTTOM_INSET_ANDROID = insets.bottom + 12
const {height} = evt.nativeEvent.layout
const topPosition =
+14 -14
View File
@@ -1,20 +1,20 @@
import React, {useImperativeHandle} from 'react'
import {
NativeScrollEvent,
NativeSyntheticEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
ScrollView,
StyleProp,
type ScrollView,
type StyleProp,
TextInput,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {
KeyboardAwareScrollView,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {runOnJS} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -25,22 +25,22 @@ import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
import {atoms as a, tokens, useTheme} from '#/alf'
import {useThemeName} from '#/alf/util/useColorModeTheme'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {
DialogControlProps,
DialogInnerProps,
DialogOuterProps,
type DialogControlProps,
type DialogInnerProps,
type DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
import {
BottomSheetSnapPointChangeEvent,
BottomSheetStateChangeEvent,
type BottomSheetSnapPointChangeEvent,
type BottomSheetStateChangeEvent,
} from '../../../modules/bottom-sheet/src/BottomSheet.types'
import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
import {type BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/shared'
@@ -299,7 +299,7 @@ export const InnerFlatList = React.forwardRef<
keyboardShouldPersistTaps="handled"
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
<View style={{height: insets.bottom + a.pt_5xl.paddingTop + 50}} />
}
ref={ref}
{...props}
+14 -9
View File
@@ -1,20 +1,25 @@
import {useCallback} from 'react'
import {SystemBars} from 'react-native-edge-to-edge'
import {useDialogStateControlContext} from '#/state/dialogs'
import {isIOS} from '#/platform/detection'
/**
* If we're calling a system API like the image picker that opens a sheet
* wrap it in this function to make sure the status bar is the correct color.
*/
export function useSheetWrapper() {
const {setFullyExpandedCount} = useDialogStateControlContext()
return useCallback(
async <T>(promise: Promise<T>): Promise<T> => {
setFullyExpandedCount(c => c + 1)
return useCallback(async <T>(promise: Promise<T>): Promise<T> => {
if (isIOS) {
const entry = SystemBars.pushStackEntry({
style: {
statusBar: 'light',
},
})
const res = await promise
setFullyExpandedCount(c => c - 1)
SystemBars.popStackEntry(entry)
return res
},
[setFullyExpandedCount],
)
} else {
return await promise
}
}, [])
}
+57 -51
View File
@@ -1,12 +1,12 @@
import React, {useContext, useMemo} from 'react'
import {StyleSheet, View, ViewProps, ViewStyle} from 'react-native'
import {StyleProp} from 'react-native'
import {forwardRef, memo, useContext, useMemo} from 'react'
import {StyleSheet, View, type ViewProps, type ViewStyle} from 'react-native'
import {type StyleProp} from 'react-native'
import {
KeyboardAwareScrollView,
KeyboardAwareScrollViewProps,
type KeyboardAwareScrollViewProps,
} from 'react-native-keyboard-controller'
import Animated, {
AnimatedScrollViewProps,
type AnimatedScrollViewProps,
useAnimatedProps,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
@@ -35,7 +35,7 @@ export type ScreenProps = React.ComponentProps<typeof View> & {
/**
* Outermost component of every screen
*/
export const Screen = React.memo(function Screen({
export const Screen = memo(function Screen({
style,
noInsetTop,
...props
@@ -61,49 +61,55 @@ export type ContentProps = AnimatedScrollViewProps & {
/**
* Default scroll view for simple pages
*/
export const Content = React.memo(function Content({
children,
style,
contentContainerStyle,
ignoreTabletLayoutOffset,
...props
}: ContentProps) {
const t = useTheme()
const {footerHeight} = useShellLayout()
const animatedProps = useAnimatedProps(() => {
return {
scrollIndicatorInsets: {
bottom: footerHeight.get(),
top: 0,
right: 1,
},
} satisfies AnimatedScrollViewProps
})
export const Content = memo(
forwardRef<Animated.ScrollView, ContentProps>(function Content(
{
children,
style,
contentContainerStyle,
ignoreTabletLayoutOffset,
...props
},
ref,
) {
const t = useTheme()
const {footerHeight} = useShellLayout()
const animatedProps = useAnimatedProps(() => {
return {
scrollIndicatorInsets: {
bottom: footerHeight.get(),
top: 0,
right: 1,
},
} satisfies AnimatedScrollViewProps
})
return (
<Animated.ScrollView
id="content"
automaticallyAdjustsScrollIndicatorInsets={false}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
// sets the scroll inset to the height of the footer
animatedProps={animatedProps}
style={[scrollViewStyles.common, style]}
contentContainerStyle={[
scrollViewStyles.contentContainer,
contentContainerStyle,
]}
{...props}>
{isWeb ? (
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
{/* @ts-expect-error web only -esb */}
{children}
</Center>
) : (
children
)}
</Animated.ScrollView>
)
})
return (
<Animated.ScrollView
ref={ref}
id="content"
automaticallyAdjustsScrollIndicatorInsets={false}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
// sets the scroll inset to the height of the footer
animatedProps={animatedProps}
style={[scrollViewStyles.common, style]}
contentContainerStyle={[
scrollViewStyles.contentContainer,
contentContainerStyle,
]}
{...props}>
{isWeb ? (
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
{/* @ts-expect-error web only -esb */}
{children}
</Center>
) : (
children
)}
</Animated.ScrollView>
)
}),
)
const scrollViewStyles = StyleSheet.create({
common: {
@@ -124,7 +130,7 @@ export type KeyboardAwareContentProps = KeyboardAwareScrollViewProps & {
*
* BE SURE TO TEST THIS WHEN USING, it's untested as of writing this comment.
*/
export const KeyboardAwareContent = React.memo(function LayoutScrollView({
export const KeyboardAwareContent = memo(function LayoutKeyboardAwareContent({
children,
style,
contentContainerStyle,
@@ -147,7 +153,7 @@ export const KeyboardAwareContent = React.memo(function LayoutScrollView({
/**
* Utility component to center content within the screen
*/
export const Center = React.memo(function LayoutContent({
export const Center = memo(function LayoutCenter({
children,
style,
ignoreTabletLayoutOffset,
@@ -192,7 +198,7 @@ export const Center = React.memo(function LayoutContent({
/**
* Only used within `Layout.Screen`, not for reuse
*/
const WebCenterBorders = React.memo(function LayoutContent() {
const WebCenterBorders = memo(function LayoutWebCenterBorders() {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {centerColumnOffset} = useLayoutBreakpoints()
-129
View File
@@ -1,129 +0,0 @@
import {useCallback, useMemo} from 'react'
import {ActivityIndicator, FlatList, View} from 'react-native'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
interface LikesDialogProps {
control: Dialog.DialogOuterProps['control']
uri: string
}
export function LikesDialog(props: LikesDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LikesDialogInner {...props} />
</Dialog.Outer>
)
}
export function LikesDialogInner({control, uri}: LikesDialogProps) {
const {_} = useLingui()
const t = useTheme()
const {
data: resolvedUri,
error: resolveError,
isFetched: hasFetchedResolvedUri,
} = useResolveUriQuery(uri)
const {
data,
isFetching: isFetchingLikedBy,
isFetched: hasFetchedLikedBy,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isError,
error: likedByError,
} = useLikedByQuery(resolvedUri?.uri)
const isLoading = !hasFetchedResolvedUri || !hasFetchedLikedBy
const likes = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.likes)
}
return []
}, [data])
const onEndReached = useCallback(async () => {
if (isFetchingLikedBy || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more likes', {message: err})
}
}, [isFetchingLikedBy, hasNextPage, isError, fetchNextPage])
const renderItem = useCallback(
({item}: {item: GetLikes.Like}) => {
return (
<ProfileCardWithFollowBtn
key={item.actor.did}
profile={item.actor}
onPress={() => control.close()}
/>
)
},
[control],
)
return (
<Dialog.Inner label={_(msg`Users that have liked this content or profile`)}>
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_lg]}>
<Trans>Liked by</Trans>
</Text>
{isLoading ? (
<View style={{minHeight: 300}}>
<Loader size="xl" />
</View>
) : resolveError || likedByError || !data ? (
<ErrorMessage message={cleanError(resolveError || likedByError)} />
) : likes.length === 0 ? (
<View style={[t.atoms.bg_contrast_50, a.px_md, a.py_xl, a.rounded_md]}>
<Text style={[a.text_center]}>
<Trans>
Nobody has liked this yet. Maybe you should be the first!
</Trans>
</Text>
</View>
) : (
<FlatList
data={likes}
keyExtractor={item => item.actor.did}
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
ListFooterComponent={
<ListFooterComponent isFetching={isFetchingNextPage} />
}
/>
)}
<Dialog.Close />
</Dialog.Inner>
)
}
function ListFooterComponent({isFetching}: {isFetching: boolean}) {
if (isFetching) {
return (
<View style={a.pt_lg}>
<ActivityIndicator />
</View>
)
}
return null
}
+11 -7
View File
@@ -1,12 +1,12 @@
import React from 'react'
import {GestureResponderEvent} from 'react-native'
import {type GestureResponderEvent} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions, useLinkProps} from '@react-navigation/native'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {AllNavigatorParams} from '#/lib/routes/types'
import {type AllNavigatorParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {
convertBskyAppUrlIfNeeded,
@@ -16,10 +16,10 @@ import {
} from '#/lib/strings/url-helpers'
import {isNative, isWeb} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
import {Button, type ButtonProps} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text, TextProps} from '#/components/Typography'
import {Text, type TextProps} from '#/components/Typography'
import {router} from '#/routes'
/**
@@ -210,7 +210,9 @@ export function useLink({
}
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
Omit<ButtonProps, 'onPress' | 'disabled'>
Omit<ButtonProps, 'onPress' | 'disabled'> & {
overridePresentation?: boolean
}
/**
* A interactive element that renders as a `<a>` tag on the web. On mobile it
@@ -228,6 +230,7 @@ export function Link({
onLongPress: outerOnLongPress,
download,
shouldProxy,
overridePresentation,
...rest
}: LinkProps) {
const {href, isExternal, onPress, onLongPress} = useLink({
@@ -237,6 +240,7 @@ export function Link({
onPress: outerOnPress,
onLongPress: outerOnLongPress,
shouldProxy: shouldProxy,
overridePresentation,
})
return (
@@ -267,7 +271,7 @@ export function Link({
export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps &
TextStyleProp &
Pick<TextProps, 'selectable' | 'numberOfLines'> &
Pick<TextProps, 'selectable' | 'numberOfLines' | 'emoji'> &
Pick<ButtonProps, 'label' | 'accessibilityHint'> & {
disableUnderline?: boolean
title?: TextProps['title']
+90 -22
View File
@@ -8,15 +8,15 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {getModerationCauseKey} from '#/lib/moderation'
import {type LogEvents} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {ProfileCardPills} from '#/view/com/profile/ProfileCard'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {
Button,
@@ -27,21 +27,26 @@ import {
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import * as Pills from '#/components/Pills'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import type * as bsky from '#/types/bsky'
export function Default({
profile,
moderationOpts,
logContext = 'ProfileCard',
testID,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
testID?: string
}) {
return (
<Link profile={profile}>
<Link testID={testID} profile={profile}>
<Card
profile={profile}
moderationOpts={moderationOpts}
@@ -60,8 +65,6 @@ export function Card({
moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
}) {
const moderation = moderateProfile(profile, moderationOpts)
return (
<Outer>
<Header>
@@ -74,10 +77,7 @@ export function Card({
/>
</Header>
<ProfileCardPills
followedBy={Boolean(profile.viewer?.followedBy)}
moderation={moderation}
/>
<Labels profile={profile} moderationOpts={moderationOpts} />
<Description profile={profile} />
</Outer>
@@ -87,7 +87,7 @@ export function Card({
export function Outer({
children,
}: {
children: React.ReactElement | React.ReactElement[]
children: React.ReactNode | React.ReactNode[]
}) {
return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
}
@@ -95,7 +95,7 @@ export function Outer({
export function Header({
children,
}: {
children: React.ReactElement | React.ReactElement[]
children: React.ReactNode | React.ReactNode[]
}) {
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
}
@@ -130,19 +130,30 @@ export function Link({
export function Avatar({
profile,
moderationOpts,
onPress,
disabledPreview,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
onPress?: () => void
disabledPreview?: boolean
}) {
const moderation = moderateProfile(profile, moderationOpts)
return (
return disabledPreview ? (
<UserAvatar
size={40}
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
moderation={moderation.ui('avatar')}
/>
) : (
<PreviewableUserAvatar
size={40}
profile={profile}
moderation={moderation.ui('avatar')}
onBeforePress={onPress}
/>
)
}
@@ -169,32 +180,61 @@ export function NameAndHandle({
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const t = useTheme()
return (
<View style={[a.flex_1]}>
<Name profile={profile} moderationOpts={moderationOpts} />
<Handle profile={profile} />
</View>
)
}
export function Name({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const moderation = moderateProfile(profile, moderationOpts)
const name = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const handle = sanitizeHandle(profile.handle, '@')
const verification = useSimpleVerificationState({profile})
return (
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{name}
</Text>
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{handle}
</Text>
{verification.showBadge && (
<View style={[a.pl_xs]}>
<VerificationCheck
width={14}
verifier={verification.role === 'verifier'}
/>
</View>
)}
</View>
)
}
export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) {
const t = useTheme()
const handle = sanitizeHandle(profile.handle, '@')
return (
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{handle}
</Text>
)
}
export function NameAndHandlePlaceholder() {
const t = useTheme()
@@ -415,3 +455,31 @@ export function FollowButtonInner({
</View>
)
}
export function Labels({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const moderation = moderateProfile(profile, moderationOpts)
const modui = moderation.ui('profileList')
const followedBy = profile.viewer?.followedBy
if (!followedBy && !modui.inform && !modui.alert) {
return null
}
return (
<Pills.Row style={[a.pt_xs]}>
{followedBy && <Pills.FollowsYou />}
{modui.alerts.map(alert => (
<Pills.Label key={getModerationCauseKey(alert)} cause={alert} />
))}
{modui.informs.map(inform => (
<Pills.Label key={getModerationCauseKey(inform)} cause={inform} />
))}
</Pills.Row>
)
}
+32 -8
View File
@@ -1,6 +1,10 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {
type AppBskyActorDefs,
moderateProfile,
type ModerationOpts,
} from '@atproto/api'
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -33,7 +37,9 @@ import * as Pills from '#/components/Pills'
import {Portal} from '#/components/Portal'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {ProfileHoverCardProps} from './types'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {type ProfileHoverCardProps} from './types'
const floatingMiddlewares = [
offset(4),
@@ -412,6 +418,7 @@ function Inner({
[currentAccount, profile],
)
const isLabeler = profile.associated?.labeler
const verification = useSimpleVerificationState({profile})
return (
<View>
@@ -465,13 +472,30 @@ function Inner({
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
<View style={[a.pb_sm, a.flex_1]}>
<Text
style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold, a.self_start]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
<View style={[a.flex_row, a.align_center, a.pt_md, a.pb_xs]}>
<Text
numberOfLines={1}
style={[a.text_lg, a.font_bold, a.self_start]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
{verification.showBadge && (
<View
style={[
a.pl_xs,
{
marginTop: -2,
},
]}>
<VerificationCheck
width={16}
verifier={verification.role === 'verifier'}
/>
</View>
)}
</Text>
</View>
<ProfileHeaderHandle profile={profileShadow} disableTaps />
</View>
+100 -174
View File
@@ -7,24 +7,17 @@ import {
View,
type ViewStyle,
} from 'react-native'
import Animated, {
LayoutAnimationConfig,
LinearTransition,
ZoomInEasyDown,
} from 'react-native-reanimated'
import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {useSession} from '#/state/session'
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {type ListMethods} from '#/view/com/util/List'
@@ -49,15 +42,14 @@ import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/componen
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {ListFooter} from '../Lists'
import type * as bsky from '#/types/bsky'
import {ProgressGuideTask} from './Task'
type Item =
| {
type: 'profile'
key: string
profile: AppBskyActorDefs.ProfileView
isSuggestion: boolean
profile: bsky.profile.AnyProfileView
}
| {
type: 'empty'
@@ -128,94 +120,39 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
const inputRef = useRef<TextInput>(null)
const [headerHeight, setHeaderHeight] = useState(0)
const {currentAccount} = useSession()
const [suggestedAccounts, setSuggestedAccounts] = useState<
Map<string, AppBskyActorDefs.ProfileView[]>
>(() => new Map())
useEffect(() => {
lastSearchText = searchText
lastSelectedInterest = selectedInterest
}, [searchText, selectedInterest])
const query = searchText || selectedInterest
const {
data: suggestions,
isFetching: isFetchingSuggestions,
error: suggestionsError,
} = useGetSuggestedUsersQuery({
category: selectedInterest,
limit: 50,
})
const {
data: searchResults,
isFetching,
error,
isError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
isFetching: isFetchingSearchResults,
error: searchResultsError,
isError: isSearchResultsError,
} = useActorSearchPaginated({
query,
enabled: !!searchText,
query: searchText,
})
const hasSearchText = !!searchText
const resultsKey = searchText || selectedInterest
const items = useMemo(() => {
const results = searchResults?.pages.flatMap(r => r.actors)
const results = hasSearchText
? searchResults?.pages.flatMap(p => p.actors)
: suggestions?.actors
let _items: Item[] = []
const seen = new Set<string>()
if (isError) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else if (results) {
// First pass: search results
for (const profile of results) {
if (profile.did === currentAccount?.did) continue
if (profile.viewer?.following) continue
// my sincere apologies to Jake Gold - your bio is too keyword-filled and
// your page-rank too high, so you're at the top of half the categories -sfn
if (
!hasSearchText &&
profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' &&
// constrain to 'tech'
selectedInterest !== 'tech'
) {
continue
}
seen.add(profile.did)
_items.push({
type: 'profile',
// Don't share identity across tabs or typing attempts
key: query + ':' + profile.did,
profile,
isSuggestion: false,
})
}
// Second pass: suggestions
_items = _items.flatMap(item => {
if (item.type !== 'profile') {
return item
}
const suggestions = suggestedAccounts.get(item.profile.did)
if (!suggestions) {
return item
}
const itemWithSuggestions = [item]
for (const suggested of suggestions) {
if (seen.has(suggested.did)) {
// Skip search results from previous step or already seen suggestions
continue
}
seen.add(suggested.did)
itemWithSuggestions.push({
type: 'profile',
key: suggested.did,
profile: suggested,
isSuggestion: true,
})
if (itemWithSuggestions.length === 1 + 3) {
break
}
}
return itemWithSuggestions
})
} else {
if (isFetchingSuggestions || isFetchingSearchResults) {
const placeholders: Item[] = Array(10)
.fill(0)
.map((__, i) => ({
@@ -224,21 +161,54 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
}))
_items.push(...placeholders)
} else if (
(hasSearchText && searchResultsError) ||
(!hasSearchText && suggestionsError) ||
!results?.length
) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else {
const seen = new Set<string>()
for (const profile of results) {
if (seen.has(profile.did)) continue
if (profile.did === currentAccount?.did) continue
if (profile.viewer?.following) continue
seen.add(profile.did)
_items.push({
type: 'profile',
// Don't share identity across tabs or typing attempts
key: resultsKey + ':' + profile.did,
profile,
})
}
}
return _items
}, [
_,
suggestions,
suggestionsError,
isFetchingSuggestions,
searchResults,
isError,
searchResultsError,
isFetchingSearchResults,
currentAccount?.did,
hasSearchText,
selectedInterest,
suggestedAccounts,
query,
resultsKey,
])
if (searchText && !isFetching && !items.length && !isError) {
if (
searchText &&
!isFetchingSearchResults &&
!items.length &&
!isSearchResultsError
) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
@@ -249,9 +219,7 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
return (
<FollowProfileCard
profile={item.profile}
isSuggestion={item.isSuggestion}
moderationOpts={moderationOpts!}
setSuggestedAccounts={setSuggestedAccounts}
noBorder={index === 0}
/>
)
@@ -297,15 +265,6 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
/>
)
const onEndReached = useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more people to follow', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
return (
<Dialog.InnerFlatList
ref={listRef}
@@ -325,15 +284,6 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
scrollIndicatorInsets={{top: headerHeight}}
initialNumToRender={8}
maxToRenderPerBatch={8}
onEndReached={onEndReached}
itemLayoutAnimation={LinearTransition}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
/>
}
/>
)
}
@@ -471,7 +421,6 @@ let Tabs = ({
contentContainerStyle?: StyleProp<ViewStyle>
}): React.ReactNode => {
const listRef = useRef<ScrollView>(null)
const [scrollX, setScrollX] = useState(0)
const [totalWidth, setTotalWidth] = useState(0)
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
@@ -490,24 +439,11 @@ let Tabs = ({
function scrollIntoViewIfNeeded(index: number) {
const btnLayout = tabOffsets[index]
if (!btnLayout) return
const viewportLeftEdge = scrollX
const viewportRightEdge = scrollX + totalWidth
const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x
const shouldScrollToRightEdge =
viewportRightEdge < btnLayout.x + btnLayout.width
if (shouldScrollToLeftEdge) {
listRef.current?.scrollTo({
x: btnLayout.x - tokens.space.lg,
animated: true,
})
} else if (shouldScrollToRightEdge) {
listRef.current?.scrollTo({
x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg,
animated: true,
})
}
listRef.current?.scrollTo({
// centered
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
animated: true,
})
}
function handleSelectTab(index: number) {
@@ -539,7 +475,7 @@ let Tabs = ({
}
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
scrollEventThrottle={200} // big throttle
onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}>
>
{interests.map((interest, i) => {
const active = interest === selectedInterest && !hasSearchText
return (
@@ -575,6 +511,7 @@ let Tab = ({
interestsDisplayName: string
onLayout: (index: number, x: number, width: number) => void
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const activeText = active ? _(msg` (active)`) : ''
return (
@@ -585,12 +522,32 @@ let Tab = ({
}>
<Button
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
variant={active ? 'solid' : 'outline'}
color={active ? 'primary' : 'secondary'}
size="small"
onPress={() => onSelectTab(index)}>
<ButtonIcon icon={SearchIcon} />
<ButtonText>{interestsDisplayName}</ButtonText>
{({hovered, pressed, focused}) => (
<View
style={[
a.rounded_full,
a.px_lg,
a.py_sm,
a.border,
active || hovered || pressed || focused
? [
t.atoms.bg_contrast_25,
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
]
: [t.atoms.bg, t.atoms.border_contrast_low],
]}>
<Text
style={[
/* TODO: medium weight */
active || hovered || pressed || focused
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{interestsDisplayName}
</Text>
</View>
)}
</Button>
</View>
)
@@ -600,49 +557,18 @@ Tab = memo(Tab)
let FollowProfileCard = ({
profile,
moderationOpts,
isSuggestion,
setSuggestedAccounts,
noBorder,
}: {
profile: AppBskyActorDefs.ProfileView
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
isSuggestion: boolean
setSuggestedAccounts: (
updater: (
v: Map<string, AppBskyActorDefs.ProfileView[]>,
) => Map<string, AppBskyActorDefs.ProfileView[]>,
) => void
noBorder?: boolean
}): React.ReactNode => {
const [hasFollowed, setHasFollowed] = useState(false)
const followupSuggestion = useSuggestedFollowsByActorQuery({
did: profile.did,
enabled: hasFollowed,
})
const candidates = followupSuggestion.data?.suggestions
useEffect(() => {
// TODO: Move out of effect.
if (hasFollowed && candidates && candidates.length > 0) {
setSuggestedAccounts(suggestions => {
const newSuggestions = new Map(suggestions)
newSuggestions.set(profile.did, candidates)
return newSuggestions
})
}
}, [hasFollowed, profile.did, candidates, setSuggestedAccounts])
return (
<LayoutAnimationConfig skipEntering={!isSuggestion}>
<Animated.View entering={native(ZoomInEasyDown)}>
<FollowProfileCardInner
profile={profile}
moderationOpts={moderationOpts}
onFollow={() => setHasFollowed(true)}
noBorder={noBorder}
/>
</Animated.View>
</LayoutAnimationConfig>
<FollowProfileCardInner
profile={profile}
moderationOpts={moderationOpts}
noBorder={noBorder}
/>
)
}
FollowProfileCard = memo(FollowProfileCard)
@@ -653,7 +579,7 @@ function FollowProfileCardInner({
onFollow,
noBorder,
}: {
profile: AppBskyActorDefs.ProfileView
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
onFollow?: () => void
noBorder?: boolean
@@ -670,7 +596,7 @@ function FollowProfileCardInner({
style={[
a.flex_1,
noBorder && a.border_t_0,
(hovered || pressed) && t.atoms.border_contrast_high,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
+18 -8
View File
@@ -1,13 +1,13 @@
import React from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {type GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonColor, ButtonText} from '#/components/Button'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Button, type ButtonColor, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {BottomSheetViewProps} from '../../modules/bottom-sheet'
import {type BottomSheetViewProps} from '../../modules/bottom-sheet'
export {
type DialogControlProps as PromptControlProps,
@@ -62,12 +62,22 @@ export function Outer({
)
}
export function TitleText({children}: React.PropsWithChildren<{}>) {
export function TitleText({
children,
style,
}: React.PropsWithChildren<ViewStyleProp>) {
const {titleId} = React.useContext(Context)
return (
<Text
nativeID={titleId}
style={[a.text_2xl, a.font_bold, a.pb_sm, a.leading_snug]}>
style={[
a.flex_1,
a.text_2xl,
a.font_bold,
a.pb_sm,
a.leading_snug,
style,
]}>
{children}
</Text>
)
@@ -190,7 +200,7 @@ export function Basic({
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
title: string
description: string
description?: string
cancelButtonCta?: string
confirmButtonCta?: string
/**
@@ -207,7 +217,7 @@ export function Basic({
return (
<Outer control={control} testID="confirmModal">
<TitleText>{title}</TitleText>
<DescriptionText>{description}</DescriptionText>
{description && <DescriptionText>{description}</DescriptionText>}
<Actions>
<Action
cta={confirmButtonCta}
+3 -1
View File
@@ -6,9 +6,11 @@ import {
childHasEmoji,
normalizeTextStyles,
renderChildrenWithEmoji,
TextProps,
type TextProps,
} from '#/alf/typography'
export type {TextProps}
export {Text as Span} from 'react-native'
/**
* Our main text component. Use this most of the time.
@@ -0,0 +1,259 @@
import {useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {useAgent, useSession} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useBreakpoints, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function ChangeEmailDialog({
control,
verifyEmailControl,
}: {
control: Dialog.DialogControlProps
verifyEmailControl: Dialog.DialogControlProps
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Inner verifyEmailControl={verifyEmailControl} />
</Dialog.Outer>
)
}
export function Inner({
verifyEmailControl,
}: {
verifyEmailControl: Dialog.DialogControlProps
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const agent = useAgent()
const control = Dialog.useDialogContext()
const {gtMobile} = useBreakpoints()
const [currentStep, setCurrentStep] = useState<
'StepOne' | 'StepTwo' | 'StepThree'
>('StepOne')
const [email, setEmail] = useState('')
const [confirmationCode, setConfirmationCode] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState('')
const currentEmail = currentAccount?.email || '(no email)'
const uiStrings = {
StepOne: {
title: _(msg`Change Your Email`),
message: '',
},
StepTwo: {
title: _(msg`Security Step Required`),
message: _(
msg`An email has been sent to your previous address, ${currentEmail}. It includes a confirmation code which you can enter below.`,
),
},
StepThree: {
title: _(msg`Email Updated!`),
message: _(
msg`Your email address has been updated but it is not yet verified. As a next step, please verify your new email.`,
),
},
}
const onRequestChange = async () => {
if (email === currentAccount?.email) {
setError(
_(
msg`The email address you entered is the same as your current email address.`,
),
)
return
}
setError('')
setIsProcessing(true)
try {
const res = await agent.com.atproto.server.requestEmailUpdate()
if (res.data.tokenRequired) {
setCurrentStep('StepTwo')
} else {
await agent.com.atproto.server.updateEmail({email: email.trim()})
await agent.resumeSession(agent.session!)
setCurrentStep('StepThree')
}
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
const onConfirm = async () => {
setError('')
setIsProcessing(true)
try {
await agent.com.atproto.server.updateEmail({
email: email.trim(),
token: confirmationCode.trim(),
})
await agent.resumeSession(agent.session!)
setCurrentStep('StepThree')
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
const onVerify = async () => {
control.close(() => {
verifyEmailControl.open()
})
}
return (
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
style={web({maxWidth: 450})}>
<Dialog.Close />
<View style={[a.gap_xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{uiStrings[currentStep].title}
</Text>
{error ? (
<View style={[a.rounded_sm, a.overflow_hidden]}>
<ErrorMessage message={error} />
</View>
) : null}
{currentStep === 'StepOne' ? (
<View>
<TextField.LabelText>
<Trans>Enter your new email address below.</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Input
label={_(msg`New email address`)}
placeholder={_(msg`alice@example.com`)}
defaultValue={email}
onChangeText={setEmail}
keyboardType="email-address"
autoComplete="email"
/>
</TextField.Root>
</View>
) : (
<Text style={[a.text_md, a.leading_snug]}>
{uiStrings[currentStep].message}
</Text>
)}
</View>
{currentStep === 'StepTwo' ? (
<View>
<TextField.LabelText>
<Trans>Confirmation code</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Input
label={_(msg`Confirmation code`)}
placeholder="XXXXX-XXXXX"
onChangeText={setConfirmationCode}
/>
</TextField.Root>
</View>
) : null}
<View style={[a.gap_sm, gtMobile && [a.flex_row_reverse, a.ml_auto]]}>
{currentStep === 'StepOne' ? (
<>
<Button
label={_(msg`Request change`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onRequestChange}>
<ButtonText>
<Trans>Request change</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`I have a code`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => setCurrentStep('StepTwo')}>
<ButtonText>
<Trans>I have a code</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepTwo' ? (
<>
<Button
label={_(msg`Confirm`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onConfirm}>
<ButtonText>
<Trans>Confirm</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`Resend email`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => {
setConfirmationCode('')
setCurrentStep('StepOne')
}}>
<ButtonText>
<Trans>Resend email</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepThree' ? (
<>
<Button
label={_(msg`Verify email`)}
variant="solid"
color="primary"
size="large"
onPress={onVerify}>
<ButtonText>
<Trans>Verify email</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Close`)}
variant="solid"
color="secondary"
size="large"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</>
) : null}
</View>
</View>
</Dialog.ScrollableInner>
)
}
@@ -1,4 +1,5 @@
import React, {
import {
Fragment,
useCallback,
useLayoutEffect,
useMemo,
@@ -6,7 +7,7 @@ import React, {
useState,
} from 'react'
import {TextInput, View} from 'react-native'
import {moderateProfile, ModerationOpts} from '@atproto/api'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -18,48 +19,62 @@ import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import {ListMethods} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
import {type ListMethods} from '#/view/com/util/List'
import {android, atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {canBeMessaged} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import type * as bsky from '#/types/bsky'
type Item =
| {
type: 'profile'
key: string
enabled: boolean
profile: bsky.profile.AnyProfileView
}
| {
type: 'empty'
key: string
message: string
}
| {
type: 'placeholder'
key: string
}
| {
type: 'error'
key: string
}
export type ProfileItem = {
type: 'profile'
key: string
profile: bsky.profile.AnyProfileView
}
type EmptyItem = {
type: 'empty'
key: string
message: string
}
type PlaceholderItem = {
type: 'placeholder'
key: string
}
type ErrorItem = {
type: 'error'
key: string
}
type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
export function SearchablePeopleList({
title,
onSelectChat,
showRecentConvos,
sortByMessageDeclaration,
onSelectChat,
renderProfileCard,
}: {
title: string
onSelectChat: (did: string) => void
showRecentConvos?: boolean
}) {
sortByMessageDeclaration?: boolean
} & (
| {
renderProfileCard: (item: ProfileItem) => React.ReactNode
onSelectChat?: undefined
}
| {
onSelectChat: (did: string) => void
renderProfileCard?: undefined
}
)) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
@@ -98,15 +113,17 @@ export function SearchablePeopleList({
_items.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
_items = _items.sort(item => {
// @ts-ignore
return item.enabled ? -1 : 1
})
if (sortByMessageDeclaration) {
_items = _items.sort(item => {
return item.type === 'profile' && canBeMessaged(item.profile)
? -1
: 1
})
}
}
} else {
const placeholders: Item[] = Array(10)
@@ -134,14 +151,13 @@ export function SearchablePeopleList({
_items.push({
type: 'profile',
key: profile.did,
enabled: true,
profile,
})
}
}
}
let followsItems: typeof _items = []
let followsItems: ProfileItem[] = []
for (const page of follows.pages) {
for (const profile of page.follows) {
@@ -150,17 +166,17 @@ export function SearchablePeopleList({
followsItems.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
}
// only sort follows
followsItems = followsItems.sort(item => {
// @ts-ignore
return item.enabled ? -1 : 1
})
if (sortByMessageDeclaration) {
// only sort follows
followsItems = followsItems.sort(item => {
return canBeMessaged(item.profile) ? -1 : 1
})
}
// then append
_items.push(...followsItems)
@@ -173,16 +189,18 @@ export function SearchablePeopleList({
_items.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
}
_items = _items.sort(item => {
// @ts-ignore
return item.enabled ? -1 : 1
})
if (sortByMessageDeclaration) {
_items = _items.sort(item => {
return item.type === 'profile' && canBeMessaged(item.profile)
? -1
: 1
})
}
} else {
_items.push(...placeholders)
}
@@ -198,6 +216,7 @@ export function SearchablePeopleList({
follows,
convos,
showRecentConvos,
sortByMessageDeclaration,
])
if (searchText && !isFetching && !items.length && !isError) {
@@ -208,15 +227,18 @@ export function SearchablePeopleList({
({item}: {item: Item}) => {
switch (item.type) {
case 'profile': {
return (
<ProfileCard
key={item.key}
enabled={item.enabled}
profile={item.profile}
moderationOpts={moderationOpts!}
onPress={onSelectChat}
/>
)
if (renderProfileCard) {
return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment>
} else {
return (
<DefaultProfileCard
key={item.key}
profile={item.profile}
moderationOpts={moderationOpts!}
onPress={onSelectChat}
/>
)
}
}
case 'placeholder': {
return <ProfileCardSkeleton key={item.key} />
@@ -228,7 +250,7 @@ export function SearchablePeopleList({
return null
}
},
[moderationOpts, onSelectChat],
[moderationOpts, onSelectChat, renderProfileCard],
)
useLayoutEffect(() => {
@@ -247,6 +269,10 @@ export function SearchablePeopleList({
a.relative,
web(a.pt_lg),
native(a.pt_4xl),
android({
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
}),
a.pb_xs,
a.px_lg,
a.border_b,
@@ -327,19 +353,18 @@ export function SearchablePeopleList({
)
}
function ProfileCard({
enabled,
function DefaultProfileCard({
profile,
moderationOpts,
onPress,
}: {
enabled: boolean
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
onPress: (did: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const enabled = canBeMessaged(profile)
const moderation = moderateProfile(profile, moderationOpts)
const handle = sanitizeHandle(profile.handle, '@')
const displayName = sanitizeDisplayName(
@@ -360,38 +385,35 @@ function ProfileCard({
<View
style={[
a.flex_1,
a.py_md,
a.py_sm,
a.px_lg,
a.gap_md,
a.align_center,
a.flex_row,
!enabled
? {opacity: 0.5}
: pressed || focused
: pressed || focused || hovered
? t.atoms.bg_contrast_25
: hovered
? t.atoms.bg_contrast_50
: t.atoms.bg,
]}>
<UserAvatar
size={42}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
numberOfLines={1}
emoji>
{displayName}
</Text>
<Text
style={[a.leading_tight, t.atoms.text_contrast_high]}
numberOfLines={2}>
{!enabled ? <Trans>{handle} can't be messaged</Trans> : handle}
</Text>
</View>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<View style={[a.flex_1]}>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
{enabled ? (
<ProfileCard.Handle profile={profile} />
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>{handle} can't be messaged</Trans>
</Text>
)}
</View>
</ProfileCard.Header>
</View>
)}
</Button>
+111 -48
View File
@@ -1,86 +1,115 @@
import React from 'react'
import {useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {useAgent, useSession} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useBreakpoints} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Envelope_Filled_Stroke2_Corner0_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ChangeEmailDialog} from './ChangeEmailDialog'
export function VerifyEmailDialog({
control,
onCloseWithoutVerifying,
onCloseAfterVerifying,
reasonText,
changeEmailControl,
reminder,
}: {
control: Dialog.DialogControlProps
onCloseWithoutVerifying?: () => void
onCloseAfterVerifying?: () => void
reasonText?: string
/**
* if a changeEmailControl for a ChangeEmailDialog is not provided,
* this component will create one for you. Using this prop
* helps reduce duplication, since these dialogs are often used together.
*/
changeEmailControl?: Dialog.DialogControlProps
reminder?: boolean
}) {
const agent = useAgent()
const fallbackChangeEmailControl = Dialog.useDialogControl()
const [didVerify, setDidVerify] = React.useState(false)
const [didVerify, setDidVerify] = useState(false)
return (
<Dialog.Outer
control={control}
onClose={async () => {
if (!didVerify) {
onCloseWithoutVerifying?.()
return
}
try {
await agent.resumeSession(agent.session!)
onCloseAfterVerifying?.()
} catch (e: unknown) {
logger.error(String(e))
return
}
}}>
<Dialog.Handle />
<Inner
<>
<Dialog.Outer
control={control}
setDidVerify={setDidVerify}
reasonText={reasonText}
/>
</Dialog.Outer>
onClose={async () => {
if (!didVerify) {
onCloseWithoutVerifying?.()
return
}
try {
await agent.resumeSession(agent.session!)
onCloseAfterVerifying?.()
} catch (e: unknown) {
logger.error(String(e))
return
}
}}>
<Dialog.Handle />
<Inner
setDidVerify={setDidVerify}
reasonText={reasonText}
changeEmailControl={changeEmailControl ?? fallbackChangeEmailControl}
reminder={reminder}
/>
</Dialog.Outer>
{!changeEmailControl && (
<ChangeEmailDialog
control={fallbackChangeEmailControl}
verifyEmailControl={control}
/>
)}
</>
)
}
export function Inner({
control,
setDidVerify,
reasonText,
changeEmailControl,
reminder,
}: {
control: Dialog.DialogControlProps
setDidVerify: (value: boolean) => void
reasonText?: string
changeEmailControl: Dialog.DialogControlProps
reminder?: boolean
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const {currentAccount} = useSession()
const agent = useAgent()
const {openModal} = useModalControls()
const {gtMobile} = useBreakpoints()
const t = useTheme()
const [currentStep, setCurrentStep] = React.useState<
'StepOne' | 'StepTwo' | 'StepThree'
>('StepOne')
const [confirmationCode, setConfirmationCode] = React.useState('')
const [isProcessing, setIsProcessing] = React.useState(false)
const [error, setError] = React.useState('')
const [currentStep, setCurrentStep] = useState<
'Reminder' | 'StepOne' | 'StepTwo' | 'StepThree'
>(reminder ? 'Reminder' : 'StepOne')
const [confirmationCode, setConfirmationCode] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState('')
const uiStrings = {
Reminder: {
title: _(msg`Please Verify Your Email`),
message: _(
msg`Your email has not yet been verified. This is an important security step which we recommend.`,
),
},
StepOne: {
title: _(msg`Verify Your Email`),
message: '',
@@ -132,11 +161,20 @@ export function Inner({
return (
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
<Dialog.Close />
style={web({maxWidth: 450})}>
<View style={[a.gap_xl]}>
{currentStep === 'Reminder' && (
<View
style={[
a.rounded_sm,
a.align_center,
a.justify_center,
{height: 150},
t.atoms.bg_contrast_100,
]}>
<EnvelopeIcon width={64} fill="white" />
</View>
)}
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{uiStrings[currentStep].title}
@@ -164,7 +202,7 @@ export function Inner({
onPress={e => {
e.preventDefault()
control.close(() => {
openModal({name: 'change-email'})
changeEmailControl.open()
})
return false
}}>
@@ -189,7 +227,7 @@ export function Inner({
onPress={e => {
e.preventDefault()
control.close(() => {
openModal({name: 'change-email'})
changeEmailControl.open()
})
return false
}}>
@@ -219,7 +257,32 @@ export function Inner({
</View>
) : null}
<View style={[a.gap_sm, gtMobile && [a.flex_row_reverse, a.ml_auto]]}>
{currentStep === 'StepOne' ? (
{currentStep === 'Reminder' ? (
<>
<Button
label={_(msg`Get started`)}
variant="solid"
color="primary"
size="large"
onPress={() => setCurrentStep('StepOne')}>
<ButtonText>
<Trans>Get started</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Maybe later`)}
accessibilityHint={_(msg`Snoozes the reminder`)}
variant="ghost"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => control.close()}>
<ButtonText>
<Trans>Maybe later</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepOne' ? (
<>
<Button
label={_(msg`Send confirmation email`)}
@@ -229,21 +292,21 @@ export function Inner({
disabled={isProcessing}
onPress={onSendEmail}>
<ButtonText>
<Trans>Send Confirmation</Trans>
<Trans>Send confirmation</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`I Have a Code`)}
label={_(msg`I have a code`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => setCurrentStep('StepTwo')}>
<ButtonText>
<Trans>I Have a Code</Trans>
<Trans>I have a code</Trans>
</ButtonText>
</Button>
</>
@@ -264,7 +327,7 @@ export function Inner({
) : null}
</Button>
<Button
label={_(msg`Resend Email`)}
label={_(msg`Resend email`)}
variant="solid"
color="secondary"
size="large"
@@ -274,13 +337,13 @@ export function Inner({
setCurrentStep('StepOne')
}}>
<ButtonText>
<Trans>Resend Email</Trans>
<Trans>Resend email</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepThree' ? (
<Button
label={_(msg`Confirm`)}
label={_(msg`Close`)}
variant="solid"
color="primary"
size="large"
@@ -0,0 +1,180 @@
import {useCallback, useMemo} from 'react'
import {View} from 'react-native'
import {type AppBskyGraphDefs, type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
getMembership,
type ListMembersip,
useDangerousListMembershipsQuery,
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {
type ProfileItem,
SearchablePeopleList,
} from '#/components/dialogs/SearchablePeopleList'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import type * as bsky from '#/types/bsky'
export function ListAddRemoveUsersDialog({
control,
list,
onChange,
}: {
control: Dialog.DialogControlProps
list: AppBskyGraphDefs.ListView
onChange?: (
type: 'add' | 'remove',
profile: bsky.profile.AnyProfileView,
) => void | undefined
}) {
return (
<Dialog.Outer control={control} testID="listAddRemoveUsersDialog">
<Dialog.Handle />
<DialogInner list={list} onChange={onChange} />
</Dialog.Outer>
)
}
function DialogInner({
list,
onChange,
}: {
list: AppBskyGraphDefs.ListView
onChange?: (
type: 'add' | 'remove',
profile: bsky.profile.AnyProfileView,
) => void | undefined
}) {
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {data: memberships} = useDangerousListMembershipsQuery()
const renderProfileCard = useCallback(
(item: ProfileItem) => {
return (
<UserResult
profile={item.profile}
onChange={onChange}
memberships={memberships}
list={list}
moderationOpts={moderationOpts}
/>
)
},
[onChange, memberships, list, moderationOpts],
)
return (
<SearchablePeopleList
title={_(msg`Add people to list`)}
renderProfileCard={renderProfileCard}
/>
)
}
function UserResult({
profile,
list,
memberships,
onChange,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
list: AppBskyGraphDefs.ListView
memberships: ListMembersip[] | undefined
onChange?: (
type: 'add' | 'remove',
profile: bsky.profile.AnyProfileView,
) => void | undefined
moderationOpts?: ModerationOpts
}) {
const {_} = useLingui()
const membership = useMemo(
() => getMembership(memberships, list.uri, profile.did),
[memberships, list.uri, profile.did],
)
const {mutate: listMembershipAdd, isPending: isAddingPending} =
useListMembershipAddMutation({
onSuccess: () => {
Toast.show(_(msg`Added to list`))
onChange?.('add', profile)
},
onError: e => Toast.show(cleanError(e), 'xmark'),
})
const {mutate: listMembershipRemove, isPending: isRemovingPending} =
useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(_(msg`Removed from list`))
onChange?.('remove', profile)
},
onError: e => Toast.show(cleanError(e), 'xmark'),
})
const isMutating = isAddingPending || isRemovingPending
const onToggleMembership = useCallback(() => {
if (typeof membership === 'undefined') {
return
}
if (membership === false) {
listMembershipAdd({
listUri: list.uri,
actorDid: profile.did,
})
} else {
listMembershipRemove({
listUri: list.uri,
actorDid: profile.did,
membershipUri: membership,
})
}
}, [list, profile, membership, listMembershipAdd, listMembershipRemove])
if (!moderationOpts) return null
return (
<View style={[a.flex_1, a.py_sm, a.px_lg]}>
<ProfileCard.Header>
<ProfileCard.Avatar profile={profile} moderationOpts={moderationOpts} />
<View style={[a.flex_1]}>
<ProfileCard.Name profile={profile} moderationOpts={moderationOpts} />
<ProfileCard.Handle profile={profile} />
</View>
{membership !== undefined && (
<Button
label={
membership === false
? _(msg`Add user to list`)
: _(msg`Remove user from list`)
}
onPress={onToggleMembership}
disabled={isMutating}
size="small"
variant="solid"
color="secondary">
{isMutating ? (
<ButtonIcon icon={Loader} />
) : (
<ButtonText>
{membership === false ? (
<Trans>Add</Trans>
) : (
<Trans>Remove</Trans>
)}
</ButtonText>
)}
</Button>
)}
</ProfileCard.Header>
</View>
)
}
@@ -0,0 +1,194 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {urls} from '#/lib/constants'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
import {VerifierCheck} from '#/components/icons/VerifierCheck'
import {Link} from '#/components/Link'
import {Span, Text} from '#/components/Typography'
export function InitialVerificationAnnouncement() {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl()
Dialog.useAutoOpen(control)
const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
return (
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Announcing verification on Bluesky`)}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
<View style={[a.align_start, a.gap_xl]}>
<View
style={[
a.pl_sm,
a.pr_md,
a.py_sm,
a.rounded_full,
a.flex_row,
a.align_center,
a.gap_xs,
{
backgroundColor: t.palette.primary_25,
},
]}>
<SparkleIcon fill={t.palette.primary_700} size="sm" />
<Text
style={[
a.font_bold,
{
color: t.palette.primary_700,
},
]}>
<Trans>New Feature</Trans>
</Text>
</View>
<View
style={[
a.w_full,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{minHeight: 100},
]}>
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/initial_verification_announcement_1.png')}
style={[
{
aspectRatio: 353 / 160,
},
]}
alt={_(
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
)}
/>
</View>
<View style={[a.gap_xs]}>
<Text style={[a.text_2xl, a.font_bold, a.leading_snug]}>
<Trans>A new form of verification</Trans>
</Text>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
Were introducing a new layer of verification on Bluesky an
easy-to-see checkmark.
</Trans>
</Text>
</View>
<View
style={[
a.w_full,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{minHeight: 100},
]}>
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/initial_verification_announcement_2.png')}
style={[
{
aspectRatio: 353 / 196,
},
]}
alt={_(
msg`An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name.`,
)}
/>
</View>
<View style={[a.gap_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<VerifierCheck width={14} />
<Text style={[a.text_lg, a.font_bold, a.leading_snug]}>
<Trans>Who can verify?</Trans>
</Text>
</View>
<View style={[a.gap_sm]}>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
Bluesky will proactively verify notable and authentic
accounts.
</Trans>
</Text>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
Trust emerges from relationships, communities, and shared
context, so were also enabling{' '}
<Span style={[a.font_bold]}>trusted verifiers</Span>:
organizations that can directly issue verification.
</Trans>
</Text>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
When you tap on a check, youll see which organizations have
granted verification.
</Trans>
</Text>
</View>
</View>
<View style={[a.w_full, a.gap_md]}>
<Link
overridePresentation
to={urls.website.blog.initialVerificationAnnouncement}
label={_(msg`Read blog post`)}
size="small"
variant="solid"
color="primary"
style={[a.justify_center, a.w_full]}
onPress={() => {
logger.metric('verification:learn-more', {
location: 'initialAnnouncementeNux',
})
}}>
<ButtonText>
<Trans>Read blog post</Trans>
</ButtonText>
</Link>
{isNative && (
<Button
label={_(msg`Close`)}
size="small"
variant="solid"
color="secondary"
style={[a.justify_center, a.w_full]}
onPress={() => {
control.close()
}}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+13 -4
View File
@@ -1,16 +1,17 @@
import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
import {type AppBskyActorDefs} from '@atproto/api'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs'
import {
usePreferencesQuery,
UsePreferencesQueryResponse,
type UsePreferencesQueryResponse,
} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile'
import {SessionAccount, useSession} from '#/state/session'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/InitialVerificationAnnouncement'
/*
* NUXs
*/
@@ -29,7 +30,12 @@ const queuedNuxs: {
currentProfile: AppBskyActorDefs.ProfileViewDetailed
preferences: UsePreferencesQueryResponse
}) => boolean
}[] = []
}[] = [
{
id: Nux.InitialVerificationAnnouncement,
enabled: () => true,
},
]
const Context = React.createContext<Context>({
activeNux: undefined,
@@ -163,6 +169,9 @@ function Inner({
return (
<Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.InitialVerificationAnnouncement && (
<InitialVerificationAnnouncement />
)}
</Context.Provider>
)
}
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
@@ -61,6 +62,12 @@ export let MessageContextMenu = ({
langPrefs.primaryLanguage,
)
openLink(translatorUrl, true)
logger.metric('translate', {
sourceLanguages: [],
targetLanguage: langPrefs.primaryLanguage,
textLength: message.text.length,
})
}, [langPrefs.primaryLanguage, message.text, openLink])
const onDelete = useCallback(() => {
+32 -17
View File
@@ -1,9 +1,9 @@
import React, {useCallback} from 'react'
import {TouchableOpacity, View} from 'react-native'
import {
AppBskyActorDefs,
ModerationCause,
ModerationDecision,
type AppBskyActorDefs,
type ModerationCause,
type ModerationDecision,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
@@ -12,12 +12,12 @@ import {useNavigation} from '@react-navigation/native'
import {BACK_HITSLOP} from '#/lib/constants'
import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/profile-shadow'
import {type Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo'
import {ConvoItem} from '#/state/messages/convo/types'
import {type ConvoItem} from '#/state/messages/convo/types'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
@@ -25,6 +25,8 @@ import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/
import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
const PFP_SIZE = isWeb ? 40 : 34
@@ -149,6 +151,9 @@ function HeaderReady({
const {_} = useLingui()
const t = useTheme()
const convoState = useConvo()
const verification = useSimpleVerificationState({
profile,
})
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
@@ -185,17 +190,27 @@ function HeaderReady({
/>
</View>
<View style={a.flex_1}>
<Text
emoji
style={[
a.text_md,
a.font_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}>
{displayName}
</Text>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.text_md,
a.font_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}>
{displayName}
</Text>
{verification.showBadge && (
<View style={[a.pl_xs]}>
<VerificationCheck
width={14}
verifier={verification.role === 'verifier'}
/>
</View>
)}
</View>
{!isDeletedAccount && (
<Text
style={[
+2 -1
View File
@@ -11,9 +11,9 @@ import * as Toast from '#/view/com/util/Toast'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SearchablePeopleList} from './SearchablePeopleList'
export function NewChat({
control,
@@ -71,6 +71,7 @@ export function NewChat({
<SearchablePeopleList
title={_(msg`Start a new chat`)}
onSelectChat={onCreateChat}
sortByMessageDeclaration
/>
</Dialog.Outer>
@@ -7,7 +7,7 @@ import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import * as Toast from '#/view/com/util/Toast'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from './SearchablePeopleList'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
export function SendViaChatDialog({
control,
@@ -62,6 +62,7 @@ function SendViaChatDialogInner({
title={_(msg`Send post to...`)}
onSelectChat={onCreateChat}
showRecentConvos
sortByMessageDeclaration
/>
)
}
+20 -19
View File
@@ -4,7 +4,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {isAndroid} from '#/platform/detection'
import {ServerInputDialog} from '#/view/com/auth/server-input'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -41,25 +40,27 @@ export function HostingProvider({
onSelect={onSelectServiceUrl}
/>
{minimal ? (
<View style={[a.flex_row, a.align_center, a.flex_wrap]}>
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_xs]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>
You are creating an account on{' '}
<Button
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Changes hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
color="secondary"
size="tiny"
style={[a.px_xs, {margin: tokens.space.xs * -1}]}>
<ButtonText style={[a.text_sm]}>
{toNiceDomain(serviceUrl)}
</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</Trans>
<Trans>You are creating an account on</Trans>
</Text>
<Button
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Changes hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
color="secondary"
size="tiny"
style={[
a.px_xs,
{marginHorizontal: tokens.space.xs * -1},
{paddingVertical: 0},
]}>
<ButtonText style={[a.text_sm]}>
{toNiceDomain(serviceUrl)}
</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</View>
) : (
<Button
@@ -73,10 +74,10 @@ export function HostingProvider({
a.flex_row,
a.align_center,
a.rounded_sm,
a.py_sm,
a.pl_md,
a.pr_sm,
a.gap_xs,
{paddingVertical: isAndroid ? 14 : 8},
]}
onPress={onPressSelectService}>
{({hovered, pressed}) => {
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const BroomSparkle_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M20.494 2.13a1 1 0 0 1 .375 1.364l-4.658 8.2.263.095c1.52.554 2.737 2.062 2.484 3.864-.336 2.393-1.358 4.12-3.245 6.047a1 1 0 0 1-1.102.222l-9.5-4a1 1 0 0 1-.166-1.754c1.458-.971 2.623-1.923 3.498-3.3 1.057-1.662 3.154-2.854 5.281-2.08l.58.212 4.827-8.494a1 1 0 0 1 1.363-.375ZM13.04 12.669c-.983-.358-2.19.142-2.91 1.273-.737 1.16-1.628 2.054-2.601 2.828l1.708.72a.2.2 0 0 0 .177-.011l2.13-1.218a.2.2 0 0 1 .29.237l-.551 1.653a.2.2 0 0 0 .112.247l3.353 1.413c1.359-1.494 1.994-2.76 2.23-4.435.094-.675-.359-1.405-1.188-1.707l-2.75-1ZM4.407 7.184a.5.5 0 0 1-.224.224l-1.29.645a.5.5 0 0 0 0 .894l1.29.645a.5.5 0 0 1 .224.224l.645 1.29a.5.5 0 0 0 .894 0l.645-1.29a.5.5 0 0 1 .224-.224l1.29-.645a.5.5 0 0 0 0-.894l-1.29-.645a.5.5 0 0 1-.224-.224l-.645-1.29a.5.5 0 0 0-.894 0l-.645 1.29ZM9.559 3.72a.36.36 0 0 0 .16-.16l.46-.921a.357.357 0 0 1 .64 0l.46.921q.054.106.16.16l.921.46a.357.357 0 0 1 0 .64l-.921.46a.36.36 0 0 0-.16.16l-.46.921a.357.357 0 0 1-.64 0l-.46-.921a.36.36 0 0 0-.16-.16l-.921-.46a.357.357 0 0 1 0-.64l.921-.46Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CircleCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.633-3.274a1 1 0 0 1 .141 1.407l-4.5 5.5a1 1 0 0 1-1.481.074l-2-2a1 1 0 1 1 1.414-1.414l1.219 1.219 3.8-4.645a1 1 0 0 1 1.407-.141Z',
})
+4
View File
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const Shield_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z',
})
export const ShieldCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12.325 2.054a1 1 0 0 0-.65 0l-8 2.75A1 1 0 0 0 3 5.75v6.162c0 2.807 1.149 4.83 2.813 6.405 1.572 1.488 3.632 2.6 5.555 3.636l.157.085a1 1 0 0 0 .95 0l.157-.085c1.923-1.037 3.983-2.148 5.556-3.636C19.85 16.742 21 14.719 21 11.912V5.75a1 1 0 0 0-.675-.946l-8-2.75ZM5 11.912V6.464l7-2.407 7 2.407v5.448c0 2.166-.851 3.687-2.188 4.952-1.276 1.209-2.964 2.158-4.812 3.157-1.848-1-3.536-1.948-4.813-3.157C5.851 15.6 5 14.078 5 11.912Zm10.207-1.205a1 1 0 0 0-1.414-1.414L11 12.086l-.793-.793a1 1 0 0 0-1.414 1.414l1.5 1.5a1 1 0 0 0 1.414 0l3.5-3.5Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Sparkle_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2a1 1 0 0 1 1 1c0 3.188.669 5.256 1.882 6.536C16.084 10.805 18.01 11.5 21 11.5a1 1 0 1 1 0 2c-2.99 0-4.916.695-6.118 1.964C13.67 16.744 13 18.812 13 22a1 1 0 1 1-2 0c0-3.188-.669-5.256-1.882-6.536C7.916 14.195 5.99 13.5 3 13.5a1 1 0 1 1 0-2c2.99 0 4.916-.695 6.118-1.964C10.33 8.256 11 6.188 11 3a1 1 0 0 1 1-1Zm0 6.734a7.608 7.608 0 0 1-1.43 2.178A7.285 7.285 0 0 1 8.349 12.5c.846.397 1.589.921 2.22 1.588A7.607 7.607 0 0 1 12 16.267a7.607 7.607 0 0 1 1.43-2.179 7.284 7.284 0 0 1 2.221-1.588 7.284 7.284 0 0 1-2.22-1.588A7.608 7.608 0 0 1 12 8.734Z',
})
+30
View File
@@ -0,0 +1,30 @@
import React from 'react'
import Svg, {Circle, Path} from 'react-native-svg'
import {type Props, useCommonSVGProps} from '#/components/icons/common'
export const VerifiedCheck = React.forwardRef<Svg, Props>(function LogoImpl(
props,
ref,
) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
return (
<Svg
fill="none"
{...rest}
ref={ref}
viewBox="0 0 24 24"
width={size}
height={size}
style={[style]}>
<Circle cx="12" cy="12" r="12" fill={fill} />
<Path
fill="#fff"
fillRule="evenodd"
clipRule="evenodd"
d="M18.311 7.421a1.437 1.437 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L6.42 12.74a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z"
/>
</Svg>
)
})
+35
View File
@@ -0,0 +1,35 @@
import React from 'react'
import Svg, {Path} from 'react-native-svg'
import {type Props, useCommonSVGProps} from '#/components/icons/common'
export const VerifierCheck = React.forwardRef<Svg, Props>(function LogoImpl(
props,
ref,
) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
return (
<Svg
fill="none"
{...rest}
ref={ref}
viewBox="0 0 24 24"
width={size}
height={size}
style={[style]}>
<Path
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M8.792 1.54a4.11 4.11 0 0 1 6.416 0 4.128 4.128 0 0 0 3.146 1.54c2.616.04 4.544 2.5 4 5.1a4.277 4.277 0 0 0 .777 3.462c1.6 2.104.912 5.17-1.427 6.36a4.21 4.21 0 0 0-2.177 2.774c-.62 2.584-3.408 3.948-5.781 2.83a4.092 4.092 0 0 0-3.492 0c-2.373 1.118-5.16-.246-5.78-2.83a4.21 4.21 0 0 0-2.178-2.775c-2.34-1.19-3.028-4.256-1.427-6.36a4.277 4.277 0 0 0 .776-3.46c-.543-2.602 1.385-5.06 4.001-5.1a4.128 4.128 0 0 0 3.146-1.54Z"
/>
<Path
fill="#fff"
fillRule="evenodd"
clipRule="evenodd"
d="M17.659 8.399a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.435a1.361 1.361 0 1 1 1.925-1.925l2.149 2.15 5.26-5.261a1.361 1.361 0 0 1 1.925 0Z"
/>
</Svg>
)
})
@@ -0,0 +1,32 @@
import {createIconSet} from '@expo/vector-icons'
import {useFonts} from 'expo-font'
import {type Props} from '#/components/icons/common'
import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
import {VerifierCheck} from '#/components/icons/VerifierCheck'
export function VerificationCheck({
verifier,
...rest
}: Props & {
verifier?: boolean
}) {
return verifier ? <VerifierCheck {...rest} /> : <VerifiedCheck {...rest} />
}
export function loadVerificationCheckFonts() {
return useFonts({
Verification: require('../../../assets/fonts/verification/Verification.ttf'),
})
}
export const VerificationCheckIcon = createIconSet(
{
'Verification-verifiedCheck': 59905,
'Verification-verifiedCheckGray': 59906,
'Verification-verifierCheck': 59907,
'Verification-verifierCheckGray': 59908,
},
'Verification',
'Verification.ttf',
)
@@ -0,0 +1,155 @@
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/types'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {useFullVerificationState} from '#/components/verification'
import {type FullVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {VerificationsDialog} from '#/components/verification/VerificationsDialog'
import {VerifierDialog} from '#/components/verification/VerifierDialog'
import type * as bsky from '#/types/bsky'
export function shouldShowVerificationCheckButton(
state: FullVerificationState,
) {
let ok = false
if (state.profile.role === 'default') {
if (state.profile.isVerified) {
ok = true
} else if (state.profile.isViewer && state.profile.wasVerified) {
ok = true
} else if (
state.viewer.role === 'verifier' &&
state.viewer.hasIssuedVerification
) {
ok = true
}
} else if (state.profile.role === 'verifier') {
if (state.profile.isViewer) {
ok = true
} else if (state.profile.isVerified) {
ok = true
}
}
if (
!state.profile.showBadge &&
!state.profile.isViewer &&
!(state.viewer.role === 'verifier' && state.viewer.hasIssuedVerification)
) {
ok = false
}
return ok
}
export function VerificationCheckButton({
profile,
size,
}: {
profile: Shadow<bsky.profile.AnyProfileView>
size: 'lg' | 'md' | 'sm'
}) {
const state = useFullVerificationState({
profile,
})
if (shouldShowVerificationCheckButton(state)) {
return <Badge profile={profile} verificationState={state} size={size} />
}
return null
}
export function Badge({
profile,
verificationState: state,
size,
}: {
profile: Shadow<bsky.profile.AnyProfileView>
verificationState: FullVerificationState
size: 'lg' | 'md' | 'sm'
}) {
const t = useTheme()
const {_} = useLingui()
const verificationsDialogControl = useDialogControl()
const verifierDialogControl = useDialogControl()
const {gtPhone} = useBreakpoints()
let dimensions = 12
if (size === 'lg') {
dimensions = gtPhone ? 20 : 18
} else if (size === 'md') {
dimensions = 16
}
const verifiedByHidden = !state.profile.showBadge && state.profile.isViewer
return (
<>
<Button
label={
state.profile.isViewer
? _(msg`View your verifications`)
: _(msg`View this user's verifications`)
}
hitSlop={20}
onPress={() => {
logger.metric('verification:badge:click', {})
if (state.profile.role === 'verifier') {
verifierDialogControl.open()
} else {
verificationsDialogControl.open()
}
}}
style={[]}>
{({hovered}) => (
<View
style={[
a.justify_end,
a.align_end,
a.transition_transform,
{
width: dimensions,
height: dimensions,
transform: [
{
scale: hovered ? 1.1 : 1,
},
],
},
]}>
<VerificationCheck
width={dimensions}
fill={
verifiedByHidden
? t.atoms.bg_contrast_100.backgroundColor
: state.profile.isVerified
? t.palette.primary_500
: t.atoms.bg_contrast_100.backgroundColor
}
verifier={state.profile.role === 'verifier'}
/>
</View>
)}
</Button>
<VerificationsDialog
control={verificationsDialogControl}
profile={profile}
verificationState={state}
/>
<VerifierDialog
control={verifierDialogControl}
profile={profile}
verificationState={state}
/>
</>
)
}
@@ -0,0 +1,70 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useVerificationCreateMutation} from '#/state/queries/verification/useVerificationCreateMutation'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import {type DialogControlProps} from '#/components/Dialog'
import {VerifiedCheck} from '#/components/icons/VerifiedCheck'
import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
import type * as bsky from '#/types/bsky'
export function VerificationCreatePrompt({
control,
profile,
}: {
control: DialogControlProps
profile: bsky.profile.AnyProfileView
}) {
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {mutateAsync: create} = useVerificationCreateMutation()
const onConfirm = useCallback(async () => {
try {
await create({profile})
Toast.show(_(msg`Successfully verified`))
} catch (e) {
Toast.show(_(msg`Failed to create a verification`), 'xmark')
logger.error('Failed to create a verification', {
safeMessage: e,
})
}
}, [_, profile, create])
return (
<Prompt.Outer control={control}>
<View style={[a.flex_row, a.align_center, a.gap_sm, a.pb_sm]}>
<VerifiedCheck width={18} />
<Prompt.TitleText style={[a.pb_0]}>
{_(msg`Verify this account?`)}
</Prompt.TitleText>
</View>
<Prompt.DescriptionText>
{_(msg`This action can be undone at any time.`)}
</Prompt.DescriptionText>
<View style={[a.pb_xl]}>
{moderationOpts ? (
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
) : null}
</View>
<Prompt.Actions>
<Prompt.Action cta={_(msg`Verify account`)} onPress={onConfirm} />
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
)
}
@@ -0,0 +1,50 @@
import {useCallback} from 'react'
import {type AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useVerificationsRemoveMutation} from '#/state/queries/verification/useVerificationsRemoveMutation'
import * as Toast from '#/view/com/util/Toast'
import {type DialogControlProps} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import type * as bsky from '#/types/bsky'
export {useDialogControl as usePromptControl} from '#/components/Dialog'
export function VerificationRemovePrompt({
control,
profile,
verifications,
onConfirm: onConfirmInner,
}: {
control: DialogControlProps
profile: bsky.profile.AnyProfileView
verifications: AppBskyActorDefs.VerificationView[]
onConfirm?: () => void
}) {
const {_} = useLingui()
const {mutateAsync: remove} = useVerificationsRemoveMutation()
const onConfirm = useCallback(async () => {
onConfirmInner?.()
try {
await remove({profile, verifications})
Toast.show(_(msg`Removed verification`))
} catch (e) {
Toast.show(_(msg`Failed to remove verification`), 'xmark')
logger.error('Failed to remove verification', {
safeMessage: e,
})
}
}, [_, profile, verifications, remove, onConfirmInner])
return (
<Prompt.Basic
control={control}
title={_(msg`Remove your verification for this account?`)}
onConfirm={onConfirm}
confirmButtonCta={_(msg`Remove verification`)}
confirmButtonColor="negative"
/>
)
}
@@ -0,0 +1,273 @@
import {View} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {urls} from '#/lib/constants'
import {getUserDisplayName} from '#/lib/getUserDisplayName'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {type FullVerificationState} from '#/components/verification'
import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
import type * as bsky from '#/types/bsky'
export {useDialogControl} from '#/components/Dialog'
export function VerificationsDialog({
control,
profile,
verificationState,
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
verificationState: FullVerificationState
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Inner
control={control}
profile={profile}
verificationState={verificationState}
/>
</Dialog.Outer>
)
}
function Inner({
profile,
control,
verificationState: state,
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
verificationState: FullVerificationState
}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const userName = getUserDisplayName(profile)
const label = state.profile.isViewer
? state.profile.isVerified
? _(msg`You are verified`)
: _(msg`Your verifications`)
: state.profile.isVerified
? _(msg`${userName} is verified`)
: _(
msg({
message: `${userName}'s verifications`,
comment: `Possessive, meaning "the verifications of {userName}"`,
}),
)
return (
<Dialog.ScrollableInner
label={label}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_2xl, a.font_bold, a.pr_4xl, a.leading_tight]}>
{label}
</Text>
<Text style={[a.text_md, a.leading_snug]}>
{state.profile.isVerified ? (
<Trans>
This account has a checkmark because it's been verified by trusted
sources.
</Trans>
) : (
<Trans>
This account has one or more attempted verifications, but it is
not currently verified.
</Trans>
)}
</Text>
</View>
{profile.verification ? (
<View style={[a.pb_xl, a.gap_md]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Verified by:</Trans>
</Text>
<View style={[a.gap_lg]}>
{profile.verification.verifications.map(v => (
<VerifierCard
key={v.uri}
verification={v}
subject={profile}
outerDialogControl={control}
/>
))}
</View>
{profile.verification.verifications.some(v => !v.isValid) &&
state.profile.isViewer && (
<Admonition type="warning" style={[a.mt_xs]}>
<Trans>Some of your verifications are invalid.</Trans>
</Admonition>
)}
</View>
) : null}
<View
style={[
a.w_full,
a.gap_sm,
a.justify_end,
gtMobile
? [a.flex_row, a.flex_row_reverse, a.justify_start]
: [a.flex_col],
]}>
<Button
label={_(msg`Close dialog`)}
size="small"
variant="solid"
color="primary"
onPress={() => {
control.close()
}}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
<Link
overridePresentation
to={urls.website.blog.initialVerificationAnnouncement}
label={_(msg`Learn more about verification on Bluesky`)}
size="small"
variant="solid"
color="secondary"
style={[a.justify_center]}
onPress={() => {
logger.metric('verification:learn-more', {
location: 'verificationsDialog',
})
}}>
<ButtonText>
<Trans>Learn more</Trans>
</ButtonText>
</Link>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function VerifierCard({
verification,
subject,
outerDialogControl,
}: {
verification: AppBskyActorDefs.VerificationView
subject: bsky.profile.AnyProfileView
outerDialogControl: Dialog.DialogControlProps
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const {data: profile, error} = useProfileQuery({did: verification.issuer})
const verificationRemovePromptControl = useDialogControl()
const canAdminister = verification.issuer === currentAccount?.did
return (
<View
style={{
opacity: verification.isValid ? 1 : 0.5,
}}>
<ProfileCard.Outer>
<ProfileCard.Header>
{error ? (
<>
<ProfileCard.AvatarPlaceholder />
<View style={[a.flex_1]}>
<Text
style={[a.text_md, a.font_bold, a.leading_snug]}
numberOfLines={1}>
<Trans>Unknown verifier</Trans>
</Text>
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{verification.issuer}
</Text>
</View>
</>
) : profile && moderationOpts ? (
<>
<ProfileCard.Link
profile={profile}
style={[a.flex_row, a.align_center, a.gap_sm, a.flex_1]}
onPress={() => {
outerDialogControl.close()
}}>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<View style={[a.flex_1]}>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{i18n.date(new Date(verification.createdAt), {
dateStyle: 'long',
})}
</Text>
</View>
</ProfileCard.Link>
{canAdminister && (
<View>
<Button
label={_(msg`Remove verification`)}
size="small"
variant="outline"
color="negative"
shape="round"
onPress={() => {
verificationRemovePromptControl.open()
}}>
<ButtonIcon icon={TrashIcon} />
</Button>
</View>
)}
</>
) : (
<>
<ProfileCard.AvatarPlaceholder />
<ProfileCard.NameAndHandlePlaceholder />
</>
)}
</ProfileCard.Header>
</ProfileCard.Outer>
<VerificationRemovePrompt
control={verificationRemovePromptControl}
profile={subject}
verifications={[verification]}
onConfirm={() => outerDialogControl.close()}
/>
</View>
)
}
@@ -0,0 +1,147 @@
import {Text as RNText, View} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {urls} from '#/lib/constants'
import {getUserDisplayName} from '#/lib/getUserDisplayName'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {VerifierCheck} from '#/components/icons/VerifierCheck'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {type FullVerificationState} from '#/components/verification'
import type * as bsky from '#/types/bsky'
export {useDialogControl} from '#/components/Dialog'
export function VerifierDialog({
control,
profile,
verificationState,
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
verificationState: FullVerificationState
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Inner
control={control}
profile={profile}
verificationState={verificationState}
/>
<Dialog.Close />
</Dialog.Outer>
)
}
function Inner({
profile,
control,
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
verificationState: FullVerificationState
}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession()
const isSelf = profile.did === currentAccount?.did
const userName = getUserDisplayName(profile)
const label = isSelf
? _(msg`You are a trusted verifier`)
: _(msg`${userName} is a trusted verifier`)
return (
<Dialog.ScrollableInner
label={label}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
<View style={[a.gap_lg]}>
<View
style={[
a.w_full,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{minHeight: 100},
]}>
<Image
accessibilityIgnoresInvertColors
source={require('../../../assets/images/initial_verification_announcement_1.png')}
style={[
{
aspectRatio: 353 / 160,
},
]}
alt={_(
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
)}
/>
</View>
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_bold, a.pr_4xl, a.leading_tight]}>
{label}
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Accounts with a scalloped blue check mark{' '}
<RNText>
<VerifierCheck width={14} />
</RNText>{' '}
can verify others. These trusted verifiers are selected by
Bluesky.
</Trans>
</Text>
</View>
<View
style={[
a.w_full,
a.gap_sm,
a.justify_end,
gtMobile ? [a.flex_row, a.justify_end] : [a.flex_col],
]}>
<Link
overridePresentation
to={urls.website.blog.initialVerificationAnnouncement}
label={_(msg`Learn more about verification on Bluesky`)}
size="small"
variant="solid"
color="primary"
style={[a.justify_center]}
onPress={() => {
logger.metric('verification:learn-more', {
location: 'verifierDialog',
})
}}>
<ButtonText>
<Trans>Learn more</Trans>
</ButtonText>
</Link>
<Button
label={_(msg`Close dialog`)}
size="small"
variant="solid"
color="secondary"
onPress={() => {
control.close()
}}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
</View>
</Dialog.ScrollableInner>
)
}
+113
View File
@@ -0,0 +1,113 @@
import {useMemo} from 'react'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import {useSession} from '#/state/session'
import type * as bsky from '#/types/bsky'
export type FullVerificationState = {
profile: {
role: 'default' | 'verifier'
isVerified: boolean
wasVerified: boolean
isViewer: boolean
showBadge: boolean
}
viewer:
| {
role: 'default'
isVerified: boolean
}
| {
role: 'verifier'
isVerified: boolean
hasIssuedVerification: boolean
}
}
export function useFullVerificationState({
profile,
}: {
profile: bsky.profile.AnyProfileView
}): FullVerificationState {
const {currentAccount} = useSession()
const currentAccountProfile = useCurrentAccountProfile()
const profileState = useSimpleVerificationState({profile})
const viewerState = useSimpleVerificationState({
profile: currentAccountProfile,
})
return useMemo(() => {
const verifications = profile.verification?.verifications || []
const wasVerified =
profileState.role === 'default' &&
!profileState.isVerified &&
verifications.length > 0
const hasIssuedVerification = Boolean(
viewerState &&
viewerState.role === 'verifier' &&
profileState.role === 'default' &&
verifications.find(v => v.issuer === currentAccount?.did),
)
return {
profile: {
...profileState,
wasVerified,
isViewer: profile.did === currentAccount?.did,
showBadge: profileState.showBadge,
},
viewer:
viewerState.role === 'verifier'
? {
role: 'verifier',
isVerified: viewerState.isVerified,
hasIssuedVerification,
}
: {
role: 'default',
isVerified: viewerState.isVerified,
},
}
}, [profile, currentAccount, profileState, viewerState])
}
export type SimpleVerificationState = {
role: 'default' | 'verifier'
isVerified: boolean
showBadge: boolean
}
export function useSimpleVerificationState({
profile,
}: {
profile?: bsky.profile.AnyProfileView
}): SimpleVerificationState {
const preferences = usePreferencesQuery()
const prefs = useMemo(
() => preferences.data?.verificationPrefs || {hideBadges: false},
[preferences.data?.verificationPrefs],
)
return useMemo(() => {
if (!profile || !profile.verification) {
return {
role: 'default',
isVerified: false,
showBadge: false,
}
}
const {verifiedStatus, trustedVerifierStatus} = profile.verification
const isVerifiedUser = ['valid', 'invalid'].includes(verifiedStatus)
const isVerifierUser = ['valid', 'invalid'].includes(trustedVerifierStatus)
const isVerified =
(isVerifiedUser && verifiedStatus === 'valid') ||
(isVerifierUser && trustedVerifierStatus === 'valid')
return {
role: isVerifierUser ? 'verifier' : 'default',
isVerified,
showBadge: prefs.hideBadges ? false : isVerified,
}
}, [profile, prefs])
}
+8
View File
@@ -192,3 +192,11 @@ export const SUPPORTED_MIME_TYPES = [
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
export const EMOJI_REACTION_LIMIT = 5
export const urls = {
website: {
blog: {
initialVerificationAnnouncement: `https://bsky.social/about/blog/04-21-2025-verification`,
},
},
}
+10
View File
@@ -0,0 +1,10 @@
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
export function getUserDisplayName<
T extends {displayName?: string; handle: string; [key: string]: any},
>(props: T): string {
return sanitizeDisplayName(
props.displayName || sanitizeHandle(props.handle, '@'),
)
}
@@ -26,10 +26,7 @@ export function KeyboardControllerProvider({
children: React.ReactNode
}) {
return (
<KeyboardProvider
enabled={false}
// I don't think this is necessary, but Chesterton's fence and all that -sfn
statusBarTranslucent={true}>
<KeyboardProvider enabled={false}>
<KeyboardControllerProviderInner>
{children}
</KeyboardControllerProviderInner>
+1
View File
@@ -13,6 +13,7 @@ export type CommonNavigatorParams = {
ModerationMutedAccounts: undefined
ModerationBlockedAccounts: undefined
ModerationInteractionSettings: undefined
ModerationVerificationSettings: undefined
Settings: undefined
Profile: {name: string; hideBackButton?: boolean}
ProfileFollowers: {name: string}
+1 -1
View File
@@ -28,7 +28,7 @@ export function isInvalidHandle(handle: string): boolean {
export function sanitizeHandle(handle: string, prefix = ''): string {
return isInvalidHandle(handle)
? '⚠Invalid Handle'
: forceLTR(`${prefix}${handle}`)
: forceLTR(`${prefix}${handle.toLocaleLowerCase()}`)
}
export interface IsValidHandle {
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

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