Compare commits

...

133 Commits

Author SHA1 Message Date
Eric Bailey 6c6c764c5d Update BUNDLE_DATE format comment 2025-07-23 17:40:15 -05:00
Eric Bailey d9cd60e1a9 Fix bad import 2025-07-23 17:12:11 -05:00
Eric Bailey df9467b644 Migrate usages of app-info to new env 2025-07-23 11:49:58 -05:00
Eric Bailey 3380a5e3ed Move env files into directory with platform specific files 2025-07-23 11:49:58 -05:00
Eric Bailey e9b1715e5e Slice bundle hash to match other platforms, needed for render.com deployments 2025-07-23 11:48:58 -05:00
Eric Bailey 8fa5c8e976 Update IS_TESTFLIGHT to include testflight-android 2025-07-23 11:46:36 -05:00
Eric Bailey 981885878b Make sure EXPO_PUBLIC_ENV is set for web container builds 2025-07-23 11:45:29 -05:00
Eric Bailey c5df6f1586 Echo variables to .env in Dockerfile instead of passing to shell script 2025-07-23 11:43:22 -05:00
Eric Bailey bb41eb14ce Properly patch Sentry package 2025-07-22 19:35:36 -05:00
Eric Bailey 8065e1827c Self review 2025-07-22 19:35:36 -05:00
Eric Bailey 70fa7a1a20 Replace NODE_ENV in places where its safe 2025-07-22 19:35:36 -05:00
Eric Bailey d0ea968022 Remove debugging field 2025-07-22 19:35:36 -05:00
Eric Bailey 7d9881f78b Prefix SENTRY_DSN with EXPO_PUBLIC to match others 2025-07-22 19:35:36 -05:00
Eric Bailey 7c916af189 Deprecate SENTRY_DIST in favor of EXPO_PUBLIC_BUNDLE_IDENTIFIER 2025-07-22 19:35:36 -05:00
Eric Bailey ad6ed83838 Fix missing bundle identifier for Render deploys 2025-07-22 19:35:36 -05:00
Eric Bailey e0397aaad9 Use existing EXPO_PUBLIC_BUNDLE_IDENTIFIER short commit has as Sentry dist value 2025-07-22 19:35:36 -05:00
Eric Bailey f492000795 Deprecate SENTRY_RELEASE since we use package.json now 2025-07-22 19:35:36 -05:00
Eric Bailey 8ccffd6542 Prefix Bitdrift key with EXPO_PUBLIC 2025-07-22 19:35:36 -05:00
Eric Bailey 9000c405ad Use new env in chat proxy header 2025-07-22 19:35:36 -05:00
Eric Bailey 9040d9101c Use new env in Bitdrift setup file 2025-07-22 19:35:36 -05:00
Eric Bailey da3da43acc Use new env in Sentry setup file 2025-07-22 19:35:36 -05:00
Eric Bailey acbbbcf725 Clean up env files 2025-07-22 19:35:36 -05:00
Caidan bbc39806fe feat(ui): remove like count from trending videos (#8689)
* feat(ui): add showLikeCount flag to CompactVideoPostCard

Introduced a showLikeCount flag to control the display of like count in
the CompactVideoPostCard component. The like count is now only shown if
both likeCount > 0 and showLikeCount are true. This allows for more
flexible UI configurations and easier toggling of like count visibility.

* fix(ui): hide gradient shadow when hiding like count

* fix(ui): increase trending video profile pic size from 20 -> 24

* fix(ui): add small drop shadow to trending video profile pic

* fix(ui): a.shadow_sm -> t.atoms.shadow_sm based on PR feedback
2025-07-22 19:16:07 -05:00
Paul Frazee 9e2b99e860 Release 1.105.0 (#8693)
* Tests fixes

* Pull latest translations
2025-07-21 20:15:29 -07:00
Chenyu fc4bf4e919 add margin between messages in the composer thread (#8691) 2025-07-21 19:49:59 -07:00
pfrazee 2a30a344b5 Nightly source-language update 2025-07-22 02:48:49 +00:00
surfdude29 4a853794dc bskyembed correct hashtag link (#8685) 2025-07-21 20:51:09 -05:00
Eric Bailey 7635a49001 introduce context for "Following" as a feed name. (#8262) (#8690)
"Following" has different translations as a name of a feed and the state of following someone in other languages like Turkish. This allows "Following" as a feed name translated correctly.

(cherry picked from commit d92a7f163ed9e28ee4ebab230758b1f00bdd9966)

Co-authored-by: Sedat Kapanoğlu <sedat@kapanoglu.com>
2025-07-21 20:49:49 -05:00
surfdude29 8ce9bdb27e tweak accessibility label (#8242) 2025-07-21 19:19:41 -05:00
surfdude29 e1a0beff7d add action context for Repost string in RepostButton.web.tsx (#8427) 2025-07-21 18:41:01 -05:00
surfdude29 e5d44923fb tweak app language names (#8436) 2025-07-21 18:40:25 -05:00
surfdude29 6d90d3c2fe remove title casing of Intl.DisplayNames language names (#8433) 2025-07-21 18:35:04 -05:00
Samuel Newman 9ebfbd0e40 Nicer error message for backend video too large error (#8596)
* nicer error message for backend video too large

* use same string for same error
2025-07-21 09:34:58 -05:00
Samuel Newman 74f7a44d71 Patch in upstream fix for video rotation on Android (#8635)
* patch in upstream fix

* build expo-image-picker from source so that patch takes
2025-07-21 09:33:59 -05:00
Samuel Newman 760b184d28 fix emoji popup scroll (#8636) 2025-07-21 09:22:35 -05:00
Samuel Newman 36fba20a01 Follow redirects for soundcloud shortlinks (#8614)
* follow redirects for soundcloud shortlinks

* clear timeout in `finally`
2025-07-21 09:18:33 -05:00
pfrazee ad16ed3096 Nightly source-language update 2025-07-19 02:43:42 +00:00
Eric Bailey 0995e81b58 Age assurance tweaks (#8671)
* Refetch state while in pending status

* Add success state to redirect, just have user close, nicer experience

* Stop refetching after pending
2025-07-18 14:21:23 -05:00
Eric Bailey 6a15679fb1 Handle missed error branch (#8663) 2025-07-18 10:19:56 -05:00
Eric Bailey 4dd9459cba Update copy (#8664) 2025-07-18 10:09:59 -05:00
Jordan Harband 95ab4fa0a1 show precise count in label/hover text (#6164) 2025-07-17 22:11:34 -07:00
Sedat Kapanoğlu cfaf741647 rename file names with colons to the common notation (#8264)
The colons in file names break Windows compatibility and prevent Bluesky from being developed on Windows. (Regression: Bluesky Git repository always shows missing files on Windows #8263)
2025-07-17 21:59:23 -07:00
pfrazee f4909e5e05 Nightly source-language update 2025-07-18 02:49:23 +00:00
Samuel Newman 08a1a806ef Fix checkmark overflow in profile card (#8612) 2025-07-17 23:03:55 +03:00
Eric Bailey 964eed54ea Age assurance fast-follows (#8656)
* Add feed banner

* Comment

* Update nux name

* Handle did error

* Hide mod settings if underage or age restricted

* Add metrics

* Remove DEV override

* Copy suggestion

* Small copy edits

* useState

* Fix bug

* Update src/components/ageAssurance/useAgeAssuranceCopy.ts

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

* Get rid of debug button

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2025-07-17 14:32:58 -05:00
Eric Bailey 00b017804b Fix bug where replying to grandparents re-orders thread (#8662)
* Fix bug where optimistic replies were inserted above the root post

* Rename variables for clarity
2025-07-17 13:22:09 -05:00
Samuel Newman d13a2e5f69 use push to convo mechanism (#8653) 2025-07-17 21:18:32 +03:00
Samuel Newman 2c0c9bf803 avoid handling notifs if deep link (#8657) 2025-07-17 21:18:16 +03:00
Samuel Newman 2e16ef4384 fix whitespace collapse (#8659) 2025-07-17 21:17:57 +03:00
Eric Bailey 42bfee5d9d Update copy and link in reporting flow (#8583)
* Update copy and link

* fix prettier

* Update link

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-07-17 11:01:10 -05:00
pfrazee 94a3e5bbe1 Nightly source-language update 2025-07-17 02:47:36 +00:00
Eric Bailey 1dbc331314 UI for age assurance compliance (#8652)
* Add geo prop

* Add prelim fetch

* Add geo debug

* Pass in assurance state to notifications registration

* Comments

* Bump git index

* Add some component utils, no design, gate chat

* Disable mod prefs buttons, does not yet edit mod prefs

* Add initial prompt component

* Refine logic for showing prompt

* Add send email dialog

* Hook up dialog to fake mutation

* Fix geo debug bug

* Move provider inside query provider

* Slightly better screen gater

* Ok decent fallback with isExempt

* Reorg

* Wrap prompt in new logic

* Override mod prefs

* Use real endpoints, optimistic state

* Add persistent card, add time-ago, warning to dialog

* Add comment

* No undefined query values

* Fix case in import

* Wait for AA to load before registering push

* Override prefs in all locations

* Small refactor of notifications registration

* Register push after aa state

* Add retries

* Update blocked screens UI

* Strengthen email validation

* Add intent dialog

* Do service auth for init

* Rug refreshJwt

* Update copy

* Some mobile styles, add dev mode option

* Fix links on native

* Clean up intent dialog on native

* Don't mutate existing session, only copy

* Handle email validation error from server

* Clarity is better

* Moar clear

* Fixes

* Tweaks

* Add country code

* Gate it

* Refresh state after redirect

* Re-check on window focus

* Remove todo

* Enable in dev

* Check for did match on redirect

* Add blocked state

* Add appeal dialog

* Copy tweaks

* Inset in blue well

* Nux the prompt

* Copy updates

* Refetch just in case

* Uppercase country code

* Align copy, add notice to chat screens

* Tweak copy

* Add test code

* Add debug code

* Refactor AccountCard

* Big refactor

* Delay post-feed queries instead

* Debug code

* Clean up state

* Reorg

* Clean up copy

* Comments

* Reorg

* UPdate URL

* Cleanup

* Remove todo

* Update debug code

* revert unneeded changes

* UPdate nux name

* Revert unneeded change

* Updaet storage schema

* Checkpoint: cleanup

* Checkpoint: almost there

* isLoaded -> isReady

* Rename useAgeAssurance

* isUnderage -> isDeclaredUnderage

* Decompose, add docblocks

* Refactor

* UPdate debug

* Apply suggestion from @surfdude29

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

* Apply suggestion from @surfdude29

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

* Apply suggestion from @surfdude29

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

* Apply suggestion from @surfdude29

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

* Apply suggestion from @surfdude29

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

* Apply suggestion from @surfdude29

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

* Drop including Bluesky

* Apply suggestion from @surfdude29

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

* Apply suggestion from @surfdude29

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

* Remove todo

* Gate debug

* Revert unneeded change

* Fail closed

* Comments

* Comment

* Comment

* fix prettier

* rm viewheader

* bump sdk

* prevent overlap in admonition

* add age assurance intent route

* Just meow

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

* Nix callback

* Fix spelling of dismissible lol

* Don't compare translated string

* Better KWS link labels

* Hide DMs send options in menu

* Add button

* Fix order

* Use only supported languages

* Rm button

* best-effort language mapping

* improve typing

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-07-16 13:58:07 -05:00
pfrazee 712c3ad421 Nightly source-language update 2025-07-16 02:47:16 +00:00
Samuel Newman 60c0689236 Fix navigation (#8648) 2025-07-15 15:53:52 +03:00
Samuel Newman 4ccbae7c30 Fix perf issue on web - restore pop behaviour to tabs (#8620) 2025-07-15 07:48:24 +03:00
pfrazee 1f82b1d557 Nightly source-language update 2025-07-15 02:49:31 +00:00
Jim Calabro c9c91fb113 omitempty on aa (#8647) 2025-07-14 16:22:41 -05:00
Jim Calabro 6375c54aa4 Merge pull request #8646 from bluesky-social/jc/aa
Fix Age Restriction Type
2025-07-14 16:24:53 -04:00
jcalabro 1da123b12b Fix Age Restriction Type 2025-07-14 16:21:06 -04:00
Samuel Newman b76f95c9bd bump version to v1.105 (#8616) 2025-07-14 10:03:28 -07:00
Samuel Newman 587c14bac8 fix who can reply icon (#8625) 2025-07-14 10:01:48 -07:00
Eric Bailey be75e00d21 Only show WhoCanReply on root post (#8645) 2025-07-14 11:37:23 -05:00
pfrazee 27d6de0b18 Nightly source-language update 2025-07-13 02:52:39 +00:00
Samuel Newman ff57e75b12 fix mention tab flicker (#8640) 2025-07-12 20:50:04 +03:00
Samuel Newman 0e3124a97e Filter activity notifs for muted words (#8637) 2025-07-11 08:49:59 -07:00
pfrazee 8f5a8f37af Nightly source-language update 2025-07-11 02:46:37 +00:00
Jim Calabro 3b329430ac Merge pull request #8634 from bluesky-social/jc/aaip
Detect Age Restricted Geos
2025-07-10 19:53:57 -04:00
Samuel Newman 377d80e21d Handle notifications on first load (#8629)
* add getInitialURL

* get deep linking working

* actually handle as push instead

* push to route instead of setting initial path

* pop to on top of notifications

* fix comment

* change chat-message handling

* add metrics

* don't reopen due to notif multiple times

* extract data from notification differently on android

* sorry sorry annoying naming nits, align logger

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-07-10 16:36:53 -05:00
jcalabro c2a5b3efa8 Detect Age Restricted Geos 2025-07-10 12:41:15 -04:00
pfrazee 22bda086d0 Nightly source-language update 2025-07-10 02:44:40 +00:00
Samuel Newman 07b028ee66 Fix quote+list card padding (#8623)
* fix quote padding not being pressable

* fix list padding not being pressable

* Fix unnecessary loading of feeds (#8578)

* stop layout shifts in feed loading

* don't load feed data if we already have it

* adjust styles, alf stuff

* remove unused button, massively simplify

* fix layout shifting in notifs

* use feedcard for feed post embeds

* use bold text to match other style

* use Link component rather than jank Pressable

* prevent nested anchors in notifs

* match following text size

* add space between content hider

* Better dead feed handling (#8579)

* add space between content hider

* add handling for feeds that fail to load

* cleanError, in case of network funkiness

* handle deleted lists

* split out missingfeed
2025-07-09 00:14:30 -07:00
Samuel Newman 1fcd313424 Translation update (#8624)
* pull translations

* extract all
2025-07-08 08:35:16 -07:00
Samuel Newman 9adcf5a12e specify autocap (#8619) 2025-07-08 05:35:46 -07:00
hailey 34291131b4 bump video lib (#8604) 2025-07-03 22:22:59 -07:00
Samuel Newman a9469cc111 Move NUX date (#8603) 2025-07-03 16:30:24 -07:00
Samuel Newman 1ed019bd72 specify mcemojipicker version (#8600) 2025-07-03 10:17:36 -07:00
pfrazee 42af417e8c Nightly source-language update 2025-07-03 02:43:45 +00:00
Samuel Newman dbfb9dad2f fix label for activity notifs (#8599) 2025-07-02 10:52:40 -07:00
Paul Frazee 9d44673acb Release 1.104.0 (#8595)
* Fix to navigation action after saving feeds

* Test updates

* Intl pull
2025-07-01 17:56:27 -07:00
Samuel Newman bc072570d2 Activity notification settings (#8485)
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: hailey <me@haileyok.com>
2025-07-01 14:36:04 -07:00
Samuel Newman 8f9a8ddce0 enforce border radius on default avatar (#8577) 2025-07-01 13:24:20 -07:00
Samuel Newman 9fdd98d8b4 Improve post spacing (#8589) 2025-07-01 13:24:03 -07:00
pfrazee 5f291b5ca8 Nightly source-language update 2025-07-01 02:51:03 +00:00
hailey 806d3792a9 feat: use t.gifs cdn for tenor search results (#8574) 2025-06-30 13:24:53 -07:00
Samuel Newman 5e20d5ffab Reduce composer font size (#8591) 2025-06-30 19:13:14 +03:00
pfrazee 130f795dfb Nightly source-language update 2025-06-29 02:49:46 +00:00
surfdude29 7837c8f07d tweak string in ContentLanguagesSettings.tsx (#8582) 2025-06-28 10:35:14 -05:00
pfrazee 3e76a4725e Nightly source-language update 2025-06-28 02:40:00 +00:00
Samuel Newman 9b306598cc tweak who can reply dialog (#8573) 2025-06-28 00:03:22 +03:00
Samuel Newman bde3fdaa42 Fix video aspect ratio error - "Record/embed/aspectRatio/width cannot be less than 1" (#8571) 2025-06-28 00:02:59 +03:00
pfrazee 494758da11 Nightly source-language update 2025-06-27 02:42:24 +00:00
Samuel Newman ad54ec8ef9 android paper text input padding (#8570) 2025-06-26 17:26:40 +03:00
Samuel Newman e5f9377a69 Virtualise labeler list (#8566)
* use flatlist for labeler list

* use key extractor

* dedupe `labelValues`

* add comment
2025-06-25 23:39:34 -07:00
pfrazee 954e7d2a89 Nightly source-language update 2025-06-26 02:41:44 +00:00
Eric Bailey 92ffe66ae1 Notifications registration (#8564)
* Formatting nits

* Debounce push token registration by 100ms

* Comment

* Align handling across native devices

* Clean up

* Simplify

* Use hooks

* Update import

* Comment

* Put app view DIDs in constants

* Clarify comment
2025-06-25 21:32:51 -05:00
Eric Bailey bb760400fe A few perf params for threads v2 (#8567)
Co-authored-by: Hailey <me@haileyok.com>
2025-06-25 15:23:09 -07:00
Samuel Newman ab4148dc06 fix pager back gesture (#8565) 2025-06-25 15:22:26 -07:00
Eric Bailey 929393a77a useEmail doesn't need a low stale time (#8568) 2025-06-25 17:22:03 -05:00
Eric Bailey 4c1515169a Tooltip (#8555)
* Working overlay, WIP

* Ok working with no overlay and global gesture handler

* Ok pretty good on native

* Cleanup

* Cleanup

* add animation

* add transform origin to animation

* Some a11y

* Improve colors

* Explicitly wrap gesture handler

* Add easier abstraction

* Web

* Fix animation

* Cleanup and remove provider

* Include demo for now

* Ok diff interface to avoid collapsed views

* Use dimensions hook

* Adjust overlap, clarify intent of consts

* Revert testing edits

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-06-24 22:03:23 -05:00
pfrazee cd820709b6 Nightly source-language update 2025-06-24 02:42:09 +00:00
hailey c894dda0bf disable new arch (#8561) 2025-06-23 10:11:17 -07:00
Samuel Newman c634cd9071 Upgrade prettier to 3.6 (#8558)
* upgrade prettier

* run prettier

* more files
2025-06-23 07:44:40 -07:00
pfrazee 3c92714e4e Nightly source-language update 2025-06-21 02:39:45 +00:00
Samuel Newman 25baf1f661 Allow disabling replies (#8537) 2025-06-20 22:44:53 +03:00
Samuel Newman dcbcd1bb80 simple heuristic for reducing footer height in thread (#8549) 2025-06-20 14:19:48 -05:00
Samuel Newman 92ee6260c1 Ignore common video errors (#8548) 2025-06-20 09:50:21 -07:00
Samuel Newman 9c7b330f92 Catch web browser errors (#8547)
* catch web browser errors

* use catch instead to ensure sheetWrapper works
2025-06-20 09:49:45 -07:00
Eric Bailey bc95c0e50c Don't send any debugs to Sentry (#8533)
* Don't send any debugs to Sentry

* Fix tests
2025-06-20 11:44:39 -05:00
Eric Bailey aeafb14fb4 Couple of Sentry cleanups (#8532)
* Temp ignore a couple logs

* Add debug for unknown notifications

* Nvm let's do this in Sentry

* Downgrade two DMs network related issues

* Check for network errors before sending in Convo

* Do the same for event bus

* Fix mistake
2025-06-20 09:30:52 -07:00
Samuel Newman 4c75b568df Android notification channels (#8539) 2025-06-20 11:01:55 +03:00
pfrazee 9702b210cc Nightly source-language update 2025-06-20 02:41:14 +00:00
Samuel Newman d53fdb0dda fix prefetch returning undefined (#8538) 2025-06-19 09:25:17 -07:00
pfrazee 9be3b0794c Nightly source-language update 2025-06-19 02:41:35 +00:00
Samuel Newman 77db98de65 bump keyboard dep (#8528) 2025-06-18 13:42:06 -07:00
Eric Bailey 51babe0164 Fix anchor text overflow in threads v2 (#8530) 2025-06-18 13:41:58 -07:00
Eric Bailey 04a2a227fc Revert "Move dialog outputs inside navigation context (#8511)" (#8531) 2025-06-18 13:39:58 -07:00
Samuel Newman 42510c0515 gate notification settings (#8527) 2025-06-18 13:36:37 -07:00
hailey f36776a91e switch mmkv version before android eas bundle (#8526) 2025-06-18 12:46:24 -07:00
Samuel Newman eb18b6a49a add scrollbars back on android (#8529) 2025-06-18 21:37:04 +03:00
Eric Bailey 3eaa2d9ddf Disable android new arch (#8525)
* Disable new arch in android for now

* Temporarily comment out mmkv script
2025-06-18 11:58:37 -05:00
Eric Bailey 066fd8cbf9 Fix select text color if system theme mismatches app theme (#8523) 2025-06-18 11:34:52 -05:00
Samuel Newman 06ebd2964a Fix linking to specific search results (#8520)
* fix linking to bsky.app/search?q=xyz

* add name to comment

* skip if notfound
2025-06-18 09:25:14 -07:00
Samuel Newman 3304cd0424 notif settings lexicon change (#8518) 2025-06-18 09:24:55 -07:00
Eric Bailey 32ba17a4f1 Fix anchor follow button alignment (#8522) 2025-06-18 09:24:27 -07:00
Eric Bailey f83335f110 Move dialog outputs inside navigation context (#8511)
* Move all dialogs within NavigationContent on native

* Ok leave old ModalsContainer alone for now

* Do the same on web

* Gate browser consent dialog just to be clear
2025-06-18 09:53:34 -05:00
Samuel Newman 4da86e5864 Modernise link warning dialog (#8243)
* add link warning dialog

* add copy for if sharing

* delete old modal

* get web working
2025-06-18 06:17:54 -07:00
Samuel Newman dd86402763 rearrange settings (#8519) 2025-06-18 05:19:42 -07:00
Frudrax Cheng 13d21692bf Mark translatable text in PreferenceControls.tsx (#8516)
* Update PreferenceControls.tsx

* Update PreferenceControls.tsx

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

* Update PreferenceControls.tsx

Co-authored-by: Minseo Lee <itoupluk427@gmail.com>

* Update PreferenceControls.tsx

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Minseo Lee <itoupluk427@gmail.com>
2025-06-18 04:57:49 -07:00
Samuel Newman e672f43ec8 fix other case of promise.all misuse (#8505) 2025-06-18 04:08:44 -07:00
pfrazee afd3d2829f Nightly source-language update 2025-06-18 02:40:54 +00:00
Eric Bailey 619fa0d0bb Delete some old dialogs (#8512)
* Delete old EditProfile dialog

* Delete old email verification dialogs
2025-06-17 18:12:37 -05:00
Samuel Newman 9cf457acf8 copy tweak (#8506) 2025-06-18 00:12:59 +03:00
hailey f1f9ca9606 facepalm (#8510) 2025-06-17 13:19:07 -07:00
314 changed files with 138364 additions and 55450 deletions
+35 -5
View File
@@ -1,8 +1,38 @@
# Copy this to `.env` and `.env.test` files
# The env the app is running in e.g. development, testflight, production
EXPO_PUBLIC_ENV=development
BITDRIFT_API_KEY=
SENTRY_AUTH_TOKEN=
EXPO_PUBLIC_LOG_LEVEL=debug
EXPO_PUBLIC_LOG_DEBUG=
# This is the short commit hash that the current bundle was made from.
EXPO_PUBLIC_BUNDLE_IDENTIFIER=
# Should be formatted YYMMDDHH so that it increases for each build.
EXPO_PUBLIC_BUNDLE_DATE=0
# The log level for the app's logger transports
EXPO_PUBLIC_LOG_LEVEL=debug
# Enable debug logs for specific logger instances
EXPO_PUBLIC_LOG_DEBUG=session
# Chat service DID
EXPO_PUBLIC_CHAT_PROXY_DID=
#
#
# Bluesky specific values
#
#
# Sentry DSN for telemetry
EXPO_PUBLIC_SENTRY_DSN=
# Bitdrift API key. If undefined, Bitdrift will be disabled.
EXPO_PUBLIC_BITDRIFT_API_KEY=
#
#
# CI and build related
#
#
# Sentry token used to authenticate source map uploads
SENTRY_AUTH_TOKEN=
+1
View File
@@ -34,6 +34,7 @@ module.exports = {
'P',
'Admonition',
'Admonition.Admonition',
'AgeAssuranceAdmonition',
'Span',
],
impliedTextProps: [],
@@ -47,8 +47,6 @@ jobs:
id: vars
run: |
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
- name: Build and push Docker image
id: build-and-push
@@ -62,8 +60,7 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
EXPO_PUBLIC_ENV=production
EXPO_PUBLIC_BUNDLE_IDENTIFIER=${{ steps.vars.outputs.sha_short }}
SENTRY_DIST=${{ steps.vars.outputs.SENTRY_DIST }}
SENTRY_RELEASE=${{ steps.vars.outputs.SENTRY_RELEASE }}
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}
+3 -13
View File
@@ -52,10 +52,6 @@ jobs:
distribution: 'temurin'
java-version: '17'
- name: "Use upgraded MMKV for Fabric"
run: |
sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' filename.txt
- name: ⚙️ Install dependencies
run: yarn install
@@ -71,17 +67,11 @@ jobs:
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "$json" > google-services.json
- name: Setup Sentry vars for build-time injection
id: sentry
run: |
echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
- name: 🏗️ EAS Build
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
run: SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }} yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
- name: ✍️ Rename Testflight bundle
if: ${{ inputs.profile != 'production' }}
@@ -143,7 +133,7 @@ jobs:
- name: 🏗️ Build Production APK
if: ${{ inputs.profile == 'production' }}
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
run: SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }} 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
+2 -8
View File
@@ -79,17 +79,11 @@ jobs:
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: Setup Sentry vars for build-time injection
id: sentry
run: |
echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
- name: 🏗️ EAS Build
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive
run: SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }} yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa
+4 -14
View File
@@ -107,18 +107,12 @@ jobs:
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "$json" > google-services.json
- name: Setup Sentry vars for build-time injection
id: sentry
run: |
echo "SENTRY_DIST=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "SENTRY_RELEASE=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
- name: 🏗️ Create Bundle
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
run: SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
- name: 📦 Package Bundle and 🚀 Deploy
if: ${{ !steps.fingerprint.outputs.includes-changes }}
@@ -209,7 +203,7 @@ jobs:
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
@@ -275,10 +269,6 @@ jobs:
distribution: 'temurin'
java-version: '17'
- name: "Use upgraded MMKV for Fabric"
run: |
sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' filename.txt
- name: ⚙️ Install dependencies
run: yarn install
@@ -291,7 +281,7 @@ jobs:
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "$json" > google-services.json
- name: 🏗️ EAS Build
+14 -13
View File
@@ -19,28 +19,26 @@ ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
# The latest git hash of the preview branch on render.com
# https://render.com/docs/docker-secrets#environment-variables-in-docker-builds
ARG RENDER_GIT_COMMIT
#
# Expo
#
ARG EXPO_PUBLIC_ENV
ENV EXPO_PUBLIC_ENV=${EXPO_PUBLIC_ENV:-development}
ARG EXPO_PUBLIC_BUNDLE_IDENTIFIER
ENV EXPO_PUBLIC_BUNDLE_IDENTIFIER=${EXPO_PUBLIC_BUNDLE_IDENTIFIER:-dev}
# The latest git hash of the preview branch on render.com
ARG RENDER_GIT_COMMIT
# If not set by GitHub workflows, we're probably in Render
ENV EXPO_PUBLIC_BUNDLE_IDENTIFIER=${EXPO_PUBLIC_BUNDLE_IDENTIFIER:-$RENDER_GIT_COMMIT}
#
# Sentry
#
ARG SENTRY_AUTH_TOKEN
ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN:-unknown}
# Will fall back to package.json#version, but this is handled elsewhere
ARG SENTRY_RELEASE
ENV SENTRY_RELEASE=$SENTRY_RELEASE
ARG SENTRY_DIST
# Default to RENDER_GIT_COMMIT if not set by GitHub workflows
ENV SENTRY_DIST=${SENTRY_DIST:-$RENDER_GIT_COMMIT}
ARG SENTRY_DSN
ENV SENTRY_DSN=$SENTRY_DSN
ARG EXPO_PUBLIC_SENTRY_DSN
ENV EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN
#
# Copy everything into the container
@@ -60,13 +58,16 @@ RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
echo "Using bundle identifier: $EXPO_PUBLIC_BUNDLE_IDENTIFIER" && \
echo "EXPO_PUBLIC_ENV=$EXPO_PUBLIC_ENV" >> .env && \
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER" >> .env && \
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env && \
echo "EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN" >> .env && \
echo "SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN" >> .env && \
npm install --global yarn && \
yarn && \
yarn intl:build 2>&1 | tee i18n.log && \
if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compile errors!\n\n"; fi && \
EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER EXPO_PUBLIC_BUNDLE_DATE=$() SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN SENTRY_RELEASE=$SENTRY_RELEASE SENTRY_DIST=$SENTRY_DIST SENTRY_DSN=$SENTRY_DSN yarn build-web
yarn build-web
# DEBUG
RUN find ./bskyweb/static && find ./web-build/static
-1
View File
@@ -28,7 +28,6 @@ appId: xyz.blueskyweb.app
- hideKeyboard
- tapOn:
id: "nextBtn"
- tapOn: "Not now"
- inputText: "e2e-test"
- tapOn:
id: "nextBtn"
+1 -1
View File
@@ -135,7 +135,7 @@ appId: xyz.blueskyweb.app
id: "e2eGotoLists"
- tapOn: "Good Ppl"
# - tapOn: "People"
- tapOn: "People"
- assertVisible: "View Bob's profile"
- tapOn:
point: "90%,43%"
-1
View File
@@ -23,5 +23,4 @@ appId: xyz.blueskyweb.app
id: "loginPasswordInput"
- inputText: "hunter2"
- pressKey: Enter
- tapOn: "Not now"
- assertVisible: "Following"
+2
View File
@@ -25,6 +25,8 @@ appId: xyz.blueskyweb.app
# Can follow/unfollow another user
- tapOn:
id: "followBtn"
- tapOn:
point: 65%,35% # dismiss the subscribe prompt
- tapOn:
id: "unfollowBtn"
+16
View File
@@ -6,6 +6,7 @@ import {
createStarterPackLinkFromAndroidReferrer,
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
@@ -997,3 +998,18 @@ describe('createStarterPackGooglePlayUri', () => {
expect(createStarterPackGooglePlayUri(undefined, 'rkey')).toEqual(null)
})
})
describe('tenorUrlToBskyGifUrl', () => {
const inputs = [
'https://media.tenor.com/someID_AAAAC/someName.gif',
'https://media.tenor.com/someID/someName.gif',
]
it.each(inputs)(
'returns url with t.gifs.bsky.app as hostname for input url',
input => {
const out = tenorUrlToBskyGifUrl(input)
expect(out.startsWith('https://t.gifs.bsky.app/')).toEqual(true)
},
)
})
+3 -3
View File
@@ -29,8 +29,8 @@ module.exports = function (_config) {
const UPDATES_CHANNEL = IS_TESTFLIGHT
? 'testflight'
: IS_PRODUCTION
? 'production'
: undefined
? 'production'
: undefined
const UPDATES_ENABLED = !!UPDATES_CHANNEL
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
@@ -219,7 +219,7 @@ module.exports = function (_config) {
compileSdkVersion: 35,
targetSdkVersion: 35,
buildToolsVersion: '35.0.0',
newArchEnabled: true,
newArchEnabled: false,
},
},
],
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a1 1 0 0 1 0 2 5.85 5.85 0 0 0-5.802 5.08L5.143 17h13.715l-.382-2.868-.01-.102a1 1 0 0 1 1.973-.262l.02.1.532 4a1 1 0 0 1-.99 1.132h-3.357c-.905 1.747-2.606 3-4.644 3s-3.74-1.253-4.643-3H4a1 1 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.61.637 1.397 1 2.22 1s1.611-.363 2.22-1H9.78ZM17 2.5a1 1 0 0 1 1 1V6h2.5a1 1 0 0 1 0 2H18v2.5a1 1 0 0 1-2 0V8h-2.5a1 1 0 1 1 0-2H16V3.5a1 1 0 0 1 1-1Z"/></svg>

After

Width:  |  Height:  |  Size: 511 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a7.854 7.854 0 0 1 7.784 6.815l1.207 9.053a1 1 0 0 1-.99 1.132h-3.354c-.904 1.748-2.608 3-4.647 3-2.038 0-3.742-1.252-4.646-3H4a1.002 1.002 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.608.637 1.398 1 2.221 1s1.613-.363 2.222-1H9.779ZM3.193 2.104a1 1 0 0 1 1.53 1.288A9.5 9.5 0 0 0 2.72 7.464a1 1 0 0 1-1.954-.427 11.46 11.46 0 0 1 2.428-4.933Zm16.205-.122a1 1 0 0 1 1.409.122 11.5 11.5 0 0 1 2.429 4.933 1 1 0 0 1-1.954.427 9.5 9.5 0 0 0-2.006-4.072 1 1 0 0 1 .122-1.41Z"/></svg>

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+1 -1
View File
@@ -160,7 +160,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
richText.push(
<Link
key={counter}
href={`/tag/${segment.tag.tag}`}
href={`/hashtag/${segment.tag.tag}`}
className="text-blue-500 hover:underline">
{segment.text}
</Link>,
+1 -3
View File
@@ -1,9 +1,7 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: ['variant', [
'&:is(.dark *):not(:is(.dark .light *))',
]],
darkMode: ['variant', ['&:is(.dark *):not(:is(.dark .light *))']],
theme: {
extend: {
colors: {
-1
View File
@@ -1,4 +1,3 @@
{
"compilerOptions": {
"target": "ES5",
+11 -8
View File
@@ -1,27 +1,30 @@
import assert from 'assert'
import {
Kysely,
KyselyPlugin,
type KyselyPlugin,
Migrator,
PluginTransformQueryArgs,
PluginTransformResultArgs,
type PluginTransformQueryArgs,
type PluginTransformResultArgs,
PostgresDialect,
QueryResult,
RootOperationNode,
UnknownRow,
type QueryResult,
type RootOperationNode,
type UnknownRow,
} from 'kysely'
import {default as Pg} from 'pg'
import {dbLogger as log} from '../logger.js'
import {default as migrations} from './migrations/index.js'
import {DbMigrationProvider} from './migrations/provider.js'
import {DbSchema} from './schema.js'
import {type DbSchema} from './schema.js'
export class Database {
migrator: Migrator
destroyed = false
constructor(public db: Kysely<DbSchema>, public cfg: PgConfig) {
constructor(
public db: Kysely<DbSchema>,
public cfg: PgConfig,
) {
this.migrator = new Migrator({
db,
migrationTableSchema: cfg.schema,
+7 -4
View File
@@ -1,11 +1,11 @@
import events from 'node:events'
import http from 'node:http'
import type http from 'node:http'
import cors from 'cors'
import express from 'express'
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
import {createHttpTerminator, type HttpTerminator} from 'http-terminator'
import {Config} from './config.js'
import {type Config} from './config.js'
import {AppContext} from './context.js'
import {default as routes, errorHandler} from './routes/index.js'
@@ -17,7 +17,10 @@ export class LinkService {
public server?: http.Server
private terminator?: HttpTerminator
constructor(public app: express.Application, public ctx: AppContext) {}
constructor(
public app: express.Application,
public ctx: AppContext,
) {}
static async create(cfg: Config): Promise<LinkService> {
let app = express()
+7 -4
View File
@@ -1,10 +1,10 @@
import events from 'node:events'
import http from 'node:http'
import type http from 'node:http'
import express from 'express'
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
import {createHttpTerminator, type HttpTerminator} from 'http-terminator'
import {Config} from './config.js'
import {type Config} from './config.js'
import {AppContext} from './context.js'
import {default as routes, errorHandler} from './routes/index.js'
@@ -15,7 +15,10 @@ export class CardService {
public server?: http.Server
private terminator?: HttpTerminator
constructor(public app: express.Application, public ctx: AppContext) {}
constructor(
public app: express.Application,
public ctx: AppContext,
) {}
static async create(cfg: Config): Promise<CardService> {
let app = express()
+5 -1
View File
@@ -258,6 +258,7 @@ func serve(cctx *cli.Context) error {
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric)
e.GET("/notifications/activity", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric)
@@ -275,6 +276,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/appearance", server.WebGeneric)
e.GET("/settings/account", server.WebGeneric)
e.GET("/settings/privacy-and-security", server.WebGeneric)
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
e.GET("/settings/content-and-media", server.WebGeneric)
e.GET("/settings/interests", server.WebGeneric)
e.GET("/settings/about", server.WebGeneric)
@@ -300,6 +302,7 @@ func serve(cctx *cli.Context) error {
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric)
e.GET("/intent/verify-email", server.WebGeneric)
e.GET("/intent/age-assurance", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
@@ -601,7 +604,8 @@ type IPCCRequest struct {
IP string `json:"ip"`
}
type IPCCResponse struct {
CC string `json:"countryCode"`
CC string `json:"countryCode"`
AgeRestrictedGeo bool `json:"isAgeRestrictedGeo,omitempty"`
}
func (srv *Server) WebIpCC(c echo.Context) error {
+2 -2
View File
@@ -89,9 +89,9 @@ If you change `SENTRY_AUTH_TOKEN`, you need to do `yarn prebuild` before running
### Adding bitdrift
Adding bitdirft is NOT required. You can keep `BITDRIFT_API_KEY=` in `.env` which will avoid initializing bitdrift during startup.
Adding bitdrift is NOT required. You can keep `EXPO_PUBLIC_BITDRIFT_API_KEY=` in `.env` which will avoid initializing bitdrift during startup.
However, if you're a part of the Bluesky team and want to enable bitdrift, fill in `BITDRIFT_API_KEY` in your `.env` to enable bitdrift.
However, if you're a part of the Bluesky team and want to enable bitdrift, fill in `EXPO_PUBLIC_BITDRIFT_API_KEY` in your `.env` to enable bitdrift.
### Adding and Updating Locales
@@ -15,6 +15,8 @@ class BackgroundNotificationHandler(
if (remoteMessage.data["reason"] == "chat-message") {
mutateWithChatMessage(remoteMessage)
} else {
mutateWithOtherReason(remoteMessage)
}
notifInterface.showMessage(remoteMessage)
@@ -39,4 +41,17 @@ class BackgroundNotificationHandler(
// TODO - Remove this once we have more backend capability
remoteMessage.data["badge"] = null
}
private fun mutateWithOtherReason(remoteMessage: RemoteMessage) {
// If oreo or higher
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
// If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post"
// assign to it's eponymous channel. otherwise do nothing, let expo handle it
when (remoteMessage.data["reason"]) {
"like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post" -> {
remoteMessage.data["channelId"] = remoteMessage.data["reason"]
}
}
}
}
}
@@ -14,7 +14,7 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MCEmojiPicker'
s.dependency 'MCEmojiPicker', '1.2.3'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
@@ -12,7 +12,7 @@ const EmojiPicker = ({onEmojiSelected}: EmojiPickerViewProps) => {
flex: 1,
width: '100%',
backgroundColor: scheme === 'dark' ? '#000' : '#fff',
} as const),
}) as const,
[scheme],
)
+9 -8
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.104.0",
"version": "1.105.0",
"private": true,
"engines": {
"node": ">=20"
@@ -12,7 +12,8 @@
"buildFromSource": [
"expo-notifications",
"expo-haptics",
"expo-media-library"
"expo-media-library",
"expo-image-picker"
]
}
}
@@ -69,7 +70,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.15.15",
"@atproto/api": "^0.15.26",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -85,7 +86,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@haileyok/bluesky-video": "0.3.1",
"@haileyok/bluesky-video": "0.3.2",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/react": "^4.14.1",
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
@@ -190,9 +191,9 @@
"react-native-gesture-handler": "2.25.0",
"react-native-get-random-values": "~1.11.0",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.17.1",
"react-native-keyboard-controller": "^1.17.5",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "^6.7.1",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "~3.17.5",
@@ -218,7 +219,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.142",
"@atproto/dev-env": "^0.3.155",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
@@ -265,7 +266,7 @@
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
"metro-react-native-babel-preset": "^0.77.0",
"prettier": "^2.8.3",
"prettier": "^3.6.0",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
"svgo": "^3.3.2",
+2 -2
View File
@@ -1,5 +1,5 @@
diff --git a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
index c282ade..8777755 100755
index c282ade..e6de3ff 100755
--- a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
+++ b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
@@ -215,7 +215,7 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
@@ -7,7 +7,7 @@ index c282ade..8777755 100755
const isHermes = assets.find(asset => asset.endsWith('.hbc'));
const windowsCallback = process.platform === "win32" ? 'node ' : '';
- execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')}`, {
+ execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')} --dist ${process.env.SENTRY_DIST}`, {
+ execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')} --dist ${process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER}`, {
env: {
...process.env,
[SENTRY_PROJECT]: sentryProject,
+38
View File
@@ -0,0 +1,38 @@
diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
index c863fb8..cde8859 100644
--- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
+++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
@@ -101,16 +101,30 @@ internal class MediaHandler(
val fileData = getAdditionalFileData(sourceUri)
val mimeType = getType(context.contentResolver, sourceUri)
+ // Extract basic metadata
+ var width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
+ var height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
+ val rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
+
+ // Android returns the encoded width/height which do not take the display rotation into
+ // account. For videos recorded in portrait mode the encoded dimensions are often landscape
+ // (e.g. 1920x1080) paired with a 90°/270° rotation flag. iOS adjusts these values before
+ // reporting them, so to keep the behaviour consistent across platforms we swap the width
+ // and height when the rotation indicates the video should be displayed in portrait.
+ if (rotation % 180 != 0) {
+ width = height.also { height = width }
+ }
+
return ImagePickerAsset(
type = MediaType.VIDEO,
uri = outputUri.toString(),
- width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH),
- height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT),
+ width = width,
+ height = height,
fileName = fileData?.fileName,
fileSize = fileData?.fileSize,
mimeType = mimeType,
duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION),
- rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION),
+ rotation = rotation,
assetId = sourceUri.getMediaStoreAssetId()
)
} catch (cause: FailedToExtractVideoMetadataException) {
@@ -0,0 +1,5 @@
# Expo Image Picker patch
Cherry-picked https://github.com/expo/expo/pull/37849
Remove when we update to a version that includes this commit.
+3 -4
View File
@@ -9,10 +9,9 @@ const templateFile = path.join(
'scripts.html',
)
const {entrypoints} = require(path.join(
projectRoot,
'web-build/asset-manifest.json',
))
const {entrypoints} = require(
path.join(projectRoot, 'web-build/asset-manifest.json'),
)
console.log(`Found ${entrypoints.length} entrypoints`)
console.log(`Writing ${templateFile}`)
+46 -41
View File
@@ -17,6 +17,7 @@ import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native'
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
@@ -25,6 +26,7 @@ import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {listenSessionDropped} from '#/state/events'
@@ -33,6 +35,7 @@ import {
ensureGeolocationResolved,
Provider as GeolocationProvider,
} from '#/state/geolocation'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
@@ -72,7 +75,6 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
SplashScreen.preventAutoHideAsync()
if (isIOS) {
@@ -94,7 +96,6 @@ function InnerApp() {
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
const hasCheckedReferrer = useStarterPackEntry()
// init
@@ -136,45 +137,49 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<ComposerProvider>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
<StatsigProvider>
<AgeAssuranceProvider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</VideoVolumeProvider>
+40 -37
View File
@@ -15,6 +15,7 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {listenSessionDropped} from '#/state/events'
@@ -116,43 +117,45 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<ComposerProvider>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
<StatsigProvider>
<AgeAssuranceProvider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceProvider>
</StatsigProvider>
</QueryProvider>
<ToastContainer />
</React.Fragment>
+156 -56
View File
@@ -1,4 +1,6 @@
import * as React from 'react'
import {useCallback, useRef} from 'react'
import {Linking} from 'react-native'
import * as Notifications from 'expo-notifications'
import {i18n, type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/macro'
import {
@@ -10,13 +12,21 @@ import {
createNavigationContainerRef,
DarkTheme,
DefaultTheme,
type LinkingOptions,
NavigationContainer,
StackActions,
} from '@react-navigation/native'
import {timeout} from '#/lib/async/timeout'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {
getNotificationPayload,
type NotificationPayload,
notificationToURL,
storePayloadForAccountSwitch,
} from '#/lib/hooks/useNotificationHandler'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {logger as notyLogger} from '#/lib/notifications/util'
import {buildStateObject} from '#/lib/routes/helpers'
import {
type AllNavigatorParams,
@@ -71,6 +81,7 @@ 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 {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
@@ -84,6 +95,7 @@ import {SearchScreen} from '#/screens/Search'
import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings'
import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings'
import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings'
import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacySettings'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
@@ -92,6 +104,18 @@ import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPr
import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings'
import {LikeNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikeNotificationSettings'
import {LikesOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
import {MentionNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MentionNotificationSettings'
import {MiscellaneousNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings'
import {NewFollowerNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/NewFollowerNotificationSettings'
import {QuoteNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/QuoteNotificationSettings'
import {ReplyNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ReplyNotificationSettings'
import {RepostNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostNotificationSettings'
import {RepostsOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings'
import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
import {SettingsScreen} from '#/screens/Settings/Settings'
import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
@@ -109,17 +133,10 @@ import {
} from '#/components/dialogs/EmailDialog'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings'
import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings'
import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings'
import {LikesOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
import {MentionNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MentionNotificationSettings'
import {MiscellaneousNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MiscellaneousNotificationSettings'
import {NewFollowerNotificationSettingsScreen} from './screens/Settings/NotificationSettings/NewFollowerNotificationSettings'
import {QuoteNotificationSettingsScreen} from './screens/Settings/NotificationSettings/QuoteNotificationSettings'
import {ReplyNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ReplyNotificationSettings'
import {RepostNotificationSettingsScreen} from './screens/Settings/NotificationSettings/RepostNotificationSettings'
import {RepostsOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings'
import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher'
import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback'
import {useLoggedOutViewControls} from './state/shell/logged-out'
import {useCloseAllActiveElements} from './state/util'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -390,6 +407,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="ActivityPrivacySettings"
getComponent={() => ActivityPrivacySettingsScreen}
options={{
title: title(msg`Privacy and Security`),
requireAuth: true,
}}
/>
<Stack.Screen
name="NotificationSettings"
getComponent={() => NotificationSettingsScreen}
@@ -447,7 +472,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
name="LikesOnRepostsNotificationSettings"
getComponent={() => LikesOnRepostsNotificationSettingsScreen}
options={{
title: title(msg`Likes on your reposts notifications`),
title: title(msg`Likes of your reposts notifications`),
requireAuth: true,
}}
/>
@@ -455,7 +480,15 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
name="RepostsOnRepostsNotificationSettings"
getComponent={() => RepostsOnRepostsNotificationSettingsScreen}
options={{
title: title(msg`Reposts on your reposts notifications`),
title: title(msg`Reposts of your reposts notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="ActivityNotificationSettings"
getComponent={() => ActivityNotificationSettingsScreen}
options={{
title: title(msg`Activity notifications`),
requireAuth: true,
}}
/>
@@ -524,6 +557,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesInboxScreen}
options={{title: title(msg`Chat request inbox`), requireAuth: true}}
/>
<Stack.Screen
name="NotificationsActivityList"
getComponent={() => NotificationsActivityListScreen}
options={{title: title(msg`Notifications`), requireAuth: true}}
/>
<Stack.Screen
name="LegacyNotificationSettings"
getComponent={() => LegacyNotificationSettingsScreen}
@@ -571,7 +609,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
* in 3 distinct tab-stacks with a different root screen on each.
*/
function TabsNavigator() {
const tabBar = React.useCallback(
const tabBar = useCallback(
(props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
<BottomBar {...props} />
),
@@ -738,6 +776,7 @@ const FlatNavigator = () => {
const LINKING = {
// TODO figure out what we are going to use
// note: `bluesky://` is what is used in app.config.js
prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
getPathFromState(state: State) {
@@ -794,13 +833,107 @@ const LINKING = {
return res
}
},
}
} satisfies LinkingOptions<AllNavigatorParams>
/**
* Used to ensure we don't handle the same notification twice
*/
let lastHandledNotificationDateDedupe: number | undefined
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
const {currentAccount} = useSession()
const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
const {currentAccount, accounts} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const prevLoggedRouteName = useRef<string | undefined>(undefined)
const emailDialogControl = useEmailDialogControl()
const closeAllActiveElements = useCloseAllActiveElements()
/**
* Handle navigation to a conversation, or prepares for account switch.
*
* Non-reactive because we need the latest data from some hooks
* after an async call - sfn
*/
const handleChatMessage = useNonReactiveCallback(
(payload: Extract<NotificationPayload, {reason: 'chat-message'}>) => {
notyLogger.debug(`handleChatMessage`, {payload})
if (payload.recipientDid !== currentAccount?.did) {
// handled in useNotificationHandler after account switch finishes
storePayloadForAccountSwitch(payload)
closeAllActiveElements()
const account = accounts.find(a => a.did === payload.recipientDid)
if (account) {
onPressSwitchAccount(account, 'Notification')
} else {
setShowLoggedOut(true)
}
} else {
// @ts-expect-error nested navigators aren't typed -sfn
navigate('MessagesTab', {
screen: 'Messages',
params: {
pushToConversation: payload.convoId,
},
})
}
},
)
async function handlePushNotificationEntry() {
if (!isNative) return
// deep links take precedence - on android,
// getLastNotificationResponseAsync returns a "notification"
// that is actually a deep link. avoid handling it twice -sfn
if (await Linking.getInitialURL()) {
return
}
/**
* The notification that caused the app to open, if applicable
*/
const response = await Notifications.getLastNotificationResponseAsync()
if (response) {
notyLogger.debug(`handlePushNotificationEntry: response`, {response})
if (response.notification.date === lastHandledNotificationDateDedupe)
return
lastHandledNotificationDateDedupe = response.notification.date
const payload = getNotificationPayload(response.notification)
if (payload) {
notyLogger.metric(
'notifications:openApp',
{reason: payload.reason, causedBoot: true},
{statsig: false},
)
if (payload.reason === 'chat-message') {
handleChatMessage(payload)
} else {
const path = notificationToURL(payload)
if (path === '/notifications') {
resetToTab('NotificationsTab')
notyLogger.debug(`handlePushNotificationEntry: default navigate`)
} else if (path) {
const [screen, params] = router.matchPath(path)
// @ts-expect-error nested navigators aren't typed -sfn
navigate('HomeTab', {screen, params})
notyLogger.debug(`handlePushNotificationEntry: navigate`, {
screen,
params,
})
}
}
}
}
}
function onReady() {
prevLoggedRouteName.current = getCurrentRouteName()
@@ -821,9 +954,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
onStateChange={() => {
logger.metric(
'router:navigate',
{
from: prevLoggedRouteName.current,
},
{from: prevLoggedRouteName.current},
{statsig: false},
)
prevLoggedRouteName.current = getCurrentRouteName()
@@ -833,6 +964,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
logModuleInitTime()
onReady()
logger.metric('router:navigate', {}, {statsig: false})
handlePushNotificationEntry()
}}
// WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
// However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
@@ -882,7 +1014,9 @@ function navigate<K extends keyof AllNavigatorParams>(
return Promise.resolve()
}
function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
function resetToTab(
tabName: 'HomeTab' | 'SearchTab' | 'MessagesTab' | 'NotificationsTab',
) {
if (navigationRef.isReady()) {
navigate(tabName)
if (navigationRef.canGoBack()) {
@@ -915,39 +1049,6 @@ function reset(): Promise<void> {
}
}
function handleLink(url: string) {
let path
if (url.startsWith('/')) {
path = url
} else if (url.startsWith('http')) {
try {
path = new URL(url).pathname
} catch (e) {
console.error('Invalid url', url, e)
return
}
} else {
console.error('Invalid url', url)
return
}
const [name, params] = router.matchPath(path)
if (isNative) {
if (name === 'Search') {
resetToTab('SearchTab')
} else if (name === 'Notifications') {
resetToTab('NotificationsTab')
} else {
resetToTab('HomeTab')
// @ts-ignore matchPath doesnt give us type-checked output -prf
navigate(name, params)
}
} else {
// @ts-ignore matchPath doesnt give us type-checked output -prf
navigate(name, params)
}
}
let didInit = false
function logModuleInitTime() {
if (didInit) {
@@ -988,7 +1089,6 @@ function logModuleInitTime() {
export {
FlatNavigator,
handleLink,
navigate,
reset,
resetToTab,
+4
View File
@@ -983,6 +983,10 @@ export const atoms = {
transition_none: web({
transitionProperty: 'none',
}),
transition_timing_default: web({
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_all: web({
transitionProperty: 'all',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
+3 -3
View File
@@ -190,7 +190,7 @@ export function Root({children}: {children: React.ReactNode}) {
if (item) playHaptic('Light')
setHoveredMenuItem(item)
},
} satisfies ContextType),
}) satisfies ContextType,
[
measurement,
setMeasurement,
@@ -710,8 +710,8 @@ export function Item({
const xOffset = position
? position.x
: align === 'left'
? measurement.x
: measurement.x + measurement.width - layout.width
? measurement.x
: measurement.x + measurement.width - layout.width
registerHoverable(
id,
+2 -9
View File
@@ -1,18 +1,11 @@
import {View} from 'react-native'
import {atoms as a, flatten, useTheme, ViewStyleProp} from '#/alf'
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
export function Divider({style}: ViewStyleProp) {
const t = useTheme()
return (
<View
style={[
a.w_full,
a.border_t,
t.atoms.border_contrast_low,
flatten(style),
]}
/>
<View style={[a.w_full, a.border_t, t.atoms.border_contrast_low, style]} />
)
}
+1 -1
View File
@@ -214,7 +214,7 @@ export function DescriptionPlaceholder() {
export function Likes({count}: {count: number}) {
const t = useTheme()
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.font_bold]}>
<Trans>
Liked by <Plural value={count || 0} one="# user" other="# users" />
</Trans>
+50
View File
@@ -0,0 +1,50 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {APP_LANGUAGES} from '#/locale/languages'
import * as Select from '#/components/Select'
export function LanguageSelect({
value,
onChange,
items = APP_LANGUAGES.map(l => ({
label: l.name,
value: l.code2,
})),
}: {
value?: string
onChange: (value: string) => void
items?: {label: string; value: string}[]
}) {
const {_} = useLingui()
const handleOnChange = React.useCallback(
(value: string) => {
if (!value) return
onChange(sanitizeAppLanguageSetting(value))
},
[onChange],
)
return (
<Select.Root
value={value ? sanitizeAppLanguageSetting(value) : undefined}
onValueChange={handleOnChange}>
<Select.Trigger label={_(msg`Select language`)}>
<Select.ValueText placeholder={_(msg`Select language`)} />
<Select.Icon />
</Select.Trigger>
<Select.Content
renderItem={({label, value}) => (
<Select.Item value={value} label={label}>
<Select.ItemIndicator />
<Select.ItemText>{label}</Select.ItemText>
</Select.Item>
)}
items={items}
/>
</Select.Root>
)
}
+57 -22
View File
@@ -9,7 +9,7 @@ import {
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {type AllNavigatorParams} from '#/lib/routes/types'
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {
convertBskyAppUrlIfNeeded,
@@ -24,6 +24,7 @@ import {Button, type ButtonProps} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text, type TextProps} from '#/components/Typography'
import {router} from '#/routes'
import {useGlobalDialogsControlContext} from './dialogs/Context'
/**
* Only available within a `Link`, since that inherits from `Button`.
@@ -98,10 +99,10 @@ export function useLink({
return typeof to === 'string'
? convertBskyAppUrlIfNeeded(sanitizeUrl(to))
: to.screen
? router.matchName(to.screen)?.build(to.params)
: to.href
? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
: undefined
? router.matchName(to.screen)?.build(to.params)
: to.href
? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
: undefined
}, [to])
if (!href) {
@@ -111,7 +112,8 @@ export function useLink({
}
const isExternal = isExternalUrl(href)
const {openModal, closeModal} = useModalControls()
const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const openLink = useOpenLink()
const onPress = React.useCallback(
@@ -132,10 +134,9 @@ export function useLink({
}
if (requiresWarning) {
openModal({
name: 'link-warning',
text: displayText,
href: href,
linkWarningDialogControl.open({
displayText,
href,
})
} else {
if (isExternal) {
@@ -154,15 +155,44 @@ export function useLink({
} else {
closeModal() // close any active modals
const [screen, params] = router.matchPath(href) as [
screen: keyof AllNavigatorParams,
params?: RouteParams,
]
// does not apply to web's flat navigator
if (isNative && screen !== 'NotFound') {
const state = navigation.getState()
// if screen is not in the current navigator, it means it's
// most likely a tab screen
if (!state.routeNames.includes(screen)) {
const parent = navigation.getParent()
if (
parent &&
parent.getState().routeNames.includes(`${screen}Tab`)
) {
// yep, it's a tab screen. i.e. SearchTab
// thus we need to navigate to the child screen
// via the parent navigator
// see https://reactnavigation.org/docs/upgrading-from-6.x/#changes-to-the-navigate-action
// TODO: can we support the other kinds of actions? push/replace -sfn
// @ts-expect-error include does not narrow the type unfortunately
parent.navigate(`${screen}Tab`, {screen, params})
return
} else {
// will probably fail, but let's try anyway
}
}
}
if (action === 'push') {
navigation.dispatch(StackActions.push(...router.matchPath(href)))
navigation.dispatch(StackActions.push(screen, params))
} else if (action === 'replace') {
navigation.dispatch(
StackActions.replace(...router.matchPath(href)),
)
navigation.dispatch(StackActions.replace(screen, params))
} else if (action === 'navigate') {
// @ts-ignore
navigation.navigate(...router.matchPath(href))
// @ts-expect-error not typed
navigation.navigate(screen, params, {pop: true})
} else {
throw Error('Unsupported navigator action.')
}
@@ -176,13 +206,13 @@ export function useLink({
displayText,
isExternal,
href,
openModal,
openLink,
closeModal,
action,
navigation,
overridePresentation,
shouldProxy,
linkWarningDialogControl,
],
)
@@ -195,16 +225,21 @@ export function useLink({
)
if (requiresWarning) {
openModal({
name: 'link-warning',
text: displayText,
href: href,
linkWarningDialogControl.open({
displayText,
href,
share: true,
})
} else {
shareUrl(href)
}
}, [disableMismatchWarning, displayText, href, isExternal, openModal])
}, [
disableMismatchWarning,
displayText,
href,
isExternal,
linkWarningDialogControl,
])
const onLongPress = React.useCallback(
(e: GestureResponderEvent) => {
+7 -5
View File
@@ -1,10 +1,10 @@
import React from 'react'
import {View} from 'react-native'
import {
AppBskyGraphDefs,
type AppBskyGraphDefs,
AtUri,
moderateUserList,
ModerationUI,
type ModerationUI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -22,10 +22,10 @@ import {
Outer,
SaveButton,
} from '#/components/FeedCard'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import type * as bsky from '#/types/bsky'
/*
* This component is based on `FeedCard` and is tightly coupled with that
@@ -50,7 +50,9 @@ type Props = {
showPinButton?: boolean
}
export function Default(props: Props) {
export function Default(
props: Props & Omit<LinkProps, 'to' | 'label' | 'children'>,
) {
const {view, showPinButton} = props
const moderationOpts = useModerationOpts()
const moderation = moderationOpts
+22 -21
View File
@@ -1,10 +1,9 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import {useMemo} from 'react'
import {moderateFeedGenerator} from '@atproto/api'
import {usePalette} from '#/lib/hooks/usePalette'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
import {atoms as a, useTheme} from '#/alf'
import * as FeedCard from '#/components/FeedCard'
import {ContentHider} from '#/components/moderation/ContentHider'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
@@ -14,13 +13,22 @@ export function FeedEmbed({
}: CommonProps & {
embed: EmbedType<'feed'>
}) {
const pal = usePalette('default')
const t = useTheme()
return (
<FeedSourceCard
feedUri={embed.view.uri}
style={[pal.view, pal.border, styles.customFeedOuter]}
showLikes
/>
<FeedCard.Link
view={embed.view}
style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}>
<FeedCard.Outer>
<FeedCard.Header>
<FeedCard.Avatar src={embed.view.avatar} />
<FeedCard.TitleAndByline
title={embed.view.displayName}
creator={embed.view.creator}
/>
</FeedCard.Header>
<FeedCard.Likes count={embed.view.likeCount || 0} />
</FeedCard.Outer>
</FeedCard.Link>
)
}
@@ -30,23 +38,16 @@ export function ModeratedFeedEmbed({
embed: EmbedType<'feed'>
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
const moderation = useMemo(() => {
return moderationOpts
? moderateFeedGenerator(embed.view, moderationOpts)
: undefined
}, [embed.view, moderationOpts])
return (
<ContentHider modui={moderation?.ui('contentList')}>
<ContentHider
modui={moderation?.ui('contentList')}
childContainerStyle={[a.pt_xs]}>
<FeedEmbed embed={embed} />
</ContentHider>
)
}
const styles = StyleSheet.create({
customFeedOuter: {
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
},
})
+3 -3
View File
@@ -77,9 +77,9 @@ export function ImageEmbed({
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
+9 -8
View File
@@ -1,5 +1,4 @@
import React from 'react'
import {View} from 'react-native'
import {useMemo} from 'react'
import {moderateUserList} from '@atproto/api'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -16,10 +15,10 @@ export function ListEmbed({
}) {
const t = useTheme()
return (
<View
style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}>
<ListCard.Default view={embed.view} />
</View>
<ListCard.Default
view={embed.view}
style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}
/>
)
}
@@ -29,13 +28,15 @@ export function ModeratedListEmbed({
embed: EmbedType<'list'>
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
const moderation = useMemo(() => {
return moderationOpts
? moderateUserList(embed.view, moderationOpts)
: undefined
}, [embed.view, moderationOpts])
return (
<ContentHider modui={moderation?.ui('contentList')}>
<ContentHider
modui={moderation?.ui('contentList')}
childContainerStyle={[a.pt_xs]}>
<ListEmbed embed={embed} />
</ContentHider>
)
@@ -317,8 +317,8 @@ export function Controls({
!focused
? msg`Unmute video`
: playing
? msg`Pause video`
: msg`Play video`,
? msg`Pause video`
: msg`Play video`,
)}
accessibilityHint=""
style={[
@@ -1,6 +1,7 @@
import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'
import {isSafari} from '#/lib/browser'
import {logger} from '#/logger'
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
@@ -79,7 +80,12 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
await ref.current.play()
} catch (e: any) {
if (
!e.message?.includes(`The request is not allowed by the user agent`)
!e.message?.includes(
`The request is not allowed by the user agent`,
) &&
!e.message?.includes(
`The play() request was interrupted by a call to pause()`,
)
) {
throw e
}
@@ -176,8 +182,15 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
} else {
const promise = ref.current.play()
if (promise !== undefined) {
promise.catch(err => {
console.error('Error playing video:', err)
promise.catch((err: any) => {
if (
// ignore this common error. it's fine
!err.message?.includes(
`The play() request was interrupted by a call to pause()`,
)
) {
logger.error('Error playing video:', {message: err})
}
})
}
}
+51 -55
View File
@@ -268,64 +268,60 @@ export function QuoteEmbed({
const [hover, setHover] = React.useState(false)
return (
<View
onPointerEnter={() => {
setHover(true)
}}
onPointerLeave={() => {
setHover(false)
}}>
style={[a.mt_sm]}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[
a.rounded_md,
a.p_md,
a.mt_sm,
a.border,
t.atoms.border_contrast_low,
style,
]}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
activeStyle={[a.p_md, a.pt_sm]}
childContainerStyle={[a.pt_sm]}>
<SubtleWebHover hover={hover} />
<Link
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}>
<View pointerEvents="none">
<PostMeta
author={quote.author}
moderation={moderation}
showAvatar
postHref={itemHref}
timestamp={quote.indexedAt}
/>
</View>
{moderation ? (
<PostAlerts
modui={moderation.ui('contentView')}
style={[a.py_xs]}
/>
) : null}
{richText ? (
<RichText
value={richText}
style={a.text_md}
numberOfLines={20}
disableLinks
/>
) : null}
{quote.embed && (
<Embed
embed={quote.embed}
moderation={moderation}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
/>
)}
</Link>
{({active}) => (
<>
{!active && <SubtleWebHover hover={hover} style={[a.rounded_md]} />}
<Link
style={[!active && a.p_md]}
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}>
<View pointerEvents="none">
<PostMeta
author={quote.author}
moderation={moderation}
showAvatar
postHref={itemHref}
timestamp={quote.indexedAt}
/>
</View>
{moderation ? (
<PostAlerts
modui={moderation.ui('contentView')}
style={[a.py_xs]}
/>
) : null}
{richText ? (
<RichText
value={richText}
style={a.text_md}
numberOfLines={20}
disableLinks
/>
) : null}
{quote.embed && (
<Embed
embed={quote.embed}
moderation={moderation}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
/>
)}
</Link>
</>
)}
</ContentHider>
</View>
)
@@ -2,13 +2,13 @@ import {Pressable} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {t} from '@lingui/macro'
import {IS_INTERNAL} from '#/lib/app-info'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useGate} from '#/lib/statsig/statsig'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {IS_INTERNAL} from '#/env'
export function DiscoverDebug({
feedContext,
@@ -17,7 +17,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {IS_INTERNAL} from '#/lib/app-info'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {getCurrentRoute} from '#/lib/routes/helpers'
@@ -83,6 +82,7 @@ import {
useReportDialogControl,
} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {IS_INTERNAL} from '#/env'
import * as bsky from '#/types/bsky'
let PostMenuItems = ({
@@ -600,8 +600,8 @@ let PostMenuItems = ({
isDetachPending
? Loader
: quoteEmbed.isDetached
? Eye
: EyeSlash
? Eye
: EyeSlash
}
position="right"
/>
@@ -62,11 +62,17 @@ export const RepostButton = ({
</Menu.Trigger>
<Menu.Outer style={{minWidth: 170}}>
<Menu.Item
label={isReposted ? _(msg`Undo repost`) : _(msg`Repost`)}
label={
isReposted
? _(msg`Undo repost`)
: _(msg({message: `Repost`, context: `action`}))
}
testID="repostDropdownRepostBtn"
onPress={onRepost}>
<Menu.ItemText>
{isReposted ? _(msg`Undo repost`) : _(msg`Repost`)}
{isReposted
? _(msg`Undo repost`)
: _(msg({message: `Repost`, context: `action`}))}
</Menu.ItemText>
<Menu.ItemIcon icon={Repost} position="right" />
</Menu.Item>
@@ -11,6 +11,7 @@ import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
@@ -36,6 +37,7 @@ let ShareMenuItems = ({
const navigation = useNavigation<NavigationProp>()
const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode()
const {isAgeRestricted} = useAgeAssurance()
const postUri = post.uri
const postAuthor = useProfileShadow(post.author)
@@ -89,7 +91,7 @@ let ShareMenuItems = ({
return (
<>
<Menu.Outer>
{hasSession && (
{hasSession && !isAgeRestricted && (
<Menu.Group>
<Menu.ContainerItem>
<RecentChats postUri={postUri} />
@@ -11,6 +11,7 @@ import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import {useBreakpoints} from '#/alf'
@@ -38,6 +39,7 @@ let ShareMenuItems = ({
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode()
const {isAgeRestricted} = useAgeAssurance()
const postUri = post.uri
const postCid = post.cid
@@ -96,7 +98,7 @@ let ShareMenuItems = ({
<Menu.Outer>
{!hideInPWI && copyLinkItem}
{hasSession && (
{hasSession && !isAgeRestricted && (
<Menu.Item
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
+8 -1
View File
@@ -185,7 +185,14 @@ let PostControls = ({
}
return (
<View style={[a.flex_row, a.justify_between, a.align_center, style]}>
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
!big && a.pt_2xs,
style,
]}>
<View
style={[
big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}],
+78 -7
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {useMemo} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
moderateProfile,
@@ -11,6 +11,8 @@ import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {type LogEvents} from '#/lib/statsig/statsig'
import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -18,7 +20,7 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, platform, useTheme} from '#/alf'
import {
Button,
ButtonIcon,
@@ -183,14 +185,77 @@ export function AvatarPlaceholder() {
export function NameAndHandle({
profile,
moderationOpts,
inline = false,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
inline?: boolean
}) {
if (inline) {
return (
<InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} />
)
} else {
return (
<View style={[a.flex_1]}>
<Name profile={profile} moderationOpts={moderationOpts} />
<Handle profile={profile} />
</View>
)
}
}
function InlineNameAndHandle({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const t = useTheme()
const verification = useSimpleVerificationState({profile})
const moderation = moderateProfile(profile, moderationOpts)
const name = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const handle = sanitizeHandle(profile.handle, '@')
return (
<View style={[a.flex_1]}>
<Name profile={profile} moderationOpts={moderationOpts} />
<Handle profile={profile} />
<View style={[a.flex_row, a.align_end, a.flex_shrink]}>
<Text
emoji
style={[
a.font_bold,
a.leading_tight,
a.flex_shrink_0,
{maxWidth: '70%'},
]}
numberOfLines={1}>
{forceLTR(name)}
</Text>
{verification.showBadge && (
<View
style={[
a.pl_2xs,
a.self_center,
{marginTop: platform({default: 0, android: -1})},
]}>
<VerificationCheck
width={platform({android: 13, default: 12})}
verifier={verification.role === 'verifier'}
/>
</View>
)}
<Text
emoji
style={[
a.leading_tight,
t.atoms.text_contrast_medium,
{flexShrink: 10},
]}
numberOfLines={1}>
{NON_BREAKING_SPACE + handle}
</Text>
</View>
)
}
@@ -212,7 +277,13 @@ export function Name({
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
style={[
a.text_md,
a.font_bold,
a.leading_snug,
a.self_start,
a.flex_shrink,
]}
numberOfLines={1}>
{name}
</Text>
@@ -280,7 +351,7 @@ export function Description({
numberOfLines?: number
}) {
const profile = useProfileShadow(profileUnshadowed)
const rt = React.useMemo(() => {
const rt = useMemo(() => {
if (!('description' in profile)) return
const rt = new RichTextApi({text: profile.description || ''})
rt.detectFacetsWithoutResolution()
@@ -74,7 +74,7 @@ export function ProfileHoverCard(props: ProfileHoverCardProps) {
return props.children
} else {
return (
<View onPointerMove={onPointerMove} style={[a.flex_shrink]}>
<View onPointerMove={onPointerMove} style={[a.flex_shrink, props.style]}>
<ProfileHoverCardInner {...props} />
</View>
)
+4 -2
View File
@@ -1,7 +1,9 @@
import type React from 'react'
export type ProfileHoverCardProps = {
children: React.ReactElement
import {type ViewStyleProp} from '#/alf'
export type ProfileHoverCardProps = ViewStyleProp & {
children: React.ReactNode
did: string
disable?: boolean
}
+3 -2
View File
@@ -111,8 +111,8 @@ export function Trigger({children, label}: TriggerProps) {
borderColor: focused
? t.palette.primary_500
: hovered
? t.palette.contrast_100
: t.palette.contrast_25,
? t.palette.contrast_100
: t.palette.contrast_25,
},
])}>
{children}
@@ -244,6 +244,7 @@ export function Item({ref, value, style, children}: ItemProps) {
onFocus={onFocus}
onBlur={onBlur}
style={flatten([
t.atoms.text,
a.relative,
a.flex,
{minHeight: 25, paddingLeft: 30, paddingRight: 35},
@@ -156,7 +156,6 @@ export function Link({
return (
<BaseLink
action="push"
to={`/starter-pack/${handleOrDid}/${rkey}`}
label={_(msg`Navigate to ${record.name}`)}
onPress={() => {
+6
View File
@@ -0,0 +1,6 @@
import {atoms as a} from '#/alf'
export const BUBBLE_MAX_WIDTH = 240
export const ARROW_SIZE = 12
export const ARROW_HALF_SIZE = ARROW_SIZE / 2
export const MIN_EDGE_SPACE = a.px_lg.paddingLeft
+409
View File
@@ -0,0 +1,409 @@
import {
Children,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {Easing, ZoomIn} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {atoms as a, select, useTheme} from '#/alf'
import {useOnGesture} from '#/components/hooks/useOnGesture'
import {Portal} from '#/components/Portal'
import {
ARROW_HALF_SIZE,
ARROW_SIZE,
BUBBLE_MAX_WIDTH,
MIN_EDGE_SPACE,
} from '#/components/Tooltip/const'
import {Text} from '#/components/Typography'
/**
* These are native specific values, not shared with web
*/
const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target
const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow beneath tip
type TooltipContextType = {
position: 'top' | 'bottom'
visible: boolean
onVisibleChange: (visible: boolean) => void
}
type TargetMeasurements = {
x: number
y: number
width: number
height: number
}
type TargetContextType = {
targetMeasurements: TargetMeasurements | undefined
setTargetMeasurements: (measurements: TargetMeasurements) => void
shouldMeasure: boolean
}
const TooltipContext = createContext<TooltipContextType>({
position: 'bottom',
visible: false,
onVisibleChange: () => {},
})
const TargetContext = createContext<TargetContextType>({
targetMeasurements: undefined,
setTargetMeasurements: () => {},
shouldMeasure: false,
})
export function Outer({
children,
position = 'bottom',
visible: requestVisible,
onVisibleChange,
}: {
children: React.ReactNode
position?: 'top' | 'bottom'
visible: boolean
onVisibleChange: (visible: boolean) => void
}) {
/**
* Lagging state to track the externally-controlled visibility of the
* tooltip, which needs to wait for the target to be measured before
* actually being shown.
*/
const [visible, setVisible] = useState<boolean>(false)
const [targetMeasurements, setTargetMeasurements] = useState<
| {
x: number
y: number
width: number
height: number
}
| undefined
>(undefined)
if (requestVisible && !visible && targetMeasurements) {
setVisible(true)
} else if (!requestVisible && visible) {
setVisible(false)
setTargetMeasurements(undefined)
}
const ctx = useMemo(
() => ({position, visible, onVisibleChange}),
[position, visible, onVisibleChange],
)
const targetCtx = useMemo(
() => ({
targetMeasurements,
setTargetMeasurements,
shouldMeasure: requestVisible,
}),
[requestVisible, targetMeasurements, setTargetMeasurements],
)
return (
<TooltipContext.Provider value={ctx}>
<TargetContext.Provider value={targetCtx}>
{children}
</TargetContext.Provider>
</TooltipContext.Provider>
)
}
export function Target({children}: {children: React.ReactNode}) {
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
const targetRef = useRef<View>(null)
useEffect(() => {
if (!shouldMeasure) return
/*
* Once opened, measure the dimensions and position of the target
*/
targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
if (pageX !== undefined && pageY !== undefined && width && height) {
setTargetMeasurements({x: pageX, y: pageY, width, height})
}
})
}, [shouldMeasure, setTargetMeasurements])
return (
<View collapsable={false} ref={targetRef}>
{children}
</View>
)
}
export function Content({
children,
label,
}: {
children: React.ReactNode
label: string
}) {
const {position, visible, onVisibleChange} = useContext(TooltipContext)
const {targetMeasurements} = useContext(TargetContext)
const requestClose = useCallback(() => {
onVisibleChange(false)
}, [onVisibleChange])
if (!visible || !targetMeasurements) return null
return (
<Portal>
<Bubble
label={label}
position={position}
/*
* Gotta pass these in here. Inside the Bubble, we're Potal-ed outside
* the context providers.
*/
targetMeasurements={targetMeasurements}
requestClose={requestClose}>
{children}
</Bubble>
</Portal>
)
}
function Bubble({
children,
label,
position,
requestClose,
targetMeasurements,
}: {
children: React.ReactNode
label: string
position: TooltipContextType['position']
requestClose: () => void
targetMeasurements: Exclude<
TargetContextType['targetMeasurements'],
undefined
>
}) {
const t = useTheme()
const insets = useSafeAreaInsets()
const dimensions = useWindowDimensions()
const [bubbleMeasurements, setBubbleMeasurements] = useState<
| {
width: number
height: number
}
| undefined
>(undefined)
const coords = useMemo(() => {
if (!bubbleMeasurements)
return {
top: 0,
bottom: 0,
left: 0,
right: 0,
tipTop: 0,
tipLeft: 0,
}
const {width: ww, height: wh} = dimensions
const maxTop = insets.top
const maxBottom = wh - insets.bottom
const {width: cw, height: ch} = bubbleMeasurements
const minLeft = MIN_EDGE_SPACE
const maxLeft = ww - minLeft
let computedPosition: 'top' | 'bottom' = position
let top = targetMeasurements.y + targetMeasurements.height
let left = Math.max(
minLeft,
targetMeasurements.x + targetMeasurements.width / 2 - cw / 2,
)
const tipTranslate = ARROW_HALF_SIZE * -1
let tipTop = tipTranslate
if (left + cw > maxLeft) {
left -= left + cw - maxLeft
}
let tipLeft =
targetMeasurements.x -
left +
targetMeasurements.width / 2 -
ARROW_HALF_SIZE
let bottom = top + ch
function positionTop() {
top = top - ch - targetMeasurements.height
bottom = top + ch
tipTop = tipTop + ch
computedPosition = 'top'
}
function positionBottom() {
top = targetMeasurements.y + targetMeasurements.height
bottom = top + ch
tipTop = tipTranslate
computedPosition = 'bottom'
}
if (position === 'top') {
positionTop()
if (top < maxTop) {
positionBottom()
}
} else {
if (bottom > maxBottom) {
positionTop()
}
}
if (computedPosition === 'bottom') {
top += ARROW_VISUAL_OFFSET
bottom += ARROW_VISUAL_OFFSET
} else {
top -= ARROW_VISUAL_OFFSET
bottom -= ARROW_VISUAL_OFFSET
}
return {
computedPosition,
top,
bottom,
left,
right: left + cw,
tipTop,
tipLeft,
}
}, [position, targetMeasurements, bubbleMeasurements, insets, dimensions])
const requestCloseWrapped = useCallback(() => {
setBubbleMeasurements(undefined)
requestClose()
}, [requestClose])
useOnGesture(
useCallback(
e => {
const {x, y} = e
const isInside =
x > coords.left &&
x < coords.right &&
y > coords.top &&
y < coords.bottom
if (!isInside) {
requestCloseWrapped()
}
},
[coords, requestCloseWrapped],
),
)
return (
<View
accessible
role="alert"
accessibilityHint=""
accessibilityLabel={label}
// android
importantForAccessibility="yes"
// ios
accessibilityViewIsModal
style={[
a.absolute,
a.align_start,
{
width: BUBBLE_MAX_WIDTH,
opacity: bubbleMeasurements ? 1 : 0,
top: coords.top,
left: coords.left,
},
]}>
<Animated.View
entering={ZoomIn.easing(Easing.out(Easing.exp))}
style={{transformOrigin: oppposite(position)}}>
<View
style={[
a.absolute,
a.top_0,
a.z_10,
t.atoms.bg,
select(t.name, {
light: t.atoms.bg,
dark: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
}),
{
borderTopLeftRadius: a.rounded_2xs.borderRadius,
borderBottomRightRadius: a.rounded_2xs.borderRadius,
width: ARROW_SIZE,
height: ARROW_SIZE,
transform: [{rotate: '45deg'}],
top: coords.tipTop,
left: coords.tipLeft,
},
]}
/>
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
select(t.name, {
light: t.atoms.bg,
dark: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
}),
t.atoms.shadow_md,
{
shadowOpacity: 0.2,
shadowOffset: {
width: 0,
height:
BUBBLE_SHADOW_OFFSET *
(coords.computedPosition === 'bottom' ? -1 : 1),
},
},
]}
onLayout={e => {
setBubbleMeasurements({
width: e.nativeEvent.layout.width,
height: e.nativeEvent.layout.height,
})
}}>
{children}
</View>
</Animated.View>
</View>
)
}
function oppposite(position: 'top' | 'bottom') {
switch (position) {
case 'top':
return 'center bottom'
case 'bottom':
return 'center top'
default:
return 'center'
}
}
export function TextBubble({children}: {children: React.ReactNode}) {
const c = Children.toArray(children)
return (
<Content label={c.join(' ')}>
<View style={[a.gap_xs]}>
{c.map((child, i) => (
<Text key={i} style={[a.text_sm, a.leading_snug]}>
{child}
</Text>
))}
</View>
</Content>
)
}
+118
View File
@@ -0,0 +1,118 @@
import {Children, createContext, useContext, useMemo} from 'react'
import {View} from 'react-native'
import {Popover} from 'radix-ui'
import {atoms as a, flatten, select, useTheme} from '#/alf'
import {transparentifyColor} from '#/alf/util/colorGeneration'
import {
ARROW_SIZE,
BUBBLE_MAX_WIDTH,
MIN_EDGE_SPACE,
} from '#/components/Tooltip/const'
import {Text} from '#/components/Typography'
type TooltipContextType = {
position: 'top' | 'bottom'
onVisibleChange: (open: boolean) => void
}
const TooltipContext = createContext<TooltipContextType>({
position: 'bottom',
onVisibleChange: () => {},
})
export function Outer({
children,
position = 'bottom',
visible,
onVisibleChange,
}: {
children: React.ReactNode
position?: 'top' | 'bottom'
visible: boolean
onVisibleChange: (visible: boolean) => void
}) {
const ctx = useMemo(
() => ({position, onVisibleChange}),
[position, onVisibleChange],
)
return (
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
</Popover.Root>
)
}
export function Target({children}: {children: React.ReactNode}) {
return (
<Popover.Trigger asChild>
<View collapsable={false}>{children}</View>
</Popover.Trigger>
)
}
export function Content({
children,
label,
}: {
children: React.ReactNode
label: string
}) {
const t = useTheme()
const {position, onVisibleChange} = useContext(TooltipContext)
return (
<Popover.Portal>
<Popover.Content
className="radix-popover-content"
aria-label={label}
side={position}
sideOffset={4}
collisionPadding={MIN_EDGE_SPACE}
onInteractOutside={() => onVisibleChange(false)}
style={flatten([
a.rounded_sm,
select(t.name, {
light: t.atoms.bg,
dark: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
}),
{
minWidth: 'max-content',
boxShadow: select(t.name, {
light: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
dark: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
dim: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
}),
},
])}>
<Popover.Arrow
width={ARROW_SIZE}
height={ARROW_SIZE / 2}
fill={select(t.name, {
light: t.atoms.bg.backgroundColor,
dark: t.atoms.bg_contrast_100.backgroundColor,
dim: t.atoms.bg_contrast_100.backgroundColor,
})}
/>
<View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}>
{children}
</View>
</Popover.Content>
</Popover.Portal>
)
}
export function TextBubble({children}: {children: React.ReactNode}) {
const c = Children.toArray(children)
return (
<Content label={c.join(' ')}>
<View style={[a.gap_xs]}>
{c.map((child, i) => (
<Text key={i} style={[a.text_sm, a.leading_snug]}>
{child}
</Text>
))}
</View>
</Content>
)
}
+34 -29
View File
@@ -390,6 +390,7 @@ export function CompactVideoPostCard({
if (!AppBskyEmbedVideo.isView(embed)) return null
const likeCount = post?.likeCount ?? 0
const showLikeCount = false
const {thumbnail} = embed
const black = getBlackColor(t)
@@ -475,47 +476,51 @@ export function CompactVideoPostCard({
/>
<MediaInsetBorder />
<View style={[a.absolute, a.inset_0]}>
<View style={[a.absolute, a.inset_0, t.atoms.shadow_sm]}>
<View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
<View
style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
style={[a.relative, a.rounded_full, {width: 24, height: 24}]}>
<UserAvatar
type="user"
size={20}
size={24}
avatar={post.author.avatar}
/>
<MediaInsetBorder />
</View>
</View>
<View
style={[
a.absolute,
a.inset_0,
a.pt_2xl,
{
top: 'auto',
},
]}>
<LinearGradient
colors={[black, 'rgba(0, 0, 0, 0)']}
locations={[0.02, 1]}
start={{x: 0, y: 1}}
end={{x: 0, y: 0}}
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
/>
{showLikeCount && (
<View
style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
{likeCount > 0 && (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Heart size="sm" fill="white" />
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
{formatCount(i18n, likeCount)}
</Text>
</View>
)}
style={[
a.absolute,
a.inset_0,
a.pt_2xl,
{
top: 'auto',
},
]}>
<LinearGradient
colors={[black, 'rgba(0, 0, 0, 0)']}
locations={[0.02, 1]}
start={{x: 0, y: 1}}
end={{x: 0, y: 0}}
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
/>
<View
style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
{likeCount > 0 && (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Heart size="sm" fill="white" />
<Text
style={[a.text_sm, a.font_bold, {color: 'white'}]}>
{formatCount(i18n, likeCount)}
</Text>
</View>
)}
</View>
</View>
</View>
)}
</View>
</View>
</Hider.Content>
+38 -15
View File
@@ -1,9 +1,15 @@
import React from 'react'
import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
import {Fragment, useMemo} from 'react'
import {
AppBskyFeedDefs,
Keyboard,
Platform,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {
type AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
type AppBskyGraphDefs,
AtUri,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
@@ -13,11 +19,11 @@ import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {isNative} from '#/platform/detection'
import {
ThreadgateAllowUISetting,
type ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {
@@ -55,7 +61,7 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
) && post.record.reply?.root
? post.record.reply.root.uri
: post.uri
const settings = React.useMemo(() => {
const settings = useMemo(() => {
return threadgateViewToAllowUISetting(post.threadgate)
}, [post.threadgate])
@@ -70,8 +76,8 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const description = anyoneCanReply
? _(msg`Everybody can reply`)
: noOneCanReply
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
const onPressOpen = () => {
if (isNative && Keyboard.isVisible()) {
@@ -154,7 +160,9 @@ function Icon({
width?: number
settings: ThreadgateAllowUISetting[]
}) {
const isEverybody = settings.length === 0
const isEverybody =
settings.length === 0 ||
settings.every(setting => setting.type === 'everybody')
const isNobody = !!settings.find(gate => gate.type === 'nobody')
const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group
return <IconComponent fill={color} width={width} />
@@ -172,12 +180,13 @@ function WhoCanReplyDialog({
embeddingDisabled: boolean
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Dialog: adjust who can interact with this post`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
style={web({maxWidth: 400})}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
<Trans>Who can interact with this post?</Trans>
@@ -188,6 +197,20 @@ function WhoCanReplyDialog({
embeddingDisabled={embeddingDisabled}
/>
</View>
{isNative && (
<Button
label={_(msg`Close`)}
onPress={() => control.close()}
size="small"
variant="solid"
color="secondary"
style={[a.mt_5xl]}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
@@ -226,10 +249,10 @@ function Rules({
<Trans>
Only{' '}
{settings.map((rule, i) => (
<React.Fragment key={`rule-${i}`}>
<Fragment key={`rule-${i}`}>
<Rule rule={rule} post={post} lists={post.threadgate!.lists} />
<Separator i={i} length={settings.length} />
</React.Fragment>
</Fragment>
))}{' '}
can reply.
</Trans>
@@ -0,0 +1,89 @@
import {useCallback} from 'react'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {BellPlus_Stroke2_Corner0_Rounded as BellPlusIcon} from '#/components/icons/BellPlus'
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import * as Tooltip from '#/components/Tooltip'
import {Text} from '#/components/Typography'
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
import type * as bsky from '#/types/bsky'
import {SubscribeProfileDialog} from './SubscribeProfileDialog'
export function SubscribeProfileButton({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const {_} = useLingui()
const requireEmailVerification = useRequireEmailVerification()
const subscribeDialogControl = useDialogControl()
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
useActivitySubscriptionsNudged()
const onDismissTooltip = () => {
setActivitySubscriptionsNudged(true)
}
const onPress = useCallback(() => {
subscribeDialogControl.open()
}, [subscribeDialogControl])
const name = createSanitizedDisplayName(profile, true)
const wrappedOnPress = requireEmailVerification(onPress, {
instructions: [
<Trans key="message">
Before you can get notifications for {name}'s posts, you must first
verify your email.
</Trans>,
],
})
const isSubscribed =
profile.viewer?.activitySubscription?.post ||
profile.viewer?.activitySubscription?.reply
const Icon = isSubscribed ? BellRingingIcon : BellPlusIcon
return (
<>
<Tooltip.Outer
visible={!activitySubscriptionsNudged}
onVisibleChange={onDismissTooltip}
position="bottom">
<Tooltip.Target>
<Button
accessibilityRole="button"
testID="dmBtn"
size="small"
color="secondary"
variant="solid"
shape="round"
label={_(msg`Get notified when ${name} posts`)}
onPress={wrappedOnPress}>
<ButtonIcon icon={Icon} size="md" />
</Button>
</Tooltip.Target>
<Tooltip.TextBubble>
<Text>
<Trans>Get notified about new posts</Trans>
</Text>
</Tooltip.TextBubble>
</Tooltip.Outer>
<SubscribeProfileDialog
control={subscribeDialogControl}
profile={profile}
moderationOpts={moderationOpts}
/>
</>
)
}
@@ -0,0 +1,309 @@
import {useMemo, useState} from 'react'
import {View} from 'react-native'
import {
type AppBskyNotificationDefs,
type AppBskyNotificationListActivitySubscriptions,
type ModerationOpts,
type Un$Typed,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type InfiniteData,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions'
import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {platform, useTheme, web} from '#/alf'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {
Button,
ButtonIcon,
type ButtonProps,
ButtonText,
} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
export function SubscribeProfileDialog({
control,
profile,
moderationOpts,
includeProfile,
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
includeProfile?: boolean
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner
profile={profile}
moderationOpts={moderationOpts}
includeProfile={includeProfile}
/>
</Dialog.Outer>
)
}
function DialogInner({
profile,
moderationOpts,
includeProfile,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
includeProfile?: boolean
}) {
const {_} = useLingui()
const t = useTheme()
const agent = useAgent()
const control = Dialog.useDialogContext()
const queryClient = useQueryClient()
const initialState = parseActivitySubscription(
profile.viewer?.activitySubscription,
)
const [state, setState] = useState(initialState)
const values = useMemo(() => {
const {post, reply} = state
const res = []
if (post) res.push('post')
if (reply) res.push('reply')
return res
}, [state])
const onChange = (newValues: string[]) => {
setState(oldValues => {
// ensure you can't have reply without post
if (!oldValues.reply && newValues.includes('reply')) {
return {
post: true,
reply: true,
}
}
if (oldValues.post && !newValues.includes('post')) {
return {
post: false,
reply: false,
}
}
return {
post: newValues.includes('post'),
reply: newValues.includes('reply'),
}
})
}
const {
mutate: saveChanges,
isPending: isSaving,
error,
} = useMutation({
mutationFn: async (
activitySubscription: Un$Typed<AppBskyNotificationDefs.ActivitySubscription>,
) => {
await agent.app.bsky.notification.putActivitySubscription({
subject: profile.did,
activitySubscription,
})
},
onSuccess: (_data, activitySubscription) => {
control.close(() => {
updateProfileShadow(queryClient, profile.did, {
activitySubscription,
})
if (!activitySubscription.post && !activitySubscription.reply) {
logger.metric('activitySubscription:disable', {})
Toast.show(
_(
msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`,
),
'check',
)
// filter out the subscription
queryClient.setQueryData(
RQKEY_getActivitySubscriptions,
(
old?: InfiniteData<AppBskyNotificationListActivitySubscriptions.OutputSchema>,
) => {
if (!old) return old
return {
...old,
pages: old.pages.map(page => ({
...page,
subscriptions: page.subscriptions.filter(
item => item.did !== profile.did,
),
})),
}
},
)
} else {
logger.metric('activitySubscription:enable', {
setting: activitySubscription.reply ? 'posts_and_replies' : 'posts',
})
if (!initialState.post && !initialState.reply) {
Toast.show(
_(
msg`You'll start receiving notifications for ${sanitizeHandle(profile.handle, '@')}!`,
),
'check',
)
} else {
Toast.show(_(msg`Changes saved`), 'check')
}
}
})
},
onError: err => {
logger.error('Could not save activity subscription', {message: err})
},
})
const buttonProps: Omit<ButtonProps, 'children'> = useMemo(() => {
const isDirty =
state.post !== initialState.post || state.reply !== initialState.reply
const hasAny = state.post || state.reply
if (isDirty) {
return {
label: _(msg`Save changes`),
color: hasAny ? 'primary' : 'negative',
onPress: () => saveChanges(state),
disabled: isSaving,
}
} else {
// on web, a disabled save button feels more natural than a massive close button
if (isWeb) {
return {
label: _(msg`Save changes`),
color: 'secondary',
disabled: true,
}
} else {
return {
label: _(msg`Cancel`),
color: 'secondary',
onPress: () => control.close(),
}
}
}
}, [state, initialState, control, _, isSaving, saveChanges])
const name = createSanitizedDisplayName(profile, false)
return (
<Dialog.ScrollableInner
style={web({maxWidth: 400})}
label={_(msg`Get notified of new posts from ${name}`)}>
<View style={[a.gap_lg]}>
<View style={[a.gap_xs]}>
<Text style={[a.font_heavy, a.text_2xl]}>
<Trans>Keep me posted</Trans>
</Text>
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
<Trans>Get notified of this accounts activity</Trans>
</Text>
</View>
{includeProfile && (
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
)}
<Toggle.Group
label={_(msg`Subscribe to account activity`)}
values={values}
onChange={onChange}>
<View style={[a.gap_sm]}>
<Toggle.Item
label={_(msg`Posts`)}
name="post"
style={[
a.flex_1,
a.py_xs,
platform({
native: [a.justify_between],
web: [a.flex_row_reverse, a.gap_sm],
}),
]}>
<Toggle.LabelText
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
<Trans>Posts</Trans>
</Toggle.LabelText>
<Toggle.Switch />
</Toggle.Item>
<Toggle.Item
label={_(msg`Replies`)}
name="reply"
style={[
a.flex_1,
a.py_xs,
platform({
native: [a.justify_between],
web: [a.flex_row_reverse, a.gap_sm],
}),
]}>
<Toggle.LabelText
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
<Trans>Replies</Trans>
</Toggle.LabelText>
<Toggle.Switch />
</Toggle.Item>
</View>
</Toggle.Group>
{error && (
<Admonition type="error">
<Trans>Could not save changes: {cleanError(error)}</Trans>
</Admonition>
)}
<Button {...buttonProps} size="large" variant="solid">
<ButtonText>{buttonProps.label}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} />}
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function parseActivitySubscription(
sub?: AppBskyNotificationDefs.ActivitySubscription,
): Un$Typed<AppBskyNotificationDefs.ActivitySubscription> {
if (!sub) return {post: false, reply: false}
const {post, reply} = sub
return {post, reply}
}
@@ -0,0 +1,155 @@
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {
AgeAssuranceInitDialog,
useDialogControl,
} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
const {isReady, isAgeRestricted, isDeclaredUnderage} = useAgeAssurance()
if (!isReady) return null
if (isDeclaredUnderage) return null
if (!isAgeRestricted) return null
return <Inner style={style} />
}
function Inner({style}: ViewStyleProp & {}) {
const t = useTheme()
const {_, i18n} = useLingui()
const control = useDialogControl()
const appealControl = Dialog.useDialogControl()
const getTimeAgo = useGetTimeAgo()
const {gtPhone} = useBreakpoints()
const copy = useAgeAssuranceCopy()
const {status, lastInitiatedAt} = useAgeAssurance()
const isBlocked = status === 'blocked'
const hasInitiated = !!lastInitiatedAt
const timeAgo = lastInitiatedAt
? getTimeAgo(lastInitiatedAt, new Date())
: null
const diff = lastInitiatedAt
? dateDiff(lastInitiatedAt, new Date(), 'down')
: null
return (
<>
<AgeAssuranceInitDialog control={control} />
<AgeAssuranceAppealDialog control={appealControl} />
<View style={style}>
<View
style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}>
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_lg,
a.pb_md,
a.z_10,
]}>
<View style={[a.align_start]}>
<AgeAssuranceBadge />
</View>
</View>
<View style={[a.pb_md]}>
<Text style={[a.text_sm, a.leading_snug]}>{copy.notice}</Text>
</View>
{isBlocked ? (
<Admonition type="warning">
<Trans>
You are currently unable to access Bluesky's Age Assurance flow.
Please{' '}
<InlineLinkText
label={_(msg`Contact our moderation team`)}
{...createStaticClick(() => {
appealControl.open()
logger.metric('ageAssurance:appealDialogOpen', {})
})}>
contact our moderation team
</InlineLinkText>{' '}
if you believe this is an error.
</Trans>
</Admonition>
) : (
<>
<Divider />
<View
style={[
a.pt_md,
gtPhone
? [
a.flex_row_reverse,
a.gap_xl,
a.justify_between,
a.align_center,
]
: [a.gap_md],
]}>
<Button
label={_(msg`Verify now`)}
size="small"
variant="solid"
color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => {
control.open()
logger.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}}>
<ButtonText>
{hasInitiated ? (
<Trans>Verify again</Trans>
) : (
<Trans>Verify now</Trans>
)}
</ButtonText>
</Button>
{lastInitiatedAt && timeAgo && diff ? (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
title={i18n.date(lastInitiatedAt, {
dateStyle: 'medium',
timeStyle: 'medium',
})}>
{diff.value === 0 ? (
<Trans>Last initiated just now</Trans>
) : (
<Trans>Last initiated {timeAgo} ago</Trans>
)}
</Text>
) : (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
<Trans>Age assurance only takes a few minutes</Trans>
</Text>
)}
</View>
</>
)}
</View>
</View>
</>
)
}
@@ -0,0 +1,104 @@
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import type * as Dialog from '#/components/Dialog'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function AgeAssuranceAdmonition({
children,
style,
}: ViewStyleProp & {children: React.ReactNode}) {
const control = useDialogControl()
const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance()
if (!isReady) return null
if (isDeclaredUnderage) return null
if (!isAgeRestricted) return null
return (
<Inner style={style} control={control}>
{children}
</Inner>
)
}
function Inner({
children,
style,
}: ViewStyleProp & {
children: React.ReactNode
control: Dialog.DialogControlProps
}) {
const t = useTheme()
const {_} = useLingui()
return (
<>
<View style={style}>
<View
style={[
a.p_md,
a.rounded_md,
a.border,
a.flex_row,
a.align_start,
a.gap_sm,
{
backgroundColor: select(t.name, {
light: t.palette.primary_25,
dark: t.palette.primary_25,
dim: t.palette.primary_25,
}),
borderColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
{
width: 32,
height: 32,
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="md" />
</View>
<View style={[a.flex_1, a.gap_xs, a.pr_2xl]}>
<Text style={[a.text_sm, a.leading_snug]}>{children}</Text>
<Text style={[a.text_sm, a.leading_snug, a.font_bold]}>
<Trans>
Learn more in your{' '}
<InlineLinkText
label={_(msg`Go to account settings`)}
to={'/settings/account'}
style={[a.text_sm, a.leading_snug, a.font_bold]}
onPress={() => {
logger.metric('ageAssurance:navigateToSettings', {})
}}>
account settings.
</InlineLinkText>
</Trans>
</Text>
</View>
</View>
</View>
</>
)
}
@@ -0,0 +1,142 @@
import React from 'react'
import {View} from 'react-native'
import {BSKY_LABELER_DID, ComAtprotoModerationDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {logger} from '#/state/ageAssurance/util'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, web} from '#/alf'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function AgeAssuranceAppealDialog({
control,
}: {
control: Dialog.DialogControlProps
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Contact our moderation team`)}
style={[web({maxWidth: 400})]}>
<Inner control={control} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Inner({control}: {control: Dialog.DialogControlProps}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const {gtPhone} = useBreakpoints()
const agent = useAgent()
const [details, setDetails] = React.useState('')
const isInvalid = details.length > 1000
const {mutate, isPending} = useMutation({
mutationFn: async () => {
logger.metric('ageAssurance:appealDialogSubmit', {})
await agent.createModerationReport(
{
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: currentAccount?.did,
},
reason: `AGE_ASSURANCE_INQUIRY: ` + details,
},
{
encoding: 'application/json',
headers: {
'atproto-proxy': `${BSKY_LABELER_DID}#atproto_labeler`,
},
},
)
},
onError: err => {
logger.error('AgeAssuranceAppealDialog failed', {safeMessage: err})
Toast.show(
_(msg`Age assurance inquiry failed to send, please try again.`),
'xmark',
)
},
onSuccess: () => {
control.close()
Toast.show(
_(
msg({
message: 'Age assurance inquiry was submitted',
context: 'toast',
}),
),
)
},
})
return (
<View>
<View style={[a.align_start]}>
<AgeAssuranceBadge />
</View>
<Text style={[a.text_2xl, a.font_heavy, a.pt_md, a.leading_tight]}>
<Trans>Contact us</Trans>
</Text>
<Text style={[a.text_sm, a.pt_sm, a.leading_snug]}>
<Trans>
Please provide any additional details you feel moderators may need in
order to properly assess your Age Assurance status.
</Trans>
</Text>
<View style={[a.pt_md]}>
<Dialog.Input
multiline
isInvalid={isInvalid}
value={details}
onChangeText={details => {
setDetails(details)
}}
label={_(msg`Additional details (limit 1000 characters)`)}
numberOfLines={4}
onSubmitEditing={() => mutate()}
/>
<View style={[a.pt_md, a.gap_sm, gtPhone && [a.flex_row_reverse]]}>
<Button
label={_(msg`Submit`)}
size="small"
variant="solid"
color="primary"
onPress={() => mutate()}>
<ButtonText>
<Trans>Submit</Trans>
</ButtonText>
{isPending && <ButtonIcon icon={Loader} position="right" />}
</Button>
<Button
label={_(msg`Cancel`)}
size="small"
variant="solid"
color="secondary"
onPress={() => control.close()}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
</View>
)
}
@@ -0,0 +1,46 @@
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {atoms as a, select, useTheme} from '#/alf'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {Text} from '#/components/Typography'
export function AgeAssuranceBadge() {
const t = useTheme()
return (
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
a.px_sm,
a.py_xs,
a.pr_sm,
a.rounded_full,
{
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="sm" />
<Text
style={[
a.font_bold,
a.leading_snug,
{
color: select(t.name, {
light: t.palette.primary_800,
dark: t.palette.primary_800,
dim: t.palette.primary_800,
}),
},
]}>
<Trans>Age Assurance</Trans>
</Text>
</View>
)
}
@@ -0,0 +1,141 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, select, useTheme} from '#/alf'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button} from '#/components/Button'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export function useInternalState() {
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const visible = useMemo(() => {
if (!isReady) return false
if (isDeclaredUnderage) return false
if (!isAgeRestricted) return false
if (lastInitiatedAt) return false
if (hidden) return false
if (nux && nux.completed) return false
return true
}, [
isReady,
isDeclaredUnderage,
isAgeRestricted,
lastInitiatedAt,
hidden,
nux,
])
const close = () => {
save({
id: Nux.AgeAssuranceDismissibleFeedBanner,
completed: true,
data: undefined,
})
}
return {visible, close}
}
export function AgeAssuranceDismissibleFeedBanner() {
const t = useTheme()
const {_} = useLingui()
const {visible, close} = useInternalState()
const copy = useAgeAssuranceCopy()
if (!visible) return null
return (
<View
style={[
a.px_lg,
{
paddingVertical: 10,
backgroundColor: select(t.name, {
light: t.palette.primary_25,
dark: t.palette.primary_25,
dim: t.palette.primary_25,
}),
},
]}>
<Link
label={_(msg`Learn more about age assurance`)}
to="/settings/account"
onPress={() => {
close()
logger.metric('ageAssurance:navigateToSettings', {})
}}
style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
{
width: 42,
height: 42,
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="lg" />
</View>
<View
style={[
a.flex_1,
{
paddingRight: 40,
},
]}>
<View style={{maxWidth: 400}}>
<Text style={[a.leading_snug]}>{copy.banner}</Text>
</View>
</View>
</Link>
<Button
label={_(msg`Don't show again`)}
size="small"
onPress={() => {
close()
logger.metric('ageAssurance:dismissFeedBanner', {})
}}
style={[
a.absolute,
a.justify_center,
a.align_center,
{
top: 0,
bottom: 0,
right: 0,
paddingRight: a.px_md.paddingLeft,
},
]}>
<X
width={20}
fill={select(t.name, {
light: t.palette.primary_600,
dark: t.palette.primary_600,
dim: t.palette.primary_600,
})}
/>
</Button>
</View>
)
}
@@ -0,0 +1,61 @@
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, type ViewStyleProp} from '#/alf'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
const {_} = useLingui()
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice)
const copy = useAgeAssuranceCopy()
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
if (!isReady) return null
if (isDeclaredUnderage) return null
if (!isAgeRestricted) return null
if (lastInitiatedAt) return null
if (hidden) return null
if (nux && nux.completed) return null
return (
<View style={style}>
<View>
<AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
<Button
label={_(msg`Don't show again`)}
size="tiny"
variant="solid"
color="secondary_inverted"
shape="round"
onPress={() => {
save({
id: Nux.AgeAssuranceDismissibleNotice,
completed: true,
data: undefined,
})
logger.metric('ageAssurance:dismissSettingsNotice', {})
}}
style={[
a.absolute,
{
top: 12,
right: 12,
},
]}>
<ButtonIcon icon={X} />
</Button>
</View>
</View>
)
}
@@ -0,0 +1,382 @@
import {useState} from 'react'
import {View} from 'react-native'
import {XRPCError} from '@atproto/xrpc'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {validate as validateEmail} from 'email-validator'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {
SupportCode,
useCreateSupportLink,
} from '#/lib/hooks/useCreateSupportLink'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTLDs} from '#/lib/hooks/useTLDs'
import {isEmailMaybeInvalid} from '#/lib/strings/email'
import {type AppLanguage} from '#/locale/languages'
import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {urls} from '#/components/ageAssurance/const'
import {KWS_SUPPORTED_LANGS} from '#/components/ageAssurance/const'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import * as TextField from '#/components/forms/TextField'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {LanguageSelect} from '#/components/LanguageSelect'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export {useDialogControl} from '#/components/Dialog/context'
export function AgeAssuranceInitDialog({
control,
}: {
control: Dialog.DialogControlProps
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(
msg`Begin the age assurance process by completing the fields below.`,
)}
style={[
web({
maxWidth: 400,
}),
]}>
<Inner />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Inner() {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const langPrefs = useLanguagePrefs()
const cleanError = useCleanError()
const {close} = Dialog.useDialogContext()
const {lastInitiatedAt} = useAgeAssuranceContext()
const getTimeAgo = useGetTimeAgo()
const tlds = useTLDs()
const createSupportLink = useCreateSupportLink()
const wasRecentlyInitiated =
lastInitiatedAt &&
new Date(lastInitiatedAt).getTime() > Date.now() - 5 * 60 * 1000 // 5 minutes
const [success, setSuccess] = useState(false)
const [email, setEmail] = useState(currentAccount?.email || '')
const [emailError, setEmailError] = useState<string>('')
const [languageError, setLanguageError] = useState(false)
const [disabled, setDisabled] = useState(false)
const [language, setLanguage] = useState<string | undefined>(
convertToKWSSupportedLanguage(langPrefs.appLanguage),
)
const [error, setError] = useState<React.ReactNode>(null)
const {mutateAsync: init, isPending} = useInitAgeAssurance()
const runEmailValidation = () => {
if (validateEmail(email)) {
setEmailError('')
setDisabled(false)
if (tlds && isEmailMaybeInvalid(email, tlds)) {
setEmailError(
_(
msg`Please double-check that you have entered your email address correctly.`,
),
)
return {status: 'maybe'}
}
return {status: 'valid'}
}
setEmailError(_(msg`Please enter a valid email address.`))
setDisabled(true)
return {status: 'invalid'}
}
const onSubmit = async () => {
setLanguageError(false)
logger.metric('ageAssurance:initDialogSubmit', {})
try {
const {status} = runEmailValidation()
if (status === 'invalid') return
if (!language) {
setLanguageError(true)
return
}
await init({
email,
language,
})
setSuccess(true)
} catch (e) {
let error: React.ReactNode = _(
msg`Something went wrong, please try again`,
)
if (e instanceof XRPCError) {
if (e.error === 'InvalidEmail') {
error = _(
msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`,
)
logger.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'})
} else if (e.error === 'DidTooLong') {
error = (
<>
<Trans>
We're having issues initializing the age assurance process for
your account. Please{' '}
<InlineLinkText
to={createSupportLink({code: SupportCode.AA_DID, email})}
label={_(msg`Contact support`)}>
contact support
</InlineLinkText>{' '}
for assistance.
</Trans>
</>
)
logger.metric('ageAssurance:initDialogError', {code: 'DidTooLong'})
} else {
logger.metric('ageAssurance:initDialogError', {code: 'other'})
}
} else {
const {clean, raw} = cleanError(e)
error = clean || raw || error
logger.metric('ageAssurance:initDialogError', {code: 'other'})
}
setError(error)
}
}
return (
<View>
<View style={[a.align_start]}>
<AgeAssuranceBadge />
<Text style={[a.text_xl, a.font_heavy, a.pt_xl, a.pb_md]}>
{success ? <Trans>Success!</Trans> : <Trans>Verify your age</Trans>}
</Text>
<View style={[a.pb_xl, a.gap_sm]}>
{success ? (
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
Please check your email inbox for further instructions. It may
take a minute or two to arrive.
</Trans>
</Text>
) : (
<>
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
We have partnered with{' '}
<InlineLinkText
overridePresentation
disableMismatchWarning
label={_(msg`KWS website`)}
to={urls.kwsHome}
style={[a.text_sm, a.leading_snug]}>
KWS
</InlineLinkText>{' '}
to verify that youre an adult. When you click "Begin" below,
KWS will check if you have previously verified your age using
this email address for other games/services powered by KWS
technology. If not, KWS will email you instructions for
verifying your age. When youre done, you'll be brought back
to continue using Bluesky.
</Trans>
</Text>
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>This should only take a few minutes.</Trans>
</Text>
</>
)}
</View>
{success ? (
<View style={[a.w_full]}>
<Button
label={_(msg`Close dialog`)}
size="large"
variant="solid"
color="secondary"
onPress={() => close()}>
<ButtonText>
<Trans>Close dialog</Trans>
</ButtonText>
</Button>
</View>
) : (
<>
<Divider />
<View style={[a.w_full, a.pt_xl, a.gap_lg, a.pb_lg]}>
{wasRecentlyInitiated && (
<Admonition type="warning">
<Trans>
You initiated this flow already,{' '}
{getTimeAgo(lastInitiatedAt, new Date(), {format: 'long'})}{' '}
ago. It may take up to 5 minutes for emails to reach your
inbox. Please consider waiting a few minutes before trying
again.
</Trans>
</Admonition>
)}
<View>
<TextField.LabelText>
<Trans>Your email</Trans>
</TextField.LabelText>
<TextField.Root isInvalid={!!emailError}>
<TextField.Input
label={_(msg`Your email`)}
placeholder={_(msg`Your email`)}
value={email}
onChangeText={setEmail}
onFocus={() => setEmailError('')}
onBlur={() => {
runEmailValidation()
}}
returnKeyType="done"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
onSubmitEditing={onSubmit}
/>
</TextField.Root>
{emailError ? (
<Admonition type="error" style={[a.mt_sm]}>
{emailError}
</Admonition>
) : (
<Admonition type="tip" style={[a.mt_sm]}>
<Trans>
Use your account email address, or another real email
address you control, in case KWS or Bluesky needs to
contact you.
</Trans>
</Admonition>
)}
</View>
<View>
<TextField.LabelText>
<Trans>Your preferred language</Trans>
</TextField.LabelText>
<LanguageSelect
value={language}
onChange={value => {
setLanguage(value)
setLanguageError(false)
}}
items={KWS_SUPPORTED_LANGS}
/>
{languageError && (
<Admonition type="error" style={[a.mt_sm]}>
<Trans>Please select a language</Trans>
</Admonition>
)}
</View>
{error && <Admonition type="error">{error}</Admonition>}
<Button
disabled={disabled}
label={_(msg`Begin age assurance process`)}
size="large"
variant="solid"
color="primary"
onPress={onSubmit}>
<ButtonText>
<Trans>Begin</Trans>
</ButtonText>
<ButtonIcon
icon={isPending ? Loader : Shield}
position="right"
/>
</Button>
</View>
<Text
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
By continuing, you agree to the{' '}
<InlineLinkText
overridePresentation
disableMismatchWarning
label={_(msg`KWS Terms of Use`)}
to={urls.kwsTermsOfUse}
style={[a.text_xs, a.leading_snug]}>
KWS Terms of Use
</InlineLinkText>{' '}
and acknowledge that KWS will store your verified status with
your hashed email address in accordance with the{' '}
<InlineLinkText
overridePresentation
disableMismatchWarning
label={_(msg`KWS Privacy Policy`)}
to={urls.kwsPrivacyPolicy}
style={[a.text_xs, a.leading_snug]}>
KWS Privacy Policy
</InlineLinkText>
. This means you wont need to verify again the next time you
use this email for other apps, games, and services powered by
KWS technology.
</Trans>
</Text>
</>
)}
</View>
</View>
)
}
// best-effort mapping of our languages to KWS supported languages
function convertToKWSSupportedLanguage(
appLanguage: string,
): string | undefined {
// `${Enum}` is how you get a type of string union of the enum values (???) -sfn
switch (appLanguage as `${AppLanguage}`) {
// only en is supported
case 'en-GB':
return 'en'
// pt-PT is pt (pt-BR is supported independently)
case 'pt-PT':
return 'pt'
// only chinese (simplified) is supported, map all chinese variants
case 'zh-Hans-CN':
case 'zh-Hant-HK':
case 'zh-Hant-TW':
return 'zh-Hans'
default:
// try and map directly - if undefined, they will have to pick from the dropdown
return KWS_SUPPORTED_LANGS.find(v => v.value === appLanguage)?.value
}
}
@@ -0,0 +1,253 @@
import {useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {retry} from '#/lib/async/retry'
import {wait} from '#/lib/async/wait'
import {isNative} from '#/platform/detection'
import {useAgeAssuranceAPIContext} from '#/state/ageAssurance'
import {logger} from '#/state/ageAssurance/util'
import {useAgent} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export type AgeAssuranceRedirectDialogState = {
result: 'success' | 'unknown'
actorDid: string
}
/**
* Validate and parse the query parameters returned from the age assurance
* redirect. If not valid, returns `undefined` and the dialog will not open.
*/
export function parseAgeAssuranceRedirectDialogState(
state: {
result?: string
actorDid?: string
} = {},
): AgeAssuranceRedirectDialogState | undefined {
let result: AgeAssuranceRedirectDialogState['result'] = 'unknown'
const actorDid = state.actorDid
switch (state.result) {
case 'success':
result = 'success'
break
case 'unknown':
default:
result = 'unknown'
break
}
if (result && actorDid) {
return {
result,
actorDid,
}
}
}
export function useAgeAssuranceRedirectDialogControl() {
return useGlobalDialogsControlContext().ageAssuranceRedirectDialogControl
}
export function AgeAssuranceRedirectDialog() {
const {_} = useLingui()
const control = useAgeAssuranceRedirectDialogControl()
// TODO for testing
// Dialog.useAutoOpen(control.control, 3e3)
return (
<Dialog.Outer control={control.control} onClose={() => control.clear()}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Verifying your age assurance status`)}
style={[web({maxWidth: 400})]}>
<Inner optimisticState={control.value} />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
const polling = useRef(false)
const unmounted = useRef(false)
const control = useAgeAssuranceRedirectDialogControl()
const [error, setError] = useState(false)
const [success, setSuccess] = useState(false)
const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext()
useEffect(() => {
if (polling.current) return
polling.current = true
logger.metric('ageAssurance:redirectDialogOpen', {})
wait(
3e3,
retry(
5,
() => true,
async () => {
if (!agent.session) return
if (unmounted.current) return
const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState()
if (data.status !== 'assured') {
throw new Error(
`Polling for age assurance state did not receive assured status`,
)
}
return data
},
1e3,
),
)
.then(async data => {
if (!data) return
if (!agent.session) return
if (unmounted.current) return
// success! update state
await refreshAgeAssuranceState()
setSuccess(true)
logger.metric('ageAssurance:redirectDialogSuccess', {})
})
.catch(() => {
if (unmounted.current) return
setError(true)
// try a refetch anyway
refreshAgeAssuranceState()
logger.metric('ageAssurance:redirectDialogFail', {})
})
return () => {
unmounted.current = true
}
}, [agent, control, refreshAgeAssuranceState])
if (success) {
return (
<>
<View style={[a.align_start, a.w_full]}>
<AgeAssuranceBadge />
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_sm,
a.pt_lg,
a.pb_md,
]}>
<SuccessIcon size="sm" fill={t.palette.positive_600} />
<Text style={[a.text_xl, a.font_heavy]}>
<Trans>Success</Trans>
</Text>
</View>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
We've confirmed your age assurance status. You can now close this
dialog.
</Trans>
</Text>
{isNative && (
<View style={[a.w_full, a.pt_lg]}>
<Button
label={_(msg`Close`)}
size="large"
variant="solid"
color="secondary"
onPress={() => control.control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
)}
</View>
<Dialog.Close />
</>
)
}
return (
<>
<View style={[a.align_start, a.w_full]}>
<AgeAssuranceBadge />
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_sm,
a.pt_lg,
a.pb_md,
]}>
{error && <ErrorIcon size="md" fill={t.palette.negative_500} />}
<Text style={[a.text_xl, a.font_heavy]}>
{error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
</Text>
{!error && <Loader size="md" />}
</View>
<Text style={[a.text_md, a.leading_snug]}>
{error ? (
<Trans>
We were unable to receive the verification due to a connection
issue. It may arrive later. If it does, your account will update
automatically.
</Trans>
) : (
<Trans>
We're confirming your age assurance status with our servers. This
should only take a few seconds.
</Trans>
)}
</Text>
{error && isNative && (
<View style={[a.w_full, a.pt_lg]}>
<Button
label={_(msg`Close`)}
size="large"
variant="solid"
color="secondary"
onPress={() => control.control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
)}
</View>
{error && <Dialog.Close />}
</>
)
}
@@ -0,0 +1,95 @@
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export function AgeRestrictedScreen({
children,
screenTitle,
infoText,
}: {
children: React.ReactNode
screenTitle?: string
infoText?: string
}) {
const {_} = useLingui()
const copy = useAgeAssuranceCopy()
const {isReady, isAgeRestricted} = useAgeAssurance()
if (!isReady) {
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.Content>
<Layout.Header.TitleText> </Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content />
</Layout.Screen>
)
}
if (!isAgeRestricted) return children
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
{screenTitle ?? <Trans>Unavailable</Trans>}
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<View style={[a.p_lg]}>
<View style={[a.align_start, a.pb_lg]}>
<AgeAssuranceBadge />
</View>
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_xl, a.leading_snug, a.font_heavy]}>
<Trans>
You must complete age assurance in order to access this screen.
</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>{copy.notice}</Text>
</View>
<View
style={[a.flex_row, a.justify_between, a.align_center, a.pb_xl]}>
<Link
label={_(msg`Go to account settings`)}
to="/settings/account"
size="small"
variant="solid"
color="primary"
onPress={() => {
logger.metric('ageAssurance:navigateToSettings', {})
}}>
<ButtonText>
<Trans>Go to account settings</Trans>
</ButtonText>
<ButtonIcon icon={ChevronRight} position="right" />
</Link>
</View>
{infoText && <Admonition type="tip">{infoText}</Admonition>}
</View>
</Layout.Content>
</Layout.Screen>
)
}
+26
View File
@@ -0,0 +1,26 @@
export const urls = {
kwsHome: 'https://www.kidswebservices.com/en-US',
kwsTermsOfUse: 'https://www.kidswebservices.com/en-US/terms-of-use',
kwsPrivacyPolicy: 'https://www.kidswebservices.com/en-US/privacy-policy',
}
export const KWS_SUPPORTED_LANGS = [
{value: 'en', label: 'English'},
{value: 'ar', label: 'العربية'},
{value: 'zh-Hans', label: '简体中文'},
{value: 'nl', label: 'Nederlands'},
{value: 'tl', label: 'Filipino'},
{value: 'fr', label: 'Français'},
{value: 'de', label: 'Deutsch'},
{value: 'id', label: 'Bahasa Indonesia'},
{value: 'it', label: 'Italiano'},
{value: 'ja', label: '日本語'},
{value: 'ko', label: '한국어'},
{value: 'pt', label: 'Português'},
{value: 'pt-BR', label: 'Português (Brasil)'},
{value: 'ru', label: 'Русский'},
{value: 'es', label: 'Español'},
{value: 'tr', label: 'Türkçe'},
{value: 'th', label: 'ภาษาไทย'},
{value: 'vi', label: 'Tiếng Việt'},
]
@@ -0,0 +1,21 @@
import {useMemo} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function useAgeAssuranceCopy() {
const {_} = useLingui()
return useMemo(() => {
return {
notice: _(
msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`,
),
banner: _(
msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`,
),
chatsInfoText: _(
msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`,
),
}
}, [_])
}
@@ -1,259 +0,0 @@
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>
)
}
+18
View File
@@ -1,5 +1,6 @@
import {createContext, useContext, useMemo, useState} from 'react'
import {type AgeAssuranceRedirectDialogState} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
import * as Dialog from '#/components/Dialog'
import {type Screen} from '#/components/dialogs/EmailDialog/types'
@@ -17,6 +18,12 @@ type ControlsContext = {
signinDialogControl: Control
inAppBrowserConsentControl: StatefulControl<string>
emailDialogControl: StatefulControl<Screen>
linkWarningDialogControl: StatefulControl<{
href: string
displayText: string
share?: boolean
}>
ageAssuranceRedirectDialogControl: StatefulControl<AgeAssuranceRedirectDialogState>
}
const ControlsContext = createContext<ControlsContext | null>(null)
@@ -36,6 +43,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const signinDialogControl = Dialog.useDialogControl()
const inAppBrowserConsentControl = useStatefulDialogControl<string>()
const emailDialogControl = useStatefulDialogControl<Screen>()
const linkWarningDialogControl = useStatefulDialogControl<{
href: string
displayText: string
share?: boolean
}>()
const ageAssuranceRedirectDialogControl =
useStatefulDialogControl<AgeAssuranceRedirectDialogState>()
const ctx = useMemo<ControlsContext>(
() => ({
@@ -43,12 +57,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
signinDialogControl,
inAppBrowserConsentControl,
emailDialogControl,
linkWarningDialogControl,
ageAssuranceRedirectDialogControl,
}),
[
mutedWordsDialogControl,
signinDialogControl,
inAppBrowserConsentControl,
emailDialogControl,
linkWarningDialogControl,
ageAssuranceRedirectDialogControl,
],
)
@@ -185,8 +185,8 @@ export function Disable() {
state.emailStatus === 'pending'
? Loader
: state.emailStatus === 'success'
? Check
: Envelope
? Check
: Envelope
}
/>
</Button>
@@ -116,8 +116,8 @@ export function Enable() {
state.status === 'pending'
? Loader
: state.status === 'success'
? Check
: ShieldIcon
? Check
: ShieldIcon
}
/>
</Button>
+5 -4
View File
@@ -5,7 +5,7 @@ import React, {
useRef,
useState,
} from 'react'
import {TextInput, View} from 'react-native'
import {type TextInput, View} from 'react-native'
import {useWindowDimensions} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro'
@@ -15,13 +15,14 @@ import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection'
import {
Gif,
type Gif,
tenorUrlToBskyGifUrl,
useFeaturedGifsQuery,
useGifSearchQuery,
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {ListMethods} from '#/view/com/util/List'
import {type ListMethods} from '#/view/com/util/List'
import {atoms as a, ios, native, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -316,7 +317,7 @@ export function GifPreview({
t.atoms.bg_contrast_25,
]}
source={{
uri: gif.media_formats.tinygif.url,
uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url),
}}
contentFit="cover"
accessibilityLabel={gif.title}
+161
View File
@@ -0,0 +1,161 @@
import {useCallback, useMemo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {shareUrl} from '#/lib/sharing'
import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {useGlobalDialogsControlContext} from './Context'
export function LinkWarningDialog() {
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
return (
<Dialog.Outer
control={linkWarningDialogControl.control}
nativeOptions={{preventExpansion: true}}
webOptions={{alignCenter: true}}
onClose={linkWarningDialogControl.clear}>
<Dialog.Handle />
<InAppBrowserConsentInner link={linkWarningDialogControl.value} />
</Dialog.Outer>
)
}
function InAppBrowserConsentInner({
link,
}: {
link?: {href: string; displayText: string; share?: boolean}
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const t = useTheme()
const openLink = useOpenLink()
const {gtMobile} = useBreakpoints()
const potentiallyMisleading = useMemo(
() => link && isPossiblyAUrl(link.displayText),
[link],
)
const onPressVisit = useCallback(() => {
control.close(() => {
if (!link) return
if (link.share) {
shareUrl(link.href)
} else {
openLink(link.href, undefined, true)
}
})
}, [control, link, openLink])
const onCancel = useCallback(() => {
control.close()
}, [control])
return (
<Dialog.ScrollableInner
style={web({maxWidth: 450})}
label={
potentiallyMisleading
? _(msg`Potentially misleading link warning`)
: _(msg`Leaving Bluesky`)
}>
<View style={[a.gap_2xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{potentiallyMisleading ? (
<Trans>Potentially misleading link</Trans>
) : (
<Trans>Leaving Bluesky</Trans>
)}
</Text>
<Text style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}>
<Trans>This link is taking you to the following website:</Trans>
</Text>
{link && <LinkBox href={link.href} />}
{potentiallyMisleading && (
<Text
style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}>
<Trans>Make sure this is where you intend to go!</Trans>
</Text>
)}
</View>
<View
style={[
a.flex_1,
a.gap_sm,
gtMobile && [a.flex_row_reverse, a.justify_start],
]}>
<Button
label={link?.share ? _(msg`Share link`) : _(msg`Visit site`)}
accessibilityHint={_(msg`Opens link ${link?.href ?? ''}`)}
onPress={onPressVisit}
size="large"
variant="solid"
color={potentiallyMisleading ? 'secondary_inverted' : 'primary'}>
<ButtonText>
{link?.share ? (
<Trans>Share link</Trans>
) : (
<Trans>Visit site</Trans>
)}
</ButtonText>
</Button>
<Button
label={_(msg`Go back`)}
onPress={onCancel}
size="large"
variant="ghost"
color="secondary">
<ButtonText>
<Trans>Go back</Trans>
</ButtonText>
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function LinkBox({href}: {href: string}) {
const t = useTheme()
const [scheme, hostname, rest] = useMemo(() => {
try {
const urlp = new URL(href)
const [subdomain, apexdomain] = splitApexDomain(urlp.hostname)
return [
urlp.protocol + '//' + subdomain,
apexdomain,
urlp.pathname.replace(/\/$/, '') + urlp.search + urlp.hash,
]
} catch {
return ['', href, '']
}
}, [href])
return (
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_medium,
a.px_md,
{paddingVertical: 10},
a.rounded_sm,
a.border,
]}>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
{scheme}
<Text style={[a.text_md, a.leading_snug, t.atoms.text, a.font_bold]}>
{hostname}
</Text>
{rest}
</Text>
</View>
)
}
@@ -1,6 +1,10 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyFeedPostgate,
AtUri,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -22,7 +26,7 @@ import {
import {
createThreadgateViewQueryKey,
getThreadgateView,
ThreadgateAllowUISetting,
type ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
useSetThreadgateAllowMutation,
useThreadgateViewQuery,
@@ -558,7 +562,8 @@ export function usePrefetchPostInteractionSettings({
await Promise.all([
queryClient.prefetchQuery({
queryKey: createPostgateQueryKey(postUri),
queryFn: () => getPostgateRecord({agent, postUri}),
queryFn: () =>
getPostgateRecord({agent, postUri}).then(res => res ?? null),
staleTime: STALE.SECONDS.THIRTY,
}),
queryClient.prefetchQuery({
@@ -390,8 +390,8 @@ function DefaultProfileCard({
!enabled
? {opacity: 0.5}
: pressed || focused || hovered
? t.atoms.bg_contrast_25
: t.atoms.bg,
? t.atoms.bg_contrast_25
: t.atoms.bg,
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
@@ -1,360 +0,0 @@
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 {useAgent, useSession} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
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] = 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
setDidVerify={setDidVerify}
reasonText={reasonText}
changeEmailControl={changeEmailControl ?? fallbackChangeEmailControl}
reminder={reminder}
/>
</Dialog.Outer>
{!changeEmailControl && (
<ChangeEmailDialog
control={fallbackChangeEmailControl}
verifyEmailControl={control}
/>
)}
</>
)
}
export function Inner({
setDidVerify,
reasonText,
changeEmailControl,
reminder,
}: {
setDidVerify: (value: boolean) => void
reasonText?: string
changeEmailControl: Dialog.DialogControlProps
reminder?: boolean
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const {currentAccount} = useSession()
const agent = useAgent()
const {gtMobile} = useBreakpoints()
const t = useTheme()
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: '',
},
StepTwo: {
title: _(msg`Enter Code`),
message: _(
msg`An email has been sent! Please enter the confirmation code included in the email below.`,
),
},
StepThree: {
title: _(msg`Success!`),
message: _(msg`Thank you! Your email has been successfully verified.`),
},
}
const onSendEmail = async () => {
setError('')
setIsProcessing(true)
try {
await agent.com.atproto.server.requestEmailConfirmation()
setCurrentStep('StepTwo')
} catch (e: unknown) {
setError(cleanError(e))
} finally {
setIsProcessing(false)
}
}
const onVerifyEmail = async () => {
setError('')
setIsProcessing(true)
try {
await agent.com.atproto.server.confirmEmail({
email: (currentAccount?.email || '').trim(),
token: confirmationCode.trim(),
})
} catch (e: unknown) {
setError(cleanError(String(e)))
setIsProcessing(false)
return
}
setIsProcessing(false)
setDidVerify(true)
setCurrentStep('StepThree')
}
return (
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
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}
</Text>
{error ? (
<View style={[a.rounded_sm, a.overflow_hidden]}>
<ErrorMessage message={error} />
</View>
) : null}
{currentStep === 'StepOne' ? (
<View>
{reasonText ? (
<View style={[a.gap_sm]}>
<Text style={[a.text_md, a.leading_snug]}>{reasonText}</Text>
<Text style={[a.text_md, a.leading_snug]}>
Don't have access to{' '}
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
{currentAccount?.email}
</Text>
?{' '}
<InlineLinkText
to="#"
label={_(msg`Change email address`)}
style={[a.text_md, a.leading_snug]}
onPress={e => {
e.preventDefault()
control.close(() => {
changeEmailControl.open()
})
return false
}}>
<Trans>Change your email address</Trans>
</InlineLinkText>
.
</Text>
</View>
) : (
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
You'll receive an email at{' '}
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
{currentAccount?.email}
</Text>{' '}
to verify it's you.
</Trans>{' '}
<InlineLinkText
to="#"
label={_(msg`Change email address`)}
style={[a.text_md, a.leading_snug]}
onPress={e => {
e.preventDefault()
control.close(() => {
changeEmailControl.open()
})
return false
}}>
<Trans>Need to change it?</Trans>
</InlineLinkText>
</Text>
)}
</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 === '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`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onSendEmail}>
<ButtonText>
<Trans>Send confirmation</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={onVerifyEmail}>
<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`Close`)}
variant="solid"
color="primary"
size="large"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
) : null}
</View>
</View>
</Dialog.ScrollableInner>
)
}
@@ -0,0 +1,177 @@
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 {isWeb} from '#/platform/detection'
import {atoms as a, useTheme, web} 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 {Text} from '#/components/Typography'
export function ActivitySubscriptionsNUX() {
const t = useTheme()
const {_} = useLingui()
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`Introducing activity notifications`)}
style={[web({maxWidth: 400})]}
contentContainerStyle={[
{
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
},
]}>
<View
style={[
a.align_center,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{
gap: isWeb ? 16 : 24,
paddingTop: isWeb ? 24 : 48,
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]}>
<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_100,
},
]}>
<SparkleIcon fill={t.palette.primary_800} size="sm" />
<Text
style={[
a.font_bold,
{
color: t.palette.primary_800,
},
]}>
<Trans>New Feature</Trans>
</Text>
</View>
<View style={[a.relative, a.w_full]}>
<View
style={[
a.absolute,
t.atoms.bg_contrast_25,
t.atoms.shadow_md,
{
shadowOpacity: 0.4,
top: 5,
bottom: 0,
left: '17%',
right: '17%',
width: '66%',
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
]}
/>
<View
style={[
a.overflow_hidden,
{
aspectRatio: 398 / 228,
},
]}>
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/activity_notifications_announcement.webp')}
style={[
a.w_full,
{
aspectRatio: 398 / 268,
},
]}
alt={_(
msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`,
)}
/>
</View>
</View>
</View>
<View
style={[
a.align_center,
a.px_xl,
isWeb ? [a.pt_xl, a.gap_xl, a.pb_sm] : [a.pt_3xl, a.gap_3xl],
]}>
<View style={[a.gap_md, a.align_center]}>
<Text
style={[
a.text_3xl,
a.leading_tight,
a.font_heavy,
a.text_center,
{
fontSize: isWeb ? 28 : 32,
maxWidth: 300,
},
]}>
<Trans>Get notified when someone posts</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.text_center,
{
maxWidth: 340,
},
]}>
<Trans>
You can now choose to be notified when specific people post. If
theres someone you want timely updates from, go to their
profile and find the new bell icon near the follow button.
</Trans>
</Text>
</View>
{!isWeb && (
<Button
label={_(msg`Close`)}
size="large"
variant="solid"
color="primary"
onPress={() => {
control.close()
}}
style={[a.w_full, {maxWidth: 280}]}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+10 -9
View File
@@ -11,12 +11,12 @@ import {
import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/InitialVerificationAnnouncement'
import {ActivitySubscriptionsNUX} from '#/components/dialogs/nuxs/ActivitySubscriptions'
/*
* NUXs
*/
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {isDaysOld} from '#/components/dialogs/nuxs/utils'
import {isExistingUserAsOf} from '#/components/dialogs/nuxs/utils'
type Context = {
activeNux: Nux | undefined
@@ -33,9 +33,12 @@ const queuedNuxs: {
}) => boolean
}[] = [
{
id: Nux.InitialVerificationAnnouncement,
id: Nux.ActivitySubscriptions,
enabled: ({currentProfile}) => {
return isDaysOld(2, currentProfile.createdAt)
return isExistingUserAsOf(
'2025-07-07T00:00:00.000Z',
currentProfile.createdAt,
)
},
},
]
@@ -111,7 +114,7 @@ function Inner({
}
React.useEffect(() => {
if (snoozed) return
if (snoozed) return // comment this out to test
if (!nuxs) return
for (const {id, enabled} of queuedNuxs) {
@@ -119,7 +122,7 @@ function Inner({
// check if completed first
if (nux && nux.completed) {
continue
continue // comment this out to test
}
// then check gate (track exposure)
@@ -172,9 +175,7 @@ function Inner({
return (
<Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.InitialVerificationAnnouncement && (
<InitialVerificationAnnouncement />
)}
{activeNux === Nux.ActivitySubscriptions && <ActivitySubscriptionsNUX />}
</Context.Provider>
)
}
+15
View File
@@ -16,3 +16,18 @@ export function isDaysOld(days: number, createdAt?: string) {
if (isOldEnough) return true
return false
}
export function isExistingUserAsOf(date: string, createdAt?: string) {
/*
* Should never happen because we gate NUXs to only accounts with a valid
* profile and a `createdAt` (see `nuxs/index.tsx`). But if it ever did, the
* account is either old enough to be pre-onboarding, or some failure happened
* during account creation. Fail closed. - esb
*/
if (!createdAt) return false
const threshold = Date.parse(date)
const then = new Date(createdAt).getTime()
return then < threshold
}
+2 -2
View File
@@ -98,8 +98,8 @@ export function EmojiReactionPicker({
: t.palette.primary_500,
}
: alreadyReacted
? {backgroundColor: t.palette.primary_200}
: bgColor,
? {backgroundColor: t.palette.primary_200}
: bgColor,
{height: 40, width: 40},
a.justify_center,
a.align_center,
@@ -78,10 +78,12 @@ function MenuInner({
<DropdownMenu.Content
sideOffset={5}
collisionPadding={{left: 5, right: 5, bottom: 5}}>
<EmojiPicker
onEmojiSelect={handleEmojiPickerResponse}
autoFocus={true}
/>
<div onWheel={evt => evt.stopPropagation()}>
<EmojiPicker
onEmojiSelect={handleEmojiPickerResponse}
autoFocus={true}
/>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
) : (
+1 -2
View File
@@ -128,8 +128,7 @@ export let MessageContextMenu = ({
label={_(msg`Message options`)}
contentLabel={_(
msg`Message from @${
sender?.handle ?? // should always be defined
'unknown'
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`,
)}>
{children}
+2 -1
View File
@@ -196,7 +196,8 @@ export function createInput(Component: typeof TextInput) {
minWidth: 0,
},
ios({paddingTop: 12, paddingBottom: 13}),
android(a.py_md),
// Needs to be sm on Paper, md on Fabric for some godforsaken reason -sfn
android(a.py_sm),
// fix for autofill styles covering border
web({
paddingTop: 10,
@@ -0,0 +1,24 @@
import {useEffect} from 'react'
import {
type GlobalGestureEvents,
useGlobalGestureEvents,
} from '#/state/global-gesture-events'
/**
* Listen for global gesture events. Callback should be wrapped with
* `useCallback` or otherwise memoized to avoid unnecessary re-renders.
*/
export function useOnGesture(
onGestureCallback: (e: GlobalGestureEvents['begin']) => void,
) {
const ctx = useGlobalGestureEvents()
useEffect(() => {
ctx.register()
ctx.events.on('begin', onGestureCallback)
return () => {
ctx.unregister()
ctx.events.off('begin', onGestureCallback)
}
}, [ctx, onGestureCallback])
}
@@ -0,0 +1 @@
export function useOnGesture() {}
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const BellPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2a1 1 0 0 1 0 2 5.85 5.85 0 0 0-5.802 5.08L5.143 17h13.715l-.382-2.868-.01-.102a1 1 0 0 1 1.973-.262l.02.1.532 4a1 1 0 0 1-.99 1.132h-3.357c-.905 1.747-2.606 3-4.644 3s-3.74-1.253-4.643-3H4a1 1 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.61.637 1.397 1 2.22 1s1.611-.363 2.22-1H9.78ZM17 2.5a1 1 0 0 1 1 1V6h2.5a1 1 0 0 1 0 2H18v2.5a1 1 0 0 1-2 0V8h-2.5a1 1 0 1 1 0-2H16V3.5a1 1 0 0 1 1-1Z',
})
+4
View File
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const BellRinging_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2a7.854 7.854 0 0 1 7.785 6.815l1.055 7.92.018.224a2 2 0 0 1-2 2.041h-2.215c-.904 1.747-2.605 3-4.643 3s-3.739-1.253-4.643-3H5.142a2 2 0 0 1-1.982-2.265l1.056-7.92.057-.363A7.854 7.854 0 0 1 12 2ZM9.78 19c.609.637 1.398 1 2.22 1s1.611-.363 2.22-1H9.78ZM12 4a5.854 5.854 0 0 0-5.76 4.81l-.041.27L5.142 17h13.716l-1.056-7.92A5.854 5.854 0 0 0 12 4ZM2.718 7.464a1 1 0 1 1-1.953-.427l1.953.427Zm20.518-.427a1 1 0 0 1-1.954.427l1.954-.427ZM3.193 2.105a1 1 0 0 1 1.531 1.287 9.47 9.47 0 0 0-2.006 4.072L.765 7.037a11.46 11.46 0 0 1 2.428-4.932Zm16.205-.123a1 1 0 0 1 1.34.047l.069.076.217.265a11.46 11.46 0 0 1 2.212 4.667l-.978.213-.976.214a9.46 9.46 0 0 0-1.826-3.853l-.18-.22-.062-.081a1 1 0 0 1 .184-1.328Z',
})
export const BellRinging_Filled_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2a7.854 7.854 0 0 1 7.784 6.815l1.207 9.053a1 1 0 0 1-.99 1.132h-3.354c-.904 1.748-2.608 3-4.647 3-2.038 0-3.742-1.252-4.646-3H4a1.002 1.002 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.608.637 1.398 1 2.221 1s1.613-.363 2.222-1H9.779ZM3.193 2.104a1 1 0 0 1 1.53 1.288A9.47 9.47 0 0 0 2.72 7.464a1 1 0 0 1-1.954-.427 11.46 11.46 0 0 1 2.428-4.933Zm16.205-.122a1 1 0 0 1 1.409.122 11.47 11.47 0 0 1 2.429 4.933 1 1 0 0 1-1.954.427 9.47 9.47 0 0 0-2.006-4.072 1 1 0 0 1 .122-1.41Z',
})

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