Compare commits

...

114 Commits

Author SHA1 Message Date
Eric Bailey 238b00d193 Configure moderation 2025-04-10 17:05:11 -05:00
Paul Frazee bf69672674 Update translations from crowdin (#8172) 2025-04-09 16:25:16 -07:00
Eric Bailey 5371114609 Add translate metrics (#8171)
* Add translate metrics

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

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

* Pull latest translations

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

* Increase the bottom pad on the dialog flatlist component

* Format

---------

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

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

* Disable other queries if full experience is disabled

* Bump package

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

* Bump deps

* Revert @react-native-picker/picker downgrade

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

* Fix lint

---------

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

* prettier
2025-04-07 18:42:29 -05:00
Eric Bailey 815a1e4c44 Center the selected tab in the sugg follows tabbar (#8146)
Co-authored-by: Hailey <me@haileyok.com>
2025-04-07 16:34:05 -07:00
Eric Bailey 4013855c10 [Explore] Small fixes (#8145)
* Hover on sugg account

* Add subtle hover to all components except feed

* Use skeleton states for refetch on focus

* Empty results state for sugg users

* Filter out pinned posts from feed previews

* Add trending header if not top module

* Tighten up spacing

* Fetch 10 profiles

* Update interests copy

* Remove refetch on focus

* Add PTR

* use a map

* Update src/screens/Search/modules/ExploreInterestsCard.tsx

* fix web double border

---------

Co-authored-by: Hailey <me@haileyok.com>
2025-04-07 16:32:30 -07:00
Eric Bailey e46b78eb85 Remove unused index to fix lint (#8148) 2025-04-07 16:06:28 -07:00
pfrazee 94fd12ff49 Nightly source-language update 2025-04-05 02:35:03 +00:00
Paul Frazee a181b0b935 Rework the visual design of the interests card in explore (#8135) 2025-04-04 18:10:13 -07:00
Paul Frazee a301e31f76 Fix the 'seen' metric in explore (#8134) 2025-04-04 17:28:02 -07:00
Eric Bailey 502d660eb4 [Explore] Invalidate queries post-interests edit (#8128)
* Insert fresh interests and reset relevant queries

* reset suggested users

---------

Co-authored-by: Hailey <me@haileyok.com>
2025-04-04 17:16:39 -07:00
Paul Frazee 6840fbb237 Update deps to support ios 18.4 (#8133) 2025-04-04 17:04:57 -07:00
Eric Bailey 0b08128e4a [Explore] Design tweaks (#8131)
* Fix interests card spacing

* Space out AvatarStack

* SP icon size

* Visual alignment of tab bar and headers

* Tweak spacing around search input

* Drop text size in sp card overflow count

* Tweak
2025-04-04 16:55:30 -07:00
Eric Bailey aca89d4aea [Explore] New suggested follows endpoint (#8130)
* Bump SDK

* Integrate new endpoint, add profile shadow, For You tab

* Format
2025-04-04 16:44:02 -07:00
Eric Bailey a0ff9b52aa [Explore] Add interests card (#8125)
* Add interests card to top of Explore

* Add tip to add interests if they have none or deselect

* Format

* Copy
2025-04-04 14:08:16 -05:00
Samuel Newman 5c59ec14c2 Match placeholder color with other skeletons (#8129) 2025-04-04 11:00:38 -07:00
Eric Bailey 94bc677ef5 Rename search to explore in titles and links (#8116)
* Rename search to explore in titles and links

* Conditionally use Explore
2025-04-04 08:30:12 -07:00
Eric Bailey 148bfa803d Content interests (#8114)
* Content prefs screen

* Handle debounce, rename

* Fix format

* Let's just use interests

* Reuse hook
2025-04-04 10:26:48 -05:00
Samuel Newman 0004e27427 [Explore] Add index to view metric (#8124)
* add index to view metric

* supply index

* always return a number
2025-04-04 08:24:26 -07:00
Samuel Newman ceb51c7e8f [Explore] Add flatlist perf-related props (#8120)
* Add flatlist perf-related props

* Make keys more unique

* Small gap

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-04-04 08:15:56 -07:00
Samuel Newman 22a7199436 remove use of sticky topBorder, add border to feed footer (#8121) 2025-04-04 17:30:46 +03:00
Samuel Newman fa08428428 [Explore] Moderate trending topic avis (#8123)
* moderate trending topic avis

* filter blurs too
2025-04-04 07:24:43 -07:00
Samuel Newman 93af013210 log views of starter packs header (#8118) 2025-04-04 07:15:59 -07:00
Samuel Newman a0c644acfe [Explore] Add shadow cache (#8119)
* enable shadowcache for feed previews

* extract keyextractor

* dedupe feeds
2025-04-04 07:15:40 -07:00
pfrazee a15658cb66 Nightly source-language update 2025-04-04 02:36:04 +00:00
Samuel Newman 898065d0a2 memoize each module individually (#8111) 2025-04-03 16:02:20 -05:00
Eric Bailey faa5f40c53 Wait for preferences before fetching trending data (#8109) 2025-04-03 13:32:35 -05:00
pfrazee b6185039f7 Nightly source-language update 2025-04-03 02:36:31 +00:00
Samuel Newman 87da619aaa [Explore] Base (#8053)
* migrate to #/screens

* rm unneeded import

* block drawer gesture on recent profiles

* rm recommendations (#8056)

* [Explore] Disable Trending videos (#8054)

* remove giant header

* disable

* [Explore] Dynamic module ordering (#8066)

* Dynamic module ordering

* [Explore] New headers, metrics (#8067)

* new sticky headers

* improve spacing between modules

* view metric on modules

* update metrics names

* [Explore] Suggested accounts module (#8072)

* use modern profile card, update load more

* add tab bar

* tabbed suggested accounts

* [Explore] Discover feeds module (#8073)

* cap number of feeds to 3

* change feed pin button

* Apply suggestions from code review

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

* restore statsig to log events

* filter out followed profiles, make suer enough are loaded (#8090)

* [Explore] Trending topics (#8055)

* redesigned trending topics

* rm borders on web

* get post count / age / ranking from api

* spacing tweaks

* fetch more topics then slice

* use api data for avis/category

* rm top border

* Integrate new SDK, part out components

* Clean up

* Use status field

* Bump SDK

* Send up interests and langs

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Clean up module spacing and borders

(cherry picked from commit 63d19b6c2d67e226e0e14709b1047a1f88b3ce1c)
(cherry picked from commit 62d7d394ab1dc31b40b9c2cf59075adbf94737a1)

* Switch back border ordering

(cherry picked from commit 34e3789f8b410132c1390df3c2bb8257630ebdd9)

* [Explore] Starter Packs (#8095)

* Temp WIP

(cherry picked from commit 43b5d7b1e64b3adb1ed162262d0310e0bf026c18)

* New SP card

* Load state

* Revert change

* Cleanup

* Interests and caching

* Count total

* Format

* Caching

* [Explore] Feed previews module (#8075)

* wip new hook

* get fetching working, maybe

* get feed previews rendering!

* fix header height

* working pin button

* extract out FeedLink

* add loader

* only make preview:header sticky

* Fix headers

* Header tweaks

* Fix moderation filter

* Fix threading

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Space it out

* Fix query key

* Mock new endpoint, filter saved feeds

* Make sure we're pinning, lower cache time

* add news category

* Remove log

* Improve suggested accounts load state

* Integrate new app view endpoint

* fragment

* Update src/screens/Search/modules/ExploreTrendingTopics.tsx

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

* Update src/screens/Search/modules/ExploreTrendingTopics.tsx

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

* lint

* maybe fix this

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Hailey <me@haileyok.com>
2025-04-02 17:21:15 -07:00
surfdude29 8d1f97b5ff Tweak copy to remove remaining uses of sign up (#7987)
* tweak copy in Deactivated.tsx

* tweak copy in Policies.tsx

* tweak comment in createNativeStackNavigatorWithAuth.tsx

* tweak copy in LoginForm.tsx

* tweak copy in StarterPackLandingScreen.tsx

* format

* Apply suggestions from code review

Co-authored-by: Hailey <hailey@blueskyweb.xyz>

* Update src/screens/StarterPack/StarterPackLandingScreen.tsx

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

* Update src/screens/StarterPack/StarterPackLandingScreen.tsx

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

---------

Co-authored-by: Hailey <hailey@blueskyweb.xyz>
2025-04-02 16:22:06 -07:00
surfdude29 4a7fa07e87 Add plural formatting, translate and tweak some accessibility strings in composer (#7997)
* add plural formatting and translate accessibility strings

* tweak accessibility label and add comments for translators

* fix

* tweak comments

* make label conditionally plural

* rm stray bracket

* try to fix prettier

* nope

* lint

* more lint

* prettier

---------

Co-authored-by: Hailey <me@haileyok.com>
2025-04-02 15:44:03 -07:00
Samuel Newman 0897a692da fix bg color (#8102) 2025-04-02 15:25:56 -07:00
surfdude29 c5900034a4 Simplify content warning dialog text (#7943)
* simplify content warning dialog text

* tweak copy

* Update src/view/com/composer/labels/LabelsBtn.tsx

Co-authored-by: Hailey <hailey@blueskyweb.xyz>

---------

Co-authored-by: Hailey <hailey@blueskyweb.xyz>
2025-04-02 14:51:10 -07:00
aryiu 0874d9602a Update languages.ts (#7996) 2025-04-02 10:15:02 -05:00
pfrazee 1289707b17 Nightly source-language update 2025-04-02 02:36:32 +00:00
surfdude29 758cb731f0 Add plural formatting for alt text truncated string (#7994)
* add plural formatting in GifAltText.tsx

* add plural formatting in ImageAltTextDialog.tsx

* import Plural

* format
2025-04-01 16:09:53 -07:00
surfdude29 5130f932ed Make Posts and People tabs translatable for user lists (#7982)
* mark Posts and People as translatable in lists

* tweak labels
2025-04-01 16:01:44 -07:00
Minseo Lee 8dfdee80bb Reapply "Modify hosting provider text for better localization" (#7928)
* Update HostingProvider.tsx

* Update src/components/forms/HostingProvider.tsx

---------

Co-authored-by: Hailey <hailey@blueskyweb.xyz>
2025-04-01 16:00:14 -07:00
surfdude29 df9909baa3 Translate number joined starter pack string and add plural formatting (#7984)
* translate number joined starter pack string

* also add plural formatting in StarterPackScreen.tsx

* import Plural

* tweak

* tweak
2025-04-01 15:59:53 -07:00
surfdude29 7a041da3a8 Add plural formatting for STARTER_PACK_MAX_SIZE toast (#8000)
* add plural formatting for starter pack max size toast

* tweak

* try again
2025-04-01 15:51:57 -07:00
surfdude29 ffbcbf814e add plural formatting for too long handle error (#7999) 2025-04-01 15:51:39 -07:00
surfdude29 98fe7edbc8 Add plural formatting for "too long" error messages when editing profile info (#7998)
* add plural formatting for profile max graphemes errors

* add missing tag

* simplify
2025-04-01 15:50:58 -07:00
Samuel Newman 5cb49314d0 [Reactions] Get reactions in chat list working again with new lexicon (#8093)
* get chat list reactions working again with new lexicon

* fix weird android bug where background wasn't clipped
2025-04-01 15:45:29 -07:00
Samuel Newman 318b29d3fd Fix loading jumps and footer on feeds tab (#8063)
* Fix loading jumps and footer on feeds tab

* same for lists/starter packs
2025-04-01 14:19:59 -07:00
Eric Bailey 4db3ccbec7 Fix suggested follows scrolling (#8036) 2025-04-01 11:33:31 -05:00
surfdude29 3d7fce563e add msg macro in EmojiPopup.android.tsx (#8091) 2025-04-01 09:36:46 -05:00
Samuel Newman fffcb609c6 fix warnings (#8092) 2025-04-01 17:27:40 +03:00
Samuel Newman 07d667dba8 Fix broken error screen in DMs (#8071) 2025-04-01 17:22:59 +03:00
pfrazee a9675b372e Nightly source-language update 2025-03-29 02:34:59 +00:00
Jason Culverhouse 036bbfbaf9 Remove double decode of URI (#8068)
At this point the URI is already decoded and decoding again will alter the uri 

```
      let link = req.query.u
```

example  of a link that has `%` encoding... the initial redirect link is properly encoded.

```
curl -vv "https://go.bsky.app/redirect?u=https%3A%2F%2Fsurf.social%2Ffeed%2Fsurf%252Fcustom%252F01jpz5vyjwvw5yaa8bfkha5xn4" 
```
The result is "double decoded", the proper link in this case should be `https://surf.social/feed/surf%2Fcustom%2F01jpz5vyjwvw5yaa8bfkha5xn4`

```
<html><head><meta http-equiv="refresh" content="0; URL='https://surf.social/feed/surf/custom/01jpz5vyjwvw5yaa8bfkha5xn4'" /><style>:root { color-scheme: light dark; }</style></head></html>
```

After changes:

```
curl -s "http://localhost:3000/redirect?u=https%3A%2F%2Fsurf.social%2Ffeed%2Fsurf%252Fcustom%252F01jpz5vyjwvw5yaa8bfkha5xn4"
<html><head><meta http-equiv="refresh" content="0; URL='https://surf.social/feed/surf%2Fcustom%2F01jpz5vyjwvw5yaa8bfkha5xn4'" /><style>:root { color-scheme: light dark; }</style></head></html>
```
2025-03-28 16:50:16 -07:00
Eric Bailey 7c124a8705 Preload emoji picker data for reactions on web (#8079)
* Preload emoji picker data for reactions on web

* Sort imports
2025-03-28 17:01:21 -05:00
Eric Bailey 78220e29d9 Put applied reactions inside context menu wrapper on native (#8078) 2025-03-28 16:56:32 -05:00
Eric Bailey c2d6fb82f3 Design tweaks to applied reactions (#8077)
* Adjust styles of applied reactions

* Tweaks for applied reactions styles
2025-03-28 16:38:41 -05:00
Eric Bailey a84ba121fa Some design tweaks to web emoji picker (#8076)
* Tighten up and reverse action items on web

* Tweak web styles for emoji bar

* Might as well make disabled more obvious

* Format
2025-03-28 16:38:24 -05:00
Samuel Newman 152bc3c1ec [DMs] Reactions - link up API (attempt 2) (#8074)
* update package

* wire up APIs

* get reactions to display

* allow removing emoji

* handle limits better

* listen to reactions in log

* update convo list with reactions

* tweaks to reaction display

* Handle empty message fallback case

* update package

* shift reacts up by 2px

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-03-28 07:34:07 -07:00
Samuel Newman 55a40c2436 [DMs] Emoji reaction picker (#8023) 2025-03-28 08:43:40 +02:00
pfrazee ac2c2a9a1d Nightly source-language update 2025-03-28 02:36:35 +00:00
Samuel Newman 5ceaee5793 Instrument signup (#8037) 2025-03-27 20:17:07 +02:00
Samuel Newman 7d1ebf6a02 Allow selecting ContextMenu options via press-and-hold (#8020)
* save locations of menu items

* enable panning to select items

* rm unused type

* fix haptic overfiring
2025-03-27 09:53:42 -07:00
Eric Bailey 1e688dee24 Make sure Sentry's auth token is set on process (#8040)
* Make sure Sentry's auth token is set on process

* Pass in token explicitly
2025-03-27 09:43:59 -05:00
Samuel Newman c265e0fb96 fix people list footer (#8039) 2025-03-27 10:18:02 +02:00
pfrazee 5dc4f0450c Nightly source-language update 2025-03-25 02:35:58 +00:00
Samuel Newman a48e64e72a make the sidebar smaller when offset, reduce total offset (#8052) 2025-03-24 22:23:27 +02:00
Samuel Newman 412b107420 Extract center column offset, reduce by 20px (#7974)
* extract offset, reduce by 20px

* move constant to `Layout/const.ts`
2025-03-24 12:55:05 -07:00
omar0404 1b8719293c fix:Conversations don't refresh when going offline and back online (#7985) 2025-03-24 12:45:43 -07:00
Samuel Newman dba6f4bb1f Revert "Small revert to be on the safe side (#7932)" (#7958)
This reverts commit 0014b8b9cd.
2025-03-24 12:11:13 -07:00
Bas van den Wollenberg 6a5770ee89 change touch device detection method (#5919) 2025-03-24 12:07:56 -07:00
Samuel Newman 7daf7a703e haptics on external link card (#8049) 2025-03-24 10:17:35 -07:00
pfrazee 6fcf97979b Nightly source-language update 2025-03-22 02:33:52 +00:00
Samuel Newman 951a8cff3c bump version to 1.100.0 (#8038) 2025-03-21 18:51:29 +02:00
Samuel Newman c4785ef96e New ContextMenu menu type for DM messages (#8014)
* get context menu somewhat working ish

* take screenshot rather than double rendering

* get animations somewhat working

* get transform animation working

* rm log

* upwards safe area

* get working on android

* get android working once and for all

* fix positioning on both platforms

* use dark blur on ios always, fix dark mode

* allow closing with hardware back press

* try and fix type error

* add note about ts-ignore

* round post

* add image capture error handling

* extract magic numbers

* set explicit embed width, rm top margin

* Message embed width tweaks

* Format

* fix position of embeds

* same as above for web

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-03-21 09:29:14 -07:00
Samuel Newman f6f253b4c9 Add eslint consistent type import rules (#8035)
* add eslint-plugin-import

* turn off eslint/no-usused-vars
2025-03-21 08:43:10 -07:00
pfrazee 3cfac98f95 Nightly source-language update 2025-03-21 02:35:41 +00:00
Hailey 52429b651f blink: use meta for redirect (#8031)
* try using meta

* color scheme

* revert test change

* escape
2025-03-20 15:08:54 -07:00
Hailey 0e658dbdfd blink: redirect on root route (#8029)
* make root route redirect to 301

* use config

* end

* add a comment
2025-03-20 14:50:15 -07:00
Samuel Newman d3c44ebc5b Fix post thread item hider line height crop (#8028) 2025-03-20 23:33:19 +02:00
pfrazee 950e2e5f74 Nightly source-language update 2025-03-20 02:34:32 +00:00
Samuel Newman 57d91373f7 rm bad ref in render usage (#8024) 2025-03-19 11:26:09 -07:00
pfrazee 6d8124c7ea Nightly source-language update 2025-03-18 02:35:37 +00:00
Hailey aa86a8619c remove from iab (#8017) 2025-03-17 15:13:34 -07:00
Hailey c612fca32a remove .parse usage (#8016)
* remove .parse usage

* add one more usage
2025-03-17 15:06:26 -07:00
Eric Bailey 4b7bda705e Don't poll for latest for Discover (#7993) 2025-03-17 12:18:59 -05:00
Paul Frazee 799205e6c2 Update i18n (#7992)
* Pull latest from crowdin

* Run intl:extract:all
2025-03-14 13:36:01 -07:00
Eric Bailey 5bff031e72 A few good metrics (#7897)
* Align dist and release tags on all platforms

* Upload web sourcemaps

* Debug code

* Send debug and error logging to Sentry

* Let .env do the work

* Try straight env

* Try copy in Dockerfile

* Ok ok ok

* Debug Dockerfile

* Proper setup

* See where we're at

* Reference var correctly

* Fall back to package version

* Verbose force env

* Should be working native builds

* Comments

* Remove unused deps

* Revert unneeded changes

* Remove debug code

* Clarify intent

* Remove deprecated files

* Add sentry vars to OTA

* Debug

* Revert "Debug"

This reverts commit 2d5857fb0fb0858ac3e2f98bc56bde913078d0a7.

* Use included CLI to upload native source maps post-OTA build

* Use included bin script

See https://github.com/getsentry/sentry-react-native/commit/0d87adb516b2a5ea61353e25098c31f9aeb7e503

* Bump sentry, update script

* Comment

* fix failing CI

* Pin sentry, update patch, update lock

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-03-14 12:08:19 -05:00
Eric Bailey 9cea7f0393 Fix missing RecordWithMedia media embed preview in notifications view (#7988) 2025-03-14 10:28:02 -05:00
Eric Bailey dcc8b35146 Add checks for invalid syntax post-i18n builds (#7935) 2025-03-14 10:27:45 -05:00
pfrazee cb54c9833e Nightly source-language update 2025-03-14 02:33:55 +00:00
Samuel Newman 1f1c1cfbcd Filter deleted convos from inbox (#7944)
* filter deleted convos from inbox

* fix logic
2025-03-13 08:50:26 -07:00
Samuel Newman 6cbd9fbc45 Fix lists tab appearing when user has a starterpack but no lists (#7952)
* Fix list tab showing up when no lists

* tweak code
2025-03-13 08:46:24 -07:00
Samuel Newman c7ba0e4f66 fix newskie dialog width (#7949) 2025-03-13 08:44:14 -07:00
surfdude29 4b94a8a8f2 fix typo (#7970) 2025-03-13 08:35:13 -07:00
Samuel Newman c402ae741f Restore hardcoded DIDs for Discover debug (#7977) 2025-03-13 16:59:21 +02:00
pfrazee b2a6281b36 Nightly source-language update 2025-03-13 02:35:07 +00:00
surfdude29 597e3c1abd tweak label (#7937) 2025-03-12 06:59:21 -07:00
omar0404 5e1c262edc fix: Account switcher shows outdated profile name (#7969) 2025-03-12 06:58:57 -07:00
pfrazee 810e85c073 Nightly source-language update 2025-03-12 02:34:05 +00:00
surfdude29 d16c80d928 Update VideoTooLargeError strings (#7923) 2025-03-12 00:56:40 +02:00
Samuel Newman e7212f9904 clean up logic for showing request buttons (#7964) 2025-03-11 13:12:49 -07:00
Hailey 490e381270 encode url (#7968) 2025-03-11 12:50:17 -07:00
Samuel Newman 26fb0a7f17 fix iframe transparency by unspecifying color-scheme (#7967) 2025-03-11 12:21:27 -07:00
Samuel Newman 94d7bc986f Fix color theme for embed (#7965)
* fix embed default theme

* use correct dataset string
2025-03-11 11:04:06 -07:00
Samuel Newman 8cb55adf8c fix submit behaviour (#7959) 2025-03-11 09:37:53 -07:00
226 changed files with 50883 additions and 35809 deletions
+11
View File
@@ -15,6 +15,7 @@ module.exports = {
'simple-import-sort',
'bsky-internal',
'eslint-plugin-react-compiler',
'import',
],
rules: {
'react/no-unescaped-entities': 0,
@@ -81,6 +82,16 @@ module.exports = {
],
'simple-import-sort/exports': 'error',
'react-compiler/react-compiler': 'warn',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{argsIgnorePattern: '^_', varsIgnorePattern: '^_'},
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
],
'import/consistent-type-specifier-style': ['warn', 'prefer-inline'],
},
ignorePatterns: [
'**/__mocks__/*.ts',
@@ -45,7 +45,10 @@ jobs:
- name: Set outputs
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
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
@@ -60,3 +63,6 @@ jobs:
cache-to: type=gha,mode=max
build-args: |
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 }}
+12 -3
View File
@@ -56,7 +56,10 @@ jobs:
run: yarn install
- name: 🔤 Compile translations
run: yarn intl:build
run: yarn intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: ✏️ Write environment variables
run: |
@@ -67,8 +70,14 @@ jobs:
echo "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: yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
- name: ✍️ Rename Testflight bundle
if: ${{ inputs.profile != 'production' }}
@@ -130,7 +139,7 @@ jobs:
- name: 🏗️ Build Production APK
if: ${{ inputs.profile == 'production' }}
run: yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
- name: 🚀 Upload Production APK Artifact
id: upload-artifact-production-apk
+11 -2
View File
@@ -69,7 +69,10 @@ jobs:
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
- name: 🔤 Compile translations
run: yarn intl:build
run: yarn intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: ✏️ Write environment variables
run: |
@@ -79,8 +82,14 @@ jobs:
echo "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: yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa
+12 -3
View File
@@ -75,8 +75,11 @@ jobs:
- name: Prettier check
run: yarn prettier --check .
- name: Check & compile i18n
run: yarn intl:build
- name: 🔤 Compile translations
run: yarn intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Type check
run: yarn typecheck
@@ -107,9 +110,15 @@ jobs:
echo "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: EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
- name: 📦 Package Bundle and 🚀 Deploy
if: ${{ !steps.fingerprint.outputs.includes-changes }}
+27 -2
View File
@@ -4,21 +4,45 @@ WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive
#
# Node
#
ENV NODE_VERSION=20
ENV NVM_DIR=/usr/share/nvm
#
# Go
#
ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
#
# Expo
#
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
#
# 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}
#
# Copy everything into the container
#
COPY . .
#
@@ -38,8 +62,9 @@ RUN \. "$NVM_DIR/nvm.sh" && \
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env && \
npm install --global yarn && \
yarn && \
yarn intl:build && \
EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER EXPO_PUBLIC_BUNDLE_DATE=$() yarn build-web
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 yarn build-web
# DEBUG
RUN find ./bskyweb/static && find ./web-build/static
-1
View File
@@ -17,7 +17,6 @@ appId: xyz.blueskyweb.app
- inputText: "Post with an image"
- tapOn:
id: "openGalleryBtn"
- tapOn: "Allow Full Access"
- tapOn: "Content warnings"
- tapOn: "Porn"
- tapOn:
+2 -22
View File
@@ -36,9 +36,6 @@ module.exports = function (config) {
const UPDATES_ENABLED = !!UPDATES_CHANNEL
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
const SENTRY_DIST = `${PLATFORM}.${VERSION}.${IS_TESTFLIGHT ? 'tf' : ''}${
IS_DEV ? 'dev' : ''
}`
return {
expo: {
@@ -204,9 +201,8 @@ module.exports = function (config) {
'@sentry/react-native/expo',
{
organization: 'blueskyweb',
project: 'react-native',
release: VERSION,
dist: SENTRY_DIST,
project: 'app',
url: 'https://sentry.io',
},
],
[
@@ -407,22 +403,6 @@ module.exports = function (config) {
projectId: '55bd077a-d905-4184-9c7f-94789ba0f302',
},
},
hooks: {
postPublish: [
/*
* @see https://docs.expo.dev/guides/using-sentry/#app-configuration
*/
{
file: './postHooks/uploadSentrySourcemapsPostHook',
config: {
organization: 'blueskyweb',
project: 'react-native',
release: VERSION,
dist: SENTRY_DIST,
},
},
],
},
},
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.158 2.879c.584-.835 1.757-1.137 2.673-.507.951.654 2.597 1.92 4.013 3.694S20.5 10.194 20.5 13c0 4.997-3.752 9-8.5 9s-8.5-4.003-8.5-9c0-2.035.874-4.636 2.578-6.712.746-.91 2.034-.855 2.786-.133l2.294-3.276Zm-3.04 15.758C6.538 17.386 5.5 15.37 5.5 13c0-1.511.666-3.616 2.042-5.342.87.797 2.254.653 2.939-.325l2.286-3.265c.871.606 2.299 1.723 3.514 3.246C17.53 8.879 18.5 10.804 18.5 13c0 2.369-1.038 4.386-2.618 5.637q.117-.518.118-1.061c0-2.601-2.038-4.382-2.911-5.04a1.8 1.8 0 0 0-2.177 0C10.038 13.195 8 14.976 8 17.577q0 .543.118 1.061ZM12 14.222c-.825.648-2 1.859-2 3.354C10 19.043 11.016 20 12 20s2-.957 2-2.424c0-1.495-1.175-2.706-2-3.354Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 791 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M15 7a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9.414L14.414 15a2 2 0 0 1-2.828 0L9 12.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L7.586 11a2 2 0 0 1 2.828 0L13 13.586 18.586 8H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 336 B

+3 -1
View File
@@ -68,7 +68,9 @@ function scan(node = document) {
if (ref_url.startsWith('http')) {
searchParams.set('ref_url', encodeURIComponent(ref_url))
}
searchParams.set('colorMode', embed.dataset.blueskyColorMode || 'system')
if (embed.dataset.blueskyEmbedColorMode) {
searchParams.set('colorMode', embed.dataset.blueskyEmbedColorMode)
}
const iframe = document.createElement('iframe')
iframe.setAttribute('data-bluesky-id', id)
+1 -1
View File
@@ -6,7 +6,7 @@
word-break: break-word;
}
:root {
#app {
color-scheme: light dark;
}
+2
View File
@@ -9,8 +9,10 @@
},
"dependencies": {
"@atproto/common": "^0.4.0",
"@types/escape-html": "^1.0.4",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"escape-html": "^1.0.3",
"express": "^4.19.2",
"http-terminator": "^3.2.0",
"kysely": "^0.27.3",
+2
View File
@@ -4,6 +4,7 @@ import {AppContext} from '../context.js'
import {default as createShortLink} from './createShortLink.js'
import {default as health} from './health.js'
import {default as redirect} from './redirect.js'
import {default as root} from './root.js'
import {default as shortLink} from './shortLink.js'
import {default as siteAssociation} from './siteAssociation.js'
@@ -14,6 +15,7 @@ export default function (ctx: AppContext, app: Express) {
app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
app = redirect(ctx, app) // GET /redirect
app = createShortLink(ctx, app) // POST /link
app = root(ctx, app) // GET / (redirect to bsky.app on root)
app = shortLink(ctx, app) // GET /:linkId (should go last due to permissive matching)
return app
}
+8 -3
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert'
import {DAY, SECOND} from '@atproto/common'
import escapeHTML from 'escape-html'
import {Express} from 'express'
import {AppContext} from '../context.js'
@@ -20,7 +21,6 @@ export default function (ctx: AppContext, app: Express) {
typeof link === 'string',
'express guarantees link query parameter is a string',
)
link = decodeURIComponent(link)
let url: URL | undefined
try {
@@ -40,8 +40,13 @@ export default function (ctx: AppContext, app: Express) {
}
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
res.setHeader('Location', url.href)
return res.status(301).end()
res.type('html')
res.status(200)
const escaped = escapeHTML(url.href)
return res.send(
`<html><head><meta http-equiv="refresh" content="0; URL='${escaped}'" /><style>:root { color-scheme: light dark; }</style></head></html>`,
)
}),
)
}
+14
View File
@@ -0,0 +1,14 @@
import {Express} from 'express'
import {AppContext} from '../context.js'
import {handler} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/',
handler(async (_req, res) => {
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
return res.status(301).end()
}),
)
}
+6 -1
View File
@@ -69,6 +69,11 @@
dependencies:
"@types/node" "*"
"@types/escape-html@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-1.0.4.tgz#dc7c166b76c7b03b27e32f80edf01d91eb5d9af2"
integrity sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==
"@types/node@*":
version "20.14.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18"
@@ -276,7 +281,7 @@ es-errors@^1.3.0:
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
escape-html@~1.0.3:
escape-html@^1.0.3, escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
+1
View File
@@ -275,6 +275,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/account", server.WebGeneric)
e.GET("/settings/privacy-and-security", server.WebGeneric)
e.GET("/settings/content-and-media", server.WebGeneric)
e.GET("/settings/interests", server.WebGeneric)
e.GET("/settings/about", server.WebGeneric)
e.GET("/settings/app-icon", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
+4 -4
View File
@@ -1,11 +1,11 @@
# Internationalization
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/u/projects/1) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
## Tools
- We use Crowdin to manage translations.
- Bluesky Crowdin: https://bluesky.crowdin.com/u/projects/1
- Bluesky Crowdin: https://bluesky.crowdin.com/bluesky-social
- Introduction to Crowdin: https://support.crowdin.com/for-translators/
- We use Lingui to implement translations. You can find the documentation [here](https://lingui.dev/).
@@ -15,7 +15,7 @@ Much of the app is translated by community contributions. (We <3 our translators
### Using Crowdin
[Crowdin](https://bluesky.crowdin.com/u/projects/1) is our primary tool for managing translations. There are two roles:
[Crowdin](https://bluesky.crowdin.com/bluesky-social) is our primary tool for managing translations. There are two roles:
- **Proof-readers**. Can create new translations and approve submitted translations.
- **Translators**. Can create new translations.
@@ -32,7 +32,7 @@ Please treat everyone with respect. Proof-readers are given final say on transla
### Adding a new language
You can request a new language be added to the project by clicking **Request New Language** on Crowdin or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
You can request a new language be added to the project by clicking **Request New Language** on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
Please only request a new language when you are certain you will be able to contribute a substantive portion of translations for the language.
+2 -2
View File
@@ -1,6 +1,6 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const {getDefaultConfig} = require('expo/metro-config')
const cfg = getDefaultConfig(__dirname)
const {getSentryExpoConfig} = require('@sentry/react-native/metro')
const cfg = getSentryExpoConfig(__dirname)
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
+50 -46
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.99.0",
"version": "1.100.0",
"private": true,
"engines": {
"node": ">=20"
@@ -50,14 +50,15 @@
"intl:push": "crowdin push translations --verbose -b main",
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
"update-extensions": "bash scripts/updateExtensions.sh",
"export": "npx expo export",
"export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps",
"upload-native-sourcemaps": "npx sentry-expo-upload-sourcemaps dist",
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 yarn build-web",
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web",
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.14.7",
"@atproto/api": "^0.14.21",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -85,12 +86,12 @@
"@radix-ui/react-focus-scope": "^1.1.2",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-menu/menu": "^1.1.7",
"@react-native-picker/picker": "2.10.2",
"@react-native-picker/picker": "2.10.3",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/drawer": "^6.6.15",
"@react-navigation/native": "^6.1.17",
"@react-navigation/native-stack": "^6.9.26",
"@sentry/react-native": "5.24.3",
"@sentry/react-native": "~6.10.0",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "^5.8.1",
"@tanstack/react-query-persist-client": "^5.25.0",
@@ -122,35 +123,35 @@
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "~52.0.19",
"expo-application": "^6.0.1",
"expo": "~52.0.42",
"expo-application": "~6.0.2",
"expo-blur": "^14.0.3",
"expo-build-properties": "^0.13.1",
"expo-camera": "~16.0.7",
"expo-clipboard": "^7.0.0",
"expo-dev-client": "^5.0.4",
"expo-device": "~7.0.1",
"expo-file-system": "^18.0.6",
"expo-font": "~13.0.1",
"expo-haptics": "^14.0.0",
"expo-image": "~2.0.3",
"expo-image-manipulator": "^13.0.5",
"expo-image-picker": "~16.0.3",
"expo-linear-gradient": "^14.0.1",
"expo-linking": "^7.0.3",
"expo-localization": "~16.0.0",
"expo-media-library": "~17.0.3",
"expo-navigation-bar": "~4.0.4",
"expo-notifications": "~0.29.11",
"expo-screen-orientation": "^8.0.2",
"expo-sharing": "^13.0.0",
"expo-splash-screen": "~0.29.18",
"expo-status-bar": "~2.0.0",
"expo-system-ui": "^4.0.4",
"expo-task-manager": "~12.0.3",
"expo-updates": "~0.26.10",
"expo-video": "^2.0.5",
"expo-web-browser": "~14.0.1",
"expo-build-properties": "~0.13.2",
"expo-camera": "~16.0.18",
"expo-clipboard": "~7.0.1",
"expo-dev-client": "~5.0.19",
"expo-device": "~7.0.3",
"expo-file-system": "~18.0.12",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-image-manipulator": "~13.0.6",
"expo-image-picker": "~16.0.6",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.5",
"expo-localization": "~16.0.1",
"expo-media-library": "~17.0.6",
"expo-navigation-bar": "~4.0.9",
"expo-notifications": "~0.29.14",
"expo-screen-orientation": "~8.0.4",
"expo-sharing": "~13.0.1",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.9",
"expo-task-manager": "~12.0.6",
"expo-updates": "~0.27.4",
"expo-video": "~2.0.6",
"expo-web-browser": "~14.0.2",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls.js": "^1.5.11",
@@ -168,30 +169,31 @@
"patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"react": "18.2.0",
"react": "18.3.1",
"react-compiler-runtime": "19.0.0-beta-a7bf2bd-20241110",
"react-dom": "^18.2.0",
"react-dom": "18.3.1",
"react-image-crop": "^11.0.7",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.76.6",
"react-native": "0.76.9",
"react-native-compressor": "1.10.3",
"react-native-date-picker": "^5.0.7",
"react-native-drawer-layout": "^4.1.1",
"react-native-emoji-popup": "^0.1.2",
"react-native-gesture-handler": "2.20.2",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "^0.41.6",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.14.5",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.7.0",
"react-native-pager-view": "6.5.1",
"react-native-picker-select": "^9.3.1",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "3.17.0-nightly-20241211-17e89ca24",
"react-native-reanimated": "~3.16.1",
"react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.14.0",
"react-native-screens": "~4.3.0",
"react-native-svg": "^15.8.0",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.2",
@@ -199,7 +201,7 @@
"react-native-vision-camera": "^4.6.3",
"react-native-web": "~0.19.13",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.12.3",
"react-native-webview": "13.12.5",
"react-remove-scroll-bar": "^2.3.6",
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
@@ -221,8 +223,9 @@
"@lingui/cli": "^4.14.1",
"@lingui/macro": "^4.14.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native/eslint-config": "^0.76.2",
"@react-native/typescript-config": "^0.76.1",
"@react-native/eslint-config": "^0.76.9",
"@react-native/typescript-config": "^0.76.9",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^12.8.1",
"@types/jest": "^29.4.0",
@@ -244,6 +247,7 @@
"eslint": "^8.19.0",
"eslint-plugin-bsky-internal": "link:./eslint",
"eslint-plugin-ft-flow": "^2.0.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-lingui": "^0.2.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
@@ -253,7 +257,7 @@
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
"jest-expo": "^52.0.2",
"jest-expo": "~52.0.6",
"jest-junit": "^16.0.0",
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
@@ -269,7 +273,7 @@
},
"resolutions": {
"@expo/image-utils": "0.6.3",
"@react-native/babel-preset": "0.76.1",
"@react-native/babel-preset": "0.76.9",
"@react-native/normalize-colors": "0.76.1",
"@types/react": "^18",
"**/expo-constants": "17.0.3",
-36
View File
@@ -1,36 +0,0 @@
diff --git a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
index 7e0b4cd..177454c 100644
--- a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
+++ b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
@@ -3,6 +3,8 @@ import { LogBox } from 'react-native';
* This is a workaround for using fetch on RN, this is a known issue in react-native and only generates a warning.
*/
export function ignoreRequireCycleLogs() {
- LogBox.ignoreLogs(['Require cycle:']);
+ try {
+ LogBox.ignoreLogs(['Require cycle:']);
+ } catch (e) {}
}
//# sourceMappingURL=ignorerequirecyclelogs.js.map
\ No newline at end of file
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 0f244f2..ae7dfb3 100755
--- a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
+++ b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
@@ -174,6 +174,7 @@ if (!outputDir) {
process.exit(1);
}
+const otherArgs = process.argv.slice(3);
const files = getAssetPathsSync(outputDir);
const groupedAssets = groupAssets(files);
@@ -195,7 +196,7 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
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(' ')} ${otherArgs.join(' ')}`, {
env: {
...process.env,
[SENTRY_PROJECT]: sentryProject,
+13
View File
@@ -0,0 +1,13 @@
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
--- 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)) {
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}`, {
env: {
...process.env,
[SENTRY_PROJECT]: sentryProject,
@@ -1,8 +1,8 @@
diff --git a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
index b928b8f..7175cf6 100644
index 64e6efe..e61485e 100644
--- a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
+++ b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
@@ -99,12 +99,12 @@ class MediaLibraryModule : Module() {
@@ -111,12 +111,12 @@ class MediaLibraryModule : Module() {
}
AsyncFunction("createAssetAsync") { localUri: String, promise: Promise ->
@@ -1,5 +1,5 @@
diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle
index 5dd0c61..bf536dd 100644
index 13bffbb..5ebbede 100644
--- a/node_modules/expo-notifications/android/build.gradle
+++ b/node_modules/expo-notifications/android/build.gradle
@@ -46,6 +46,7 @@ dependencies {
@@ -41,7 +41,7 @@ diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Scro
index 93af874..106f8ec 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
@@ -66,28 +66,51 @@ - (void)preserveContentOffsetWithBlock:(void (^)())block
@@ -66,28 +66,51 @@
* ScrollView, we force it to be centered, but when you zoom or the content otherwise
* becomes larger than the ScrollView, there is no padding around the content but it
* can still fill the whole view.
@@ -103,7 +103,7 @@ index 93af874..106f8ec 100644
- (BOOL)touchesShouldCancelInContentView:(UIView *)view
{
if ([_overridingDelegate respondsToSelector:@selector(touchesShouldCancelInContentView:)]) {
@@ -257,6 +280,10 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
@@ -257,6 +280,10 @@
}
}
@@ -131,7 +131,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 53bfd04..ff1b1ed 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -23,6 +23,7 @@ @implementation RCTRefreshControl {
@@ -23,6 +23,7 @@
UIColor *_titleColor;
CGFloat _progressViewOffset;
BOOL _hasMovedToWindow;
@@ -139,7 +139,7 @@ index 53bfd04..ff1b1ed 100644
}
- (instancetype)init
@@ -58,6 +59,12 @@ - (void)layoutSubviews
@@ -58,6 +59,12 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
_isInitialRender = false;
}
@@ -152,7 +152,7 @@ index 53bfd04..ff1b1ed 100644
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
@@ -221,4 +228,50 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
}
}
@@ -207,7 +207,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 40aaf9c..1c60164 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -22,11 +22,12 @@ - (UIView *)view
@@ -22,11 +22,12 @@ RCT_EXPORT_MODULE()
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
@@ -222,10 +222,10 @@ index 40aaf9c..1c60164 100644
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
index e9ce48c..84a6fca 100644
index 6f41b5c..9b4f77f 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
@@ -159,26 +159,8 @@ - (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view
@@ -159,26 +159,8 @@
return !shouldDisableScrollInteraction;
}
@@ -252,7 +252,7 @@ index e9ce48c..84a6fca 100644
super.contentOffset = CGPointMake(
RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
@@ -427,6 +409,11 @@ - (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
@@ -433,6 +415,11 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
// Does nothing
}
@@ -264,7 +264,7 @@ index e9ce48c..84a6fca 100644
- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
{
[super insertReactSubview:view atIndex:atIndex];
@@ -443,6 +430,8 @@ - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
@@ -449,6 +436,8 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
_contentView = view;
RCTApplyTransformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
[_scrollView addSubview:view];
@@ -273,7 +273,7 @@ index e9ce48c..84a6fca 100644
}
}
@@ -652,9 +641,46 @@ -(void)delegateMethod : (UIScrollView *)scrollView \
@@ -658,9 +647,46 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
}
RCT_SCROLL_EVENT_HANDLER(scrollViewWillBeginDecelerating, onMomentumScrollBegin)
@@ -321,7 +321,7 @@ index e9ce48c..84a6fca 100644
- (void)addScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
{
[_scrollListeners addObject:scrollListener];
@@ -933,6 +959,7 @@ - (void)updateContentSizeIfNeeded
@@ -939,6 +965,7 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
CGSize contentSize = self.contentSize;
if (!CGSizeEqualToSize(_scrollView.contentSize, contentSize)) {
_scrollView.contentSize = contentSize;
@@ -329,7 +329,7 @@ index e9ce48c..84a6fca 100644
}
}
@@ -1055,6 +1082,22 @@ -(type)getter \
@@ -1061,6 +1088,22 @@ RCT_SET_AND_PRESERVE_OFFSET(setShowsHorizontalScrollIndicator, showsHorizontalSc
RCT_SET_AND_PRESERVE_OFFSET(setShowsVerticalScrollIndicator, showsVerticalScrollIndicator, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setZoomScale, zoomScale, CGFloat);
@@ -356,7 +356,7 @@ diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManag
index cd1e7eb..c1d0172 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
@@ -83,6 +83,7 @@ - (UIView *)view
@@ -83,6 +83,7 @@ RCT_EXPORT_VIEW_PROPERTY(showsVerticalScrollIndicator, BOOL)
RCT_EXPORT_VIEW_PROPERTY(scrollEventThrottle, NSTimeInterval)
RCT_EXPORT_VIEW_PROPERTY(zoomScale, CGFloat)
RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets)
@@ -1,34 +0,0 @@
const exec = require('child_process').execSync
const SENTRY_AUTH_TOKEN = process.env.SENTRY_AUTH_TOKEN
module.exports = ({config}) => {
if (!SENTRY_AUTH_TOKEN) {
console.log(
'SENTRY_AUTH_TOKEN environment variable must be set to upload sourcemaps. Skipping.',
)
return
}
const org = config.organization
const project = config.project
const release = config.release
const dist = config.dist
if (!org || !project || !release || !dist) {
console.log(
'"organization", "project", "release", and "dist" must be set in the hook config to upload sourcemaps. Skipping.',
)
return
}
try {
console.log('Uploading sourcemaps to Sentry...')
exec(
`node node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps dist --url https://sentry.io/ -o ${org} -p ${project} -r ${release} -d ${dist}`,
)
console.log('Sourcemaps uploaded to Sentry.')
} catch (e) {
console.error('Error uploading sourcemaps to Sentry:', e)
}
}
+54 -50
View File
@@ -15,6 +15,7 @@ import * as SplashScreen from 'expo-splash-screen'
import * as SystemUI from 'expo-system-ui'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native'
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
import {QueryProvider} from '#/lib/react-query'
@@ -65,6 +66,7 @@ import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
@@ -127,55 +129,57 @@ function InnerApp() {
return (
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<RootSiblingParent>
<VideoVolumeProvider>
<React.Fragment
// 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>
<TrendingConfigProvider>
<GestureHandlerRootView
style={s.h100pct}>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GestureHandlerRootView>
</TrendingConfigProvider>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
</QueryProvider>
</React.Fragment>
</VideoVolumeProvider>
</RootSiblingParent>
</Splash>
<ContextMenuProvider>
<Splash isReady={isReady && hasCheckedReferrer}>
<RootSiblingParent>
<VideoVolumeProvider>
<React.Fragment
// 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>
<TrendingConfigProvider>
<GestureHandlerRootView
style={s.h100pct}>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GestureHandlerRootView>
</TrendingConfigProvider>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
</QueryProvider>
</React.Fragment>
</VideoVolumeProvider>
</RootSiblingParent>
</Splash>
</ContextMenuProvider>
</ThemeProvider>
</Alf>
)
@@ -236,4 +240,4 @@ function App() {
)
}
export default App
export default Sentry.wrap(App)
+53 -49
View File
@@ -7,6 +7,7 @@ import {RootSiblingParent} from 'react-native-root-siblings'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
@@ -55,6 +56,7 @@ import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
@@ -106,54 +108,56 @@ function InnerApp() {
return (
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<RootSiblingParent>
<VideoVolumeProvider>
<ActiveVideoProvider>
<React.Fragment
// 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>
<TrendingConfigProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</TrendingConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
</QueryProvider>
<ToastContainer />
</React.Fragment>
</ActiveVideoProvider>
</VideoVolumeProvider>
</RootSiblingParent>
<ContextMenuProvider>
<RootSiblingParent>
<VideoVolumeProvider>
<ActiveVideoProvider>
<React.Fragment
// 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>
<TrendingConfigProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</TrendingConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
</QueryProvider>
<ToastContainer />
</React.Fragment>
</ActiveVideoProvider>
</VideoVolumeProvider>
</RootSiblingParent>
</ContextMenuProvider>
</ThemeProvider>
</Alf>
)
@@ -207,4 +211,4 @@ function App() {
)
}
export default App
export default Sentry.wrap(App)
+22 -14
View File
@@ -1,9 +1,8 @@
import * as React from 'react'
import {JSX} from 'react/jsx-runtime'
import {i18n, MessageDescriptor} from '@lingui/core'
import {i18n, type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/macro'
import {
BottomTabBarProps,
type BottomTabBarProps,
createBottomTabNavigator,
} from '@react-navigation/bottom-tabs'
import {
@@ -20,16 +19,16 @@ import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {buildStateObject} from '#/lib/routes/helpers'
import {
AllNavigatorParams,
BottomTabNavigatorParams,
FlatNavigatorParams,
HomeTabNavigatorParams,
MessagesTabNavigatorParams,
MyProfileTabNavigatorParams,
NotificationsTabNavigatorParams,
SearchTabNavigatorParams,
type AllNavigatorParams,
type BottomTabNavigatorParams,
type FlatNavigatorParams,
type HomeTabNavigatorParams,
type MessagesTabNavigatorParams,
type MyProfileTabNavigatorParams,
type NotificationsTabNavigatorParams,
type SearchTabNavigatorParams,
} from '#/lib/routes/types'
import {RouteParams, State} from '#/lib/routes/types'
import {type RouteParams, type State} from '#/lib/routes/types'
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
import {bskyTitle} from '#/lib/strings/headings'
import {logger} from '#/logger'
@@ -59,7 +58,6 @@ import {ProfileScreen} from '#/view/screens/Profile'
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
import {ProfileListScreen} from '#/view/screens/ProfileList'
import {SavedFeeds} from '#/view/screens/SavedFeeds'
import {SearchScreen} from '#/view/screens/Search'
import {Storybook} from '#/view/screens/Storybook'
import {SupportScreen} from '#/view/screens/Support'
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
@@ -81,9 +79,11 @@ import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed'
import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers'
import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {SearchScreen} from '#/screens/Search'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
import {SettingsInterests} from '#/screens/Settings/SettingsInterests'
import {
StarterPackScreen,
StarterPackScreenShort,
@@ -376,6 +376,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="SettingsInterests"
getComponent={() => SettingsInterests}
options={{
title: title(msg`Your interests`),
requireAuth: true,
}}
/>
<Stack.Screen
name="AboutSettings"
getComponent={() => AboutSettingsScreen}
@@ -629,7 +637,7 @@ const FlatNavigator = () => {
<Flat.Screen
name="Search"
getComponent={() => SearchScreen}
options={{title: title(msg`Search`)}}
options={{title: title(msg`Explore`)}}
/>
<Flat.Screen
name="Notifications"
+12 -1
View File
@@ -1,4 +1,9 @@
import {Platform, StyleProp, StyleSheet, ViewStyle} from 'react-native'
import {
Platform,
type StyleProp,
StyleSheet,
type ViewStyle,
} from 'react-native'
import * as tokens from '#/alf/tokens'
import {ios, native, platform, web} from '#/alf/util/platform'
@@ -126,6 +131,9 @@ export const atoms = {
rounded_md: {
borderRadius: tokens.borderRadius.md,
},
rounded_lg: {
borderRadius: tokens.borderRadius.lg,
},
rounded_full: {
borderRadius: tokens.borderRadius.full,
},
@@ -358,6 +366,9 @@ export const atoms = {
border_r: {
borderRightWidth: StyleSheet.hairlineWidth,
},
border_transparent: {
borderColor: 'transparent',
},
curve_circular: ios({
borderCurve: 'circular',
}),
+2 -2
View File
@@ -31,8 +31,8 @@ export function useBreakpoints(): Record<Breakpoint, boolean> & {
* Fine-tuned breakpoints for the shell layout
*/
export function useLayoutBreakpoints() {
const rightNavVisible = useMediaQuery({minWidth: 1075})
const centerColumnOffset = useMediaQuery({minWidth: 1075, maxWidth: 1300})
const rightNavVisible = useMediaQuery({minWidth: 1100})
const centerColumnOffset = useMediaQuery({minWidth: 1100, maxWidth: 1300})
const leftNavMinimal = useMediaQuery({maxWidth: 1300})
return {
+1
View File
@@ -44,6 +44,7 @@ export const borderRadius = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
full: 999,
} as const
+4 -4
View File
@@ -6,7 +6,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import * as bsky from '#/types/bsky'
import type * as bsky from '#/types/bsky'
export function AvatarStack({
profiles,
@@ -19,7 +19,7 @@ export function AvatarStack({
numPending?: number
backgroundColor?: string
}) {
const halfSize = size / 2
const translation = size / 3 // overlap by 1/3
const t = useTheme()
const moderationOpts = useModerationOpts()
@@ -43,7 +43,7 @@ export function AvatarStack({
a.flex_row,
a.align_center,
a.relative,
{width: size + (items.length - 1) * halfSize},
{width: size + (items.length - 1) * (size - translation)},
]}>
{items.map((item, i) => (
<View
@@ -54,7 +54,7 @@ export function AvatarStack({
{
width: size,
height: size,
left: i * -halfSize,
left: i * -translation,
borderWidth: 1,
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
borderRadius: 999,
@@ -0,0 +1,85 @@
import {Pressable} from 'react-native'
import Animated, {
Extrapolation,
interpolate,
type SharedValue,
useAnimatedProps,
useAnimatedStyle,
} from 'react-native-reanimated'
import {BlurView} from 'expo-blur'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {useContextMenuContext} from './context'
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
type Props = {
animation: SharedValue<number>
intensity?: number
onPress?: () => void
}
export function Backdrop(props: Props) {
const {mode} = useContextMenuContext()
switch (mode) {
case 'full':
return <BlurredBackdrop {...props} />
case 'auxiliary-only':
return <OpacityBackdrop {...props} />
}
}
function BlurredBackdrop({animation, intensity = 50, onPress}: Props) {
const {_} = useLingui()
const animatedProps = useAnimatedProps(() => ({
intensity: interpolate(
animation.get(),
[0, 1],
[0, intensity],
Extrapolation.CLAMP,
),
}))
return (
<AnimatedBlurView
animatedProps={animatedProps}
style={[a.absolute, a.inset_0]}
tint="systemMaterialDark">
<Pressable
style={a.flex_1}
accessibilityLabel={_(msg`Close menu`)}
accessibilityHint={_(msg`Tap to close context menu`)}
onPress={onPress}
/>
</AnimatedBlurView>
)
}
function OpacityBackdrop({animation, onPress}: Props) {
const t = useTheme()
const {_} = useLingui()
const animatedStyle = useAnimatedStyle(() => ({
opacity: interpolate(
animation.get(),
[0, 1],
[0, 0.05],
Extrapolation.CLAMP,
),
}))
return (
<Animated.View
style={[a.absolute, a.inset_0, t.atoms.bg_contrast_975, animatedStyle]}>
<Pressable
style={a.flex_1}
accessibilityLabel={_(msg`Close menu`)}
accessibilityHint={_(msg`Tap to close context menu`)}
onPress={onPress}
/>
</Animated.View>
)
}
+51
View File
@@ -0,0 +1,51 @@
import {Pressable} from 'react-native'
import Animated, {
Extrapolation,
interpolate,
SharedValue,
useAnimatedStyle,
} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {useContextMenuContext} from './context'
export function Backdrop({
animation,
intensity = 50,
onPress,
}: {
animation: SharedValue<number>
intensity?: number
onPress?: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {mode} = useContextMenuContext()
const reduced = mode === 'auxiliary-only'
const target = reduced ? 0.05 : intensity / 100
const animatedStyle = useAnimatedStyle(() => ({
opacity: interpolate(
animation.get(),
[0, 1],
[0, target],
Extrapolation.CLAMP,
),
}))
return (
<Animated.View
style={[a.absolute, a.inset_0, t.atoms.bg_contrast_975, animatedStyle]}>
<Pressable
style={a.flex_1}
accessibilityLabel={_(msg`Close menu`)}
accessibilityHint={_(msg`Tap to close context menu`)}
onPress={onPress}
/>
</Animated.View>
)
}
+49
View File
@@ -0,0 +1,49 @@
import React from 'react'
import {
type ContextType,
type ItemContextType,
type MenuContextType,
} from '#/components/ContextMenu/types'
export const Context = React.createContext<ContextType | null>(null)
export const MenuContext = React.createContext<MenuContextType | null>(null)
export const ItemContext = React.createContext<ItemContextType | null>(null)
export function useContextMenuContext() {
const context = React.useContext(Context)
if (!context) {
throw new Error(
'useContextMenuContext must be used within a Context.Provider',
)
}
return context
}
export function useContextMenuMenuContext() {
const context = React.useContext(MenuContext)
if (!context) {
throw new Error(
'useContextMenuMenuContext must be used within a Context.Provider',
)
}
return context
}
export function useContextMenuItemContext() {
const context = React.useContext(ItemContext)
if (!context) {
throw new Error(
'useContextMenuItemContext must be used within a Context.Provider',
)
}
return context
}
+904
View File
@@ -0,0 +1,904 @@
import React, {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import {
BackHandler,
Keyboard,
type LayoutChangeEvent,
Pressable,
type StyleProp,
useWindowDimensions,
View,
type ViewStyle,
} from 'react-native'
import {
Gesture,
GestureDetector,
type GestureStateChangeEvent,
type GestureUpdateEvent,
type PanGestureHandlerEventPayload,
} from 'react-native-gesture-handler'
import Animated, {
clamp,
interpolate,
runOnJS,
type SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
type WithSpringConfig,
} from 'react-native-reanimated'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {captureRef} from 'react-native-view-shot'
import {Image, type ImageErrorEventData} from 'expo-image'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useIsFocused} from '@react-navigation/native'
import flattenReactChildren from 'react-keyed-flatten-children'
import {HITSLOP_10} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {atoms as a, platform, tokens, useTheme} from '#/alf'
import {
Context,
ItemContext,
MenuContext,
useContextMenuContext,
useContextMenuItemContext,
useContextMenuMenuContext,
} from '#/components/ContextMenu/context'
import {
type AuxiliaryViewProps,
type ContextType,
type ItemIconProps,
type ItemProps,
type ItemTextProps,
type Measurement,
type TriggerProps,
} from '#/components/ContextMenu/types'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {createPortalGroup} from '#/components/Portal'
import {Text} from '#/components/Typography'
import {Backdrop} from './Backdrop'
export {
type DialogControlProps as ContextMenuControlProps,
useDialogControl as useContextMenuControl,
} from '#/components/Dialog'
const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
const SPRING_IN: WithSpringConfig = {
mass: isIOS ? 1.25 : 0.75,
damping: 50,
stiffness: 1100,
restDisplacementThreshold: 0.01,
}
const SPRING_OUT: WithSpringConfig = {
mass: isIOS ? 1.25 : 0.75,
damping: 150,
stiffness: 1000,
restDisplacementThreshold: 0.01,
}
/**
* Needs placing near the top of the provider stack, but BELOW the theme provider.
*/
export function Provider({children}: {children: React.ReactNode}) {
return (
<PortalProvider>
{children}
<Outlet />
</PortalProvider>
)
}
export function Root({children}: {children: React.ReactNode}) {
const playHaptic = useHaptics()
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
const [measurement, setMeasurement] = useState<Measurement | null>(null)
const animationSV = useSharedValue(0)
const translationSV = useSharedValue(0)
const isFocused = useIsFocused()
const hoverables = useRef<
Map<string, {id: string; rect: Measurement; onTouchUp: () => void}>
>(new Map())
const hoverablesSV = useSharedValue<
Record<string, {id: string; rect: Measurement}>
>({})
const syncHoverablesThrottleRef = useRef<ReturnType<typeof setTimeout>>()
const [hoveredMenuItem, setHoveredMenuItem] = useState<string | null>(null)
const onHoverableTouchUp = useCallback((id: string) => {
const hoverable = hoverables.current.get(id)
if (!hoverable) {
logger.warn(`No such hoverable with id ${id}`)
return
}
hoverable.onTouchUp()
}, [])
const onCompletedClose = useCallback(() => {
hoverables.current.clear()
setMeasurement(null)
}, [])
const context = useMemo(
() =>
({
isOpen: !!measurement && isFocused,
measurement,
animationSV,
translationSV,
mode,
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => {
setMeasurement(evt)
setMode(mode)
animationSV.set(withSpring(1, SPRING_IN))
},
close: () => {
animationSV.set(
withSpring(0, SPRING_OUT, finished => {
if (finished) {
hoverablesSV.set({})
translationSV.set(0)
runOnJS(onCompletedClose)()
}
}),
)
},
registerHoverable: (
id: string,
rect: Measurement,
onTouchUp: () => void,
) => {
hoverables.current.set(id, {id, rect, onTouchUp})
// we need this data on the UI thread, but we want to limit cross-thread communication
// and this function will be called in quick succession, so we need to throttle it
if (syncHoverablesThrottleRef.current)
clearTimeout(syncHoverablesThrottleRef.current)
syncHoverablesThrottleRef.current = setTimeout(() => {
syncHoverablesThrottleRef.current = undefined
hoverablesSV.set(
Object.fromEntries(
// eslint-ignore
[...hoverables.current.entries()].map(([id, {rect}]) => [
id,
{id, rect},
]),
),
)
}, 1)
},
hoverablesSV,
onTouchUpMenuItem: onHoverableTouchUp,
hoveredMenuItem,
setHoveredMenuItem: item => {
if (item) playHaptic('Light')
setHoveredMenuItem(item)
},
} satisfies ContextType),
[
measurement,
setMeasurement,
onCompletedClose,
isFocused,
animationSV,
translationSV,
hoverablesSV,
onHoverableTouchUp,
hoveredMenuItem,
setHoveredMenuItem,
playHaptic,
mode,
],
)
useEffect(() => {
if (isAndroid && context.isOpen) {
const listener = BackHandler.addEventListener('hardwareBackPress', () => {
context.close()
return true
})
return () => listener.remove()
}
}, [context])
return <Context.Provider value={context}>{children}</Context.Provider>
}
export function Trigger({children, label, contentLabel, style}: TriggerProps) {
const context = useContextMenuContext()
const playHaptic = useHaptics()
const {top: topInset} = useSafeAreaInsets()
const ref = useRef<View>(null)
const isFocused = useIsFocused()
const [image, setImage] = useState<string | null>(null)
const [pendingMeasurement, setPendingMeasurement] = useState<{
measurement: Measurement
mode: 'full' | 'auxiliary-only'
} | null>(null)
const open = useNonReactiveCallback(
async (mode: 'full' | 'auxiliary-only') => {
playHaptic()
Keyboard.dismiss()
const [measurement, capture] = await Promise.all([
new Promise<Measurement>(resolve => {
ref.current?.measureInWindow((x, y, width, height) =>
resolve({
x,
y:
y +
platform({
default: 0,
android: topInset, // not included in measurement
}),
width,
height,
}),
)
}),
captureRef(ref, {result: 'data-uri'}).catch(err => {
logger.error(err instanceof Error ? err : String(err), {
message: 'Failed to capture image of context menu trigger',
})
// will cause the image to fail to load, but it will get handled gracefully
return '<failed capture>'
}),
])
setImage(capture)
setPendingMeasurement({measurement, mode})
},
)
const doubleTapGesture = useMemo(() => {
return Gesture.Tap()
.numberOfTaps(2)
.hitSlop(HITSLOP_10)
.onEnd(() => open('auxiliary-only'))
.runOnJS(true)
}, [open])
const {
hoverablesSV,
setHoveredMenuItem,
onTouchUpMenuItem,
translationSV,
animationSV,
} = context
const hoveredItemSV = useSharedValue<string | null>(null)
useAnimatedReaction(
() => hoveredItemSV.get(),
(hovered, prev) => {
if (hovered !== prev) {
runOnJS(setHoveredMenuItem)(hovered)
}
},
)
const pressAndHoldGesture = useMemo(() => {
return Gesture.Pan()
.activateAfterLongPress(500)
.cancelsTouchesInView(false)
.averageTouches(true)
.onStart(() => {
'worklet'
runOnJS(open)('full')
})
.onUpdate(evt => {
'worklet'
const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
hoveredItemSV.set(item)
})
.onEnd(() => {
'worklet'
// don't recalculate hovered item - if they haven't moved their finger from
// the initial press, it's jarring to then select the item underneath
// as the menu may have slid into place beneath their finger
const item = hoveredItemSV.get()
if (item) {
runOnJS(onTouchUpMenuItem)(item)
}
})
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
const composedGestures = Gesture.Exclusive(
doubleTapGesture,
pressAndHoldGesture,
)
const measurement = context.measurement || pendingMeasurement?.measurement
return (
<>
<GestureDetector gesture={composedGestures}>
<View ref={ref} style={[{opacity: context.isOpen ? 0 : 1}, style]}>
{children({
isNative: true,
control: {isOpen: context.isOpen, open},
state: {
pressed: false,
hovered: false,
focused: false,
},
props: {
ref: null,
onPress: null,
onFocus: null,
onBlur: null,
onPressIn: null,
onPressOut: null,
accessibilityHint: null,
accessibilityLabel: label,
accessibilityRole: null,
},
})}
</View>
</GestureDetector>
{isFocused && image && measurement && (
<Portal>
<TriggerClone
label={contentLabel}
translation={translationSV}
animation={animationSV}
image={image}
measurement={measurement}
onDisplay={() => {
if (pendingMeasurement) {
context.open(
pendingMeasurement.measurement,
pendingMeasurement.mode,
)
setPendingMeasurement(null)
}
}}
/>
</Portal>
)}
</>
)
}
/**
* an image of the underlying trigger with a grow animation
*/
function TriggerClone({
translation,
animation,
image,
measurement,
onDisplay,
label,
}: {
translation: SharedValue<number>
animation: SharedValue<number>
image: string
measurement: Measurement
onDisplay: () => void
label: string
}) {
const {_} = useLingui()
const animatedStyles = useAnimatedStyle(() => ({
transform: [{translateY: translation.get() * animation.get()}],
}))
const handleError = useCallback(
(evt: ImageErrorEventData) => {
logger.error('Context menu image load error', {message: evt.error})
onDisplay()
},
[onDisplay],
)
return (
<Animated.View
style={[
a.absolute,
{
top: measurement.y,
left: measurement.x,
width: measurement.width,
height: measurement.height,
},
a.z_10,
a.pointer_events_none,
animatedStyles,
]}>
<Image
onDisplay={onDisplay}
onError={handleError}
source={image}
style={{
width: measurement.width,
height: measurement.height,
}}
accessibilityLabel={label}
accessibilityHint={_(msg`The subject of the context menu`)}
accessibilityIgnoresInvertColors={false}
/>
</Animated.View>
)
}
export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
const context = useContextMenuContext()
const {width: screenWidth} = useWindowDimensions()
const {top: topInset} = useSafeAreaInsets()
const ensureOnScreenTranslationSV = useSharedValue(0)
const {isOpen, mode, measurement, translationSV, animationSV} = context
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: clamp(animationSV.get(), 0, 1),
transform: [
{
translateY:
(ensureOnScreenTranslationSV.get() || translationSV.get()) *
animationSV.get(),
},
{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
],
}
})
const menuContext = useMemo(() => ({align}), [align])
const onLayout = useCallback(() => {
if (!measurement) return
let translation = 0
// vibes based, just assuming it'll fit within this space. revisit if we use
// AuxiliaryView for something tall
const TOP_INSET = topInset + 80
const distanceMessageFromTop = measurement.y - TOP_INSET
if (distanceMessageFromTop < 0) {
translation = -distanceMessageFromTop
}
// normally, the context menu is responsible for measuring itself and moving everything into the right place
// however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
if (mode === 'auxiliary-only') {
translationSV.set(translation)
ensureOnScreenTranslationSV.set(0)
}
// however, we also need to make sure that for super tall triggers, we don't go off the screen
// so we have an additional cap on the standard transform every other element has
// note: this breaks the press-and-hold gesture for the reaction items. unfortunately I think
// we'll just have to live with it for now, fixing it would be possible but be a large complexity
// increase for an edge case
else {
ensureOnScreenTranslationSV.set(translation)
}
}, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
if (!isOpen || !measurement) return null
return (
<Portal>
<Context.Provider value={context}>
<MenuContext.Provider value={menuContext}>
<Animated.View
onLayout={onLayout}
style={[
a.absolute,
{
top: measurement.y,
transformOrigin:
align === 'left' ? 'bottom left' : 'bottom right',
},
align === 'left'
? {left: measurement.x}
: {right: screenWidth - measurement.x - measurement.width},
animatedStyle,
a.z_20,
]}>
{children}
</Animated.View>
</MenuContext.Provider>
</Context.Provider>
</Portal>
)
}
const MENU_WIDTH = 240
export function Outer({
children,
style,
align = 'left',
}: {
children: React.ReactNode
style?: StyleProp<ViewStyle>
align?: 'left' | 'right'
}) {
const t = useTheme()
const context = useContextMenuContext()
const insets = useSafeAreaInsets()
const frame = useSafeAreaFrame()
const {width: screenWidth} = useWindowDimensions()
const {animationSV, translationSV} = context
const animatedContainerStyle = useAnimatedStyle(() => ({
transform: [{translateY: translationSV.get() * animationSV.get()}],
}))
const animatedStyle = useAnimatedStyle(() => ({
opacity: clamp(animationSV.get(), 0, 1),
transform: [{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}],
}))
const onLayout = useCallback(
(evt: LayoutChangeEvent) => {
if (!context.measurement) return // should not happen
let translation = 0
// pure vibes based
const TOP_INSET = insets.top + 80
const BOTTOM_INSET_IOS = insets.bottom + 20
const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn
const {height} = evt.nativeEvent.layout
const topPosition =
context.measurement.y + context.measurement.height + tokens.space.xs
const bottomPosition = topPosition + height
const safeAreaBottomLimit =
frame.height -
platform({
ios: BOTTOM_INSET_IOS,
android: BOTTOM_INSET_ANDROID,
default: 0,
})
const diff = bottomPosition - safeAreaBottomLimit
if (diff > 0) {
translation = -diff
} else {
const distanceMessageFromTop = context.measurement.y - TOP_INSET
if (distanceMessageFromTop < 0) {
translation = -Math.max(distanceMessageFromTop, diff)
}
}
if (translation !== 0) {
translationSV.set(translation)
}
},
[context.measurement, frame.height, insets, translationSV],
)
const menuContext = useMemo(() => ({align}), [align])
if (!context.isOpen || !context.measurement) return null
return (
<Portal>
<Context.Provider value={context}>
<MenuContext.Provider value={menuContext}>
<Backdrop animation={animationSV} onPress={context.close} />
{context.mode === 'full' && (
/* containing element - stays the same size, so we measure it
to determine if a translation is necessary. also has the positioning */
<Animated.View
onLayout={onLayout}
style={[
a.absolute,
a.z_10,
a.mt_xs,
{
width: MENU_WIDTH,
top: context.measurement.y + context.measurement.height,
},
align === 'left'
? {left: context.measurement.x}
: {
right:
screenWidth -
context.measurement.x -
context.measurement.width,
},
animatedContainerStyle,
]}>
{/* scaling element - has the scale/fade animation on it */}
<Animated.View
style={[
a.rounded_md,
a.shadow_md,
t.atoms.bg_contrast_25,
a.w_full,
// @ts-ignore react-native-web expects string, and this file is platform-split -sfn
// note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
// in the typecheck CI - presumably because of RNW overriding the types
{
transformOrigin:
// "top right" doesn't seem to work on android, so set explicitly in pixels
align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
},
animatedStyle,
style,
]}>
{/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
so put the shadow on the scaling element and the overflow on the innermost element */}
<View
style={[
a.flex_1,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
]}>
{flattenReactChildren(children).map((child, i) => {
return React.isValidElement(child) &&
(child.type === Item || child.type === Divider) ? (
<React.Fragment key={i}>
{i > 0 ? (
<View
style={[a.border_b, t.atoms.border_contrast_low]}
/>
) : null}
{React.cloneElement(child, {
// @ts-expect-error not typed
style: {
borderRadius: 0,
borderWidth: 0,
},
})}
</React.Fragment>
) : null
})}
</View>
</Animated.View>
</Animated.View>
)}
</MenuContext.Provider>
</Context.Provider>
</Portal>
)
}
export function Item({
children,
label,
unstyled,
style,
onPress,
position,
...rest
}: ItemProps) {
const t = useTheme()
const context = useContextMenuContext()
const playHaptic = useHaptics()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const id = useId()
const {align} = useContextMenuMenuContext()
const {close, measurement, registerHoverable} = context
const handleLayout = useCallback(
(evt: LayoutChangeEvent) => {
if (!measurement) return // should be impossible
const layout = evt.nativeEvent.layout
const yOffset = position
? position.y
: measurement.y + measurement.height + tokens.space.xs
const xOffset = position
? position.x
: align === 'left'
? measurement.x
: measurement.x + measurement.width - layout.width
registerHoverable(
id,
{
width: layout.width,
height: layout.height,
y: yOffset + layout.y,
x: xOffset + layout.x,
},
() => {
close()
onPress()
},
)
},
[id, measurement, registerHoverable, close, onPress, align, position],
)
const itemContext = useMemo(
() => ({disabled: Boolean(rest.disabled)}),
[rest.disabled],
)
return (
<Pressable
{...rest}
onLayout={handleLayout}
accessibilityHint=""
accessibilityLabel={label}
onFocus={onFocus}
onBlur={onBlur}
onPress={e => {
close()
onPress?.(e)
}}
onPressIn={e => {
onPressIn()
rest.onPressIn?.(e)
playHaptic('Light')
}}
onPressOut={e => {
onPressOut()
rest.onPressOut?.(e)
}}
style={[
!unstyled && [
a.flex_row,
a.align_center,
a.gap_sm,
a.px_md,
a.rounded_md,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
{minHeight: 44, paddingVertical: 10},
(focused || pressed || context.hoveredMenuItem === id) &&
!rest.disabled &&
t.atoms.bg_contrast_50,
],
style,
]}>
<ItemContext.Provider value={itemContext}>
{typeof children === 'function'
? children(
(focused || pressed || context.hoveredMenuItem === id) &&
!rest.disabled,
)
: children}
</ItemContext.Provider>
</Pressable>
)
}
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
const {disabled} = useContextMenuItemContext()
return (
<Text
numberOfLines={2}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
t.atoms.text_contrast_high,
{paddingTop: 3},
style,
disabled && t.atoms.text_contrast_low,
]}>
{children}
</Text>
)
}
export function ItemIcon({icon: Comp}: ItemIconProps) {
const t = useTheme()
const {disabled} = useContextMenuItemContext()
return (
<Comp
size="lg"
fill={
disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
)
}
export function ItemRadio({selected}: {selected: boolean}) {
const t = useTheme()
return (
<View
style={[
a.justify_center,
a.align_center,
a.rounded_full,
t.atoms.border_contrast_high,
{
borderWidth: 1,
height: 20,
width: 20,
},
]}>
{selected ? (
<View
style={[
a.absolute,
a.rounded_full,
{height: 14, width: 14},
selected ? {backgroundColor: t.palette.primary_500} : {},
]}
/>
) : null}
</View>
)
}
export function LabelText({children}: {children: React.ReactNode}) {
const t = useTheme()
return (
<Text
style={[a.font_bold, t.atoms.text_contrast_medium, {marginBottom: -8}]}>
{children}
</Text>
)
}
export function Divider() {
const t = useTheme()
return (
<View
style={[t.atoms.border_contrast_low, a.flex_1, {borderTopWidth: 3}]}
/>
)
}
function getHoveredHoverable(
evt:
| GestureStateChangeEvent<PanGestureHandlerEventPayload>
| GestureUpdateEvent<PanGestureHandlerEventPayload>,
hoverables: SharedValue<Record<string, {id: string; rect: Measurement}>>,
translation: SharedValue<number>,
) {
'worklet'
const x = evt.absoluteX
const y = evt.absoluteY
const yOffset = translation.get()
const rects = Object.values(hoverables.get())
for (const {id, rect} of rects) {
const isWithinLeftBound = x >= rect.x
const isWithinRightBound = x <= rect.x + rect.width
const isWithinTopBound = y >= rect.y + yOffset
const isWithinBottomBound = y <= rect.y + rect.height + yOffset
if (
isWithinLeftBound &&
isWithinRightBound &&
isWithinTopBound &&
isWithinBottomBound
) {
return id
}
}
return null
}
+12
View File
@@ -0,0 +1,12 @@
import {type AuxiliaryViewProps} from './types'
export * from '#/components/Menu'
export function Provider({children}: {children: React.ReactNode}) {
return children
}
// native only
export function AuxiliaryView({}: AuxiliaryViewProps) {
return null
}
+136
View File
@@ -0,0 +1,136 @@
import {
type AccessibilityRole,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from 'react-native'
import {type SharedValue} from 'react-native-reanimated'
import type React from 'react'
import type * as Dialog from '#/components/Dialog'
import {
type ItemProps as MenuItemProps,
type RadixPassThroughTriggerProps,
} from '#/components/Menu/types'
export type {
GroupProps,
ItemIconProps,
ItemTextProps,
} from '#/components/Menu/types'
export type AuxiliaryViewProps = {
children?: React.ReactNode
align?: 'left' | 'right'
}
export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & {
// remove default styles (i.e. for emoji reactions)
unstyled?: boolean
onPress: (evt?: GestureResponderEvent) => void
children?: React.ReactNode | ((hovered: boolean) => React.ReactNode)
// absolute position of the parent element. if undefined, assumed to
// be in the context menu. use this if using AuxiliaryView
position?: Measurement
}
export type Measurement = {
x: number
y: number
width: number
height: number
}
export type ContextType = {
isOpen: boolean
measurement: Measurement | null
/* Spring animation between 0 and 1 */
animationSV: SharedValue<number>
/* Translation in Y axis to ensure everything's onscreen */
translationSV: SharedValue<number>
mode: 'full' | 'auxiliary-only'
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void
close: () => void
registerHoverable: (
id: string,
rect: Measurement,
onTouchUp: () => void,
) => void
hoverablesSV: SharedValue<Record<string, {id: string; rect: Measurement}>>
hoveredMenuItem: string | null
setHoveredMenuItem: React.Dispatch<React.SetStateAction<string | null>>
onTouchUpMenuItem: (id: string) => void
}
export type MenuContextType = {
align: 'left' | 'right'
}
export type ItemContextType = {
disabled: boolean
}
export type TriggerProps = {
children(props: TriggerChildProps): React.ReactNode
label: string
/**
* When activated, this is the accessibility label for the entire thing that has been triggered.
* For example, if the trigger is a message bubble, use the message content.
*
* @platform ios, android
*/
contentLabel: string
hint?: string
role?: AccessibilityRole
style?: StyleProp<ViewStyle>
}
export type TriggerChildProps =
| {
isNative: true
control: {
isOpen: boolean
open: (mode: 'full' | 'auxiliary-only') => void
}
state: {
hovered: false
focused: false
pressed: false
}
/**
* We don't necessarily know what these will be spread on to, so we
* should add props one-by-one.
*
* On web, these properties are applied to a parent `Pressable`, so this
* object is empty.
*/
props: {
ref: null
onPress: null
onFocus: null
onBlur: null
onPressIn: null
onPressOut: null
accessibilityHint: null
accessibilityLabel: string
accessibilityRole: null
}
}
| {
isNative: false
control: Dialog.DialogOuterProps['control']
state: {
hovered: false
focused: false
pressed: false
}
props: RadixPassThroughTriggerProps & {
onPress: () => void
onFocus: () => void
onBlur: () => void
onMouseEnter: () => void
onMouseLeave: () => void
accessibilityHint?: string
accessibilityLabel: string
accessibilityRole: AccessibilityRole
}
}
+14 -14
View File
@@ -1,20 +1,20 @@
import React, {useImperativeHandle} from 'react'
import {
NativeScrollEvent,
NativeSyntheticEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
ScrollView,
StyleProp,
type ScrollView,
type StyleProp,
TextInput,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {
KeyboardAwareScrollView,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {runOnJS} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -25,22 +25,22 @@ import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
import {atoms as a, tokens, useTheme} from '#/alf'
import {useThemeName} from '#/alf/util/useColorModeTheme'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {
DialogControlProps,
DialogInnerProps,
DialogOuterProps,
type DialogControlProps,
type DialogInnerProps,
type DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
import {
BottomSheetSnapPointChangeEvent,
BottomSheetStateChangeEvent,
type BottomSheetSnapPointChangeEvent,
type BottomSheetStateChangeEvent,
} from '../../../modules/bottom-sheet/src/BottomSheet.types'
import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
import {type BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/shared'
@@ -299,7 +299,7 @@ export const InnerFlatList = React.forwardRef<
keyboardShouldPersistTaps="handled"
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
<View style={{height: insets.bottom + a.pt_5xl.paddingTop + 50}} />
}
ref={ref}
{...props}
+48 -20
View File
@@ -1,8 +1,8 @@
import React from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {type GestureResponderEvent, View} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyGraphDefs,
type AppBskyFeedDefs,
type AppBskyGraphDefs,
AtUri,
RichText as RichTextApi,
} from '@atproto/api'
@@ -23,15 +23,20 @@ import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {
Button,
ButtonIcon,
type ButtonProps,
ButtonText,
} from '#/components/Button'
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {RichText, RichTextProps} from '#/components/RichText'
import {RichText, type RichTextProps} from '#/components/RichText'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import type * as bsky from '#/types/bsky'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
type Props = {
view: AppBskyFeedDefs.GeneratorView
@@ -81,11 +86,11 @@ export function Link({
}
export function Outer({children}: {children: React.ReactNode}) {
return <View style={[a.w_full, a.gap_md]}>{children}</View>
return <View style={[a.w_full, a.gap_sm]}>{children}</View>
}
export function Header({children}: {children: React.ReactNode}) {
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
}
export type AvatarProps = {src: string | undefined; size?: number}
@@ -220,22 +225,27 @@ export function Likes({count}: {count: number}) {
export function SaveButton({
view,
pin,
...props
}: {
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
pin?: boolean
}) {
text?: boolean
} & Partial<ButtonProps>) {
const {hasSession} = useSession()
if (!hasSession) return null
return <SaveButtonInner view={view} pin={pin} />
return <SaveButtonInner view={view} pin={pin} {...props} />
}
function SaveButtonInner({
view,
pin,
text = true,
...buttonProps
}: {
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
pin?: boolean
}) {
text?: boolean
} & Partial<ButtonProps>) {
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
@@ -294,14 +304,32 @@ function SaveButtonInner({
disabled={isPending}
label={_(msg`Add this feed to your feeds`)}
size="small"
variant="ghost"
color="secondary"
shape="square"
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
variant="solid"
color={savedFeedConfig ? 'secondary' : 'primary'}
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}
{...buttonProps}>
{savedFeedConfig ? (
<ButtonIcon size="md" icon={isPending ? Loader : Trash} />
<>
{isPending ? (
<ButtonIcon size="md" icon={Loader} />
) : (
!text && <ButtonIcon size="md" icon={TrashIcon} />
)}
{text && (
<ButtonText>
<Trans>Unpin Feed</Trans>
</ButtonText>
)}
</>
) : (
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
<>
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
{text && (
<ButtonText>
<Trans>Pin Feed</Trans>
</ButtonText>
)}
</>
)}
</Button>
+28 -26
View File
@@ -360,35 +360,37 @@ export function ProfileGrid({
</View>
) : (
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
{content}
<View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more accounts on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>
Browse more suggestions on the Explore page
</Trans>
</Text>
<Button
label={_(msg`Browse more accounts on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>
Browse more suggestions on the Explore page
</Trans>
</Text>
<Arrow size="xl" />
<Arrow size="xl" />
</View>
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
</CardOuter>
</Button>
</View>
</ScrollView>
</View>
</BlockDrawerGesture>
)}
</View>
+2 -1
View File
@@ -23,6 +23,7 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {
BUTTON_VISUAL_ALIGNMENT_OFFSET,
CENTER_COLUMN_OFFSET,
HEADER_SLOT_SIZE,
SCROLLBAR_OFFSET,
} from '#/components/Layout/const'
@@ -65,7 +66,7 @@ export function Outer({
gtMobile && [a.mx_auto, {maxWidth: 600}],
!isWithinOffsetView && {
transform: [
{translateX: centerColumnOffset ? -150 : 0},
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
],
},
+5
View File
@@ -14,3 +14,8 @@ export const BUTTON_VISUAL_ALIGNMENT_OFFSET = 3
* Corresponds to the width of a small square or round button
*/
export const HEADER_SLOT_SIZE = 34
/**
* How far to shift the center column when in the tablet breakpoint
*/
export const CENTER_COLUMN_OFFSET = -105
+3 -3
View File
@@ -21,7 +21,7 @@ import {
web,
} from '#/alf'
import {useDialogContext} from '#/components/Dialog'
import {SCROLLBAR_OFFSET} from '#/components/Layout/const'
import {CENTER_COLUMN_OFFSET, SCROLLBAR_OFFSET} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context'
export * from '#/components/Layout/const'
@@ -173,7 +173,7 @@ export const Center = React.memo(function LayoutContent({
centerColumnOffset &&
!ignoreTabletLayoutOffset &&
!isWithinDialog
? -150
? CENTER_COLUMN_OFFSET
: 0,
},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
@@ -209,7 +209,7 @@ const WebCenterBorders = React.memo(function LayoutContent() {
left: '50%',
transform: [
{translateX: '-50%'},
{translateX: centerColumnOffset ? -150 : 0},
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
...a.scrollbar_offset.transform,
],
}),
+8
View File
@@ -54,6 +54,14 @@ export function Embed({
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
</Outer>
)
} else if (
e.type === 'post_with_media' &&
// ignore further "nested" RecordWithMedia
e.media.type !== 'post_with_media' &&
// ignore any unknowns
e.media.view !== null
) {
return <Embed embed={e.media.view} style={style} />
}
return null
+2 -7
View File
@@ -2,14 +2,9 @@ import React from 'react'
import type {ContextType, ItemContextType} from '#/components/Menu/types'
export const Context = React.createContext<ContextType>({
// @ts-ignore
control: null,
})
export const Context = React.createContext<ContextType | null>(null)
export const ItemContext = React.createContext<ItemContextType>({
disabled: false,
})
export const ItemContext = React.createContext<ItemContextType | null>(null)
export function useMenuContext() {
const context = React.useContext(Context)
+3 -1
View File
@@ -30,11 +30,13 @@ export {
useDialogControl as useMenuControl,
} from '#/components/Dialog'
export {useMenuContext}
export function Root({
children,
control,
}: React.PropsWithChildren<{
control?: Dialog.DialogOuterProps['control']
control?: Dialog.DialogControlProps
}>) {
const defaultControl = Dialog.useDialogControl()
const context = React.useMemo<ContextType>(
+3 -1
View File
@@ -26,6 +26,8 @@ import {
import {Portal} from '#/components/Portal'
import {Text} from '#/components/Typography'
export {useMenuContext}
export function useMenuControl(): Dialog.DialogControlProps {
const id = React.useId()
const [isOpen, setIsOpen] = React.useState(false)
@@ -50,7 +52,7 @@ export function Root({
children,
control,
}: React.PropsWithChildren<{
control?: Dialog.DialogOuterProps['control']
control?: Dialog.DialogControlProps
}>) {
const {_} = useLingui()
const defaultControl = useMenuControl()
+2 -2
View File
@@ -11,7 +11,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isNative} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
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'
@@ -81,7 +81,7 @@ export function NewskieDialog({
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`New user info dialog`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
style={web({width: 'auto', maxWidth: 400, minWidth: 200})}>
<View style={[a.gap_md]}>
<View style={[a.align_center]}>
<View
+27 -16
View File
@@ -1,14 +1,14 @@
import React from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {type GestureResponderEvent, View} from 'react-native'
import {
moderateProfile,
ModerationOpts,
type ModerationOpts,
RichText as RichTextApi,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LogEvents} from '#/lib/statsig/statsig'
import {type LogEvents} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -18,13 +18,18 @@ import {ProfileCardPills} from '#/view/com/profile/ProfileCard'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
import {
Button,
ButtonIcon,
type ButtonProps,
ButtonText,
} from '#/components/Button'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import type * as bsky from '#/types/bsky'
export function Default({
profile,
@@ -133,7 +138,7 @@ export function Avatar({
return (
<UserAvatar
size={42}
size={40}
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
moderation={moderation.ui('avatar')}
@@ -147,10 +152,10 @@ export function AvatarPlaceholder() {
<View
style={[
a.rounded_full,
t.atoms.bg_contrast_50,
t.atoms.bg_contrast_25,
{
width: 42,
height: 42,
width: 40,
height: 40,
},
]}
/>
@@ -198,7 +203,7 @@ export function NameAndHandlePlaceholder() {
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
t.atoms.bg_contrast_25,
{
width: '60%',
height: 14,
@@ -209,7 +214,7 @@ export function NameAndHandlePlaceholder() {
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
t.atoms.bg_contrast_25,
{
width: '40%',
height: 10,
@@ -261,7 +266,7 @@ export function DescriptionPlaceholder({
}) {
const t = useTheme()
return (
<View style={[{gap: 8}]}>
<View style={[a.pt_2xs, {gap: 6}]}>
{Array(numberOfLines)
.fill(0)
.map((_, i) => (
@@ -270,7 +275,7 @@ export function DescriptionPlaceholder({
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_50,
t.atoms.bg_contrast_25,
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
]}
/>
@@ -286,6 +291,7 @@ export type FollowButtonProps = {
LogEvents['profile:unfollow']['logContext']
colorInverted?: boolean
onFollow?: () => void
withIcon?: boolean
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
@@ -301,6 +307,7 @@ export function FollowButtonInner({
onPress: onPressProp,
onFollow,
colorInverted,
withIcon = true,
...rest
}: FollowButtonProps) {
const {_} = useLingui()
@@ -386,7 +393,9 @@ export function FollowButtonInner({
color="secondary"
{...rest}
onPress={onPressUnfollow}>
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
{withIcon && (
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
)}
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
</Button>
) : (
@@ -397,7 +406,9 @@ export function FollowButtonInner({
color={colorInverted ? 'secondary_inverted' : 'primary'}
{...rest}
onPress={onPressFollow}>
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
{withIcon && (
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
)}
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
</Button>
)}
+119 -181
View File
@@ -1,26 +1,26 @@
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {ScrollView, TextInput, useWindowDimensions, View} from 'react-native'
import Animated, {
LayoutAnimationConfig,
LinearTransition,
ZoomInEasyDown,
} from 'react-native-reanimated'
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
import {
ScrollView,
type StyleProp,
TextInput,
useWindowDimensions,
View,
type ViewStyle,
} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {useSession} from '#/state/session'
import {Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {ListMethods} from '#/view/com/util/List'
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {type ListMethods} from '#/view/com/util/List'
import {
popularInterests,
useInterestsDisplayNames,
@@ -31,7 +31,7 @@ import {
tokens,
useBreakpoints,
useTheme,
ViewStyleProp,
type ViewStyleProp,
web,
} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -42,15 +42,14 @@ import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/componen
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {ListFooter} from '../Lists'
import type * as bsky from '#/types/bsky'
import {ProgressGuideTask} from './Task'
type Item =
| {
type: 'profile'
key: string
profile: AppBskyActorDefs.ProfileView
isSuggestion: boolean
profile: bsky.profile.AnyProfileView
}
| {
type: 'empty'
@@ -121,94 +120,39 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
const inputRef = useRef<TextInput>(null)
const [headerHeight, setHeaderHeight] = useState(0)
const {currentAccount} = useSession()
const [suggestedAccounts, setSuggestedAccounts] = useState<
Map<string, AppBskyActorDefs.ProfileView[]>
>(() => new Map())
useEffect(() => {
lastSearchText = searchText
lastSelectedInterest = selectedInterest
}, [searchText, selectedInterest])
const query = searchText || selectedInterest
const {
data: suggestions,
isFetching: isFetchingSuggestions,
error: suggestionsError,
} = useGetSuggestedUsersQuery({
category: selectedInterest,
limit: 50,
})
const {
data: searchResults,
isFetching,
error,
isError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
isFetching: isFetchingSearchResults,
error: searchResultsError,
isError: isSearchResultsError,
} = useActorSearchPaginated({
query,
enabled: !!searchText,
query: searchText,
})
const hasSearchText = !!searchText
const resultsKey = searchText || selectedInterest
const items = useMemo(() => {
const results = searchResults?.pages.flatMap(r => r.actors)
const results = hasSearchText
? searchResults?.pages.flatMap(p => p.actors)
: suggestions?.actors
let _items: Item[] = []
const seen = new Set<string>()
if (isError) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else if (results) {
// First pass: search results
for (const profile of results) {
if (profile.did === currentAccount?.did) continue
if (profile.viewer?.following) continue
// my sincere apologies to Jake Gold - your bio is too keyword-filled and
// your page-rank too high, so you're at the top of half the categories -sfn
if (
!hasSearchText &&
profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' &&
// constrain to 'tech'
selectedInterest !== 'tech'
) {
continue
}
seen.add(profile.did)
_items.push({
type: 'profile',
// Don't share identity across tabs or typing attempts
key: query + ':' + profile.did,
profile,
isSuggestion: false,
})
}
// Second pass: suggestions
_items = _items.flatMap(item => {
if (item.type !== 'profile') {
return item
}
const suggestions = suggestedAccounts.get(item.profile.did)
if (!suggestions) {
return item
}
const itemWithSuggestions = [item]
for (const suggested of suggestions) {
if (seen.has(suggested.did)) {
// Skip search results from previous step or already seen suggestions
continue
}
seen.add(suggested.did)
itemWithSuggestions.push({
type: 'profile',
key: suggested.did,
profile: suggested,
isSuggestion: true,
})
if (itemWithSuggestions.length === 1 + 3) {
break
}
}
return itemWithSuggestions
})
} else {
if (isFetchingSuggestions || isFetchingSearchResults) {
const placeholders: Item[] = Array(10)
.fill(0)
.map((__, i) => ({
@@ -217,21 +161,54 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
}))
_items.push(...placeholders)
} else if (
(hasSearchText && searchResultsError) ||
(!hasSearchText && suggestionsError) ||
!results?.length
) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else {
const seen = new Set<string>()
for (const profile of results) {
if (seen.has(profile.did)) continue
if (profile.did === currentAccount?.did) continue
if (profile.viewer?.following) continue
seen.add(profile.did)
_items.push({
type: 'profile',
// Don't share identity across tabs or typing attempts
key: resultsKey + ':' + profile.did,
profile,
})
}
}
return _items
}, [
_,
suggestions,
suggestionsError,
isFetchingSuggestions,
searchResults,
isError,
searchResultsError,
isFetchingSearchResults,
currentAccount?.did,
hasSearchText,
selectedInterest,
suggestedAccounts,
query,
resultsKey,
])
if (searchText && !isFetching && !items.length && !isError) {
if (
searchText &&
!isFetchingSearchResults &&
!items.length &&
!isSearchResultsError
) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
@@ -242,9 +219,7 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
return (
<FollowProfileCard
profile={item.profile}
isSuggestion={item.isSuggestion}
moderationOpts={moderationOpts!}
setSuggestedAccounts={setSuggestedAccounts}
noBorder={index === 0}
/>
)
@@ -290,15 +265,6 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
/>
)
const onEndReached = useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more people to follow', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
return (
<Dialog.InnerFlatList
ref={listRef}
@@ -318,15 +284,6 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
scrollIndicatorInsets={{top: headerHeight}}
initialNumToRender={8}
maxToRenderPerBatch={8}
onEndReached={onEndReached}
itemLayoutAnimation={LinearTransition}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
/>
}
/>
)
}
@@ -452,15 +409,18 @@ let Tabs = ({
selectedInterest,
hasSearchText,
interestsDisplayNames,
TabComponent = Tab,
contentContainerStyle,
}: {
onSelectTab: (tab: string) => void
interests: string[]
selectedInterest: string
hasSearchText: boolean
interestsDisplayNames: Record<string, string>
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
contentContainerStyle?: StyleProp<ViewStyle>
}): React.ReactNode => {
const listRef = useRef<ScrollView>(null)
const [scrollX, setScrollX] = useState(0)
const [totalWidth, setTotalWidth] = useState(0)
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
@@ -479,24 +439,11 @@ let Tabs = ({
function scrollIntoViewIfNeeded(index: number) {
const btnLayout = tabOffsets[index]
if (!btnLayout) return
const viewportLeftEdge = scrollX
const viewportRightEdge = scrollX + totalWidth
const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x
const shouldScrollToRightEdge =
viewportRightEdge < btnLayout.x + btnLayout.width
if (shouldScrollToLeftEdge) {
listRef.current?.scrollTo({
x: btnLayout.x - tokens.space.lg,
animated: true,
})
} else if (shouldScrollToRightEdge) {
listRef.current?.scrollTo({
x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg,
animated: true,
})
}
listRef.current?.scrollTo({
// centered
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
animated: true,
})
}
function handleSelectTab(index: number) {
@@ -518,7 +465,7 @@ let Tabs = ({
<ScrollView
ref={listRef}
horizontal
contentContainerStyle={[a.gap_sm, a.px_lg]}
contentContainerStyle={[a.gap_sm, a.px_lg, contentContainerStyle]}
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToOffsets={
@@ -528,11 +475,11 @@ let Tabs = ({
}
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
scrollEventThrottle={200} // big throttle
onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}>
>
{interests.map((interest, i) => {
const active = interest === selectedInterest && !hasSearchText
return (
<Tab
<TabComponent
key={interest}
onSelectTab={handleSelectTab}
active={active}
@@ -547,6 +494,7 @@ let Tabs = ({
)
}
Tabs = memo(Tabs)
export {Tabs}
let Tab = ({
onSelectTab,
@@ -563,6 +511,7 @@ let Tab = ({
interestsDisplayName: string
onLayout: (index: number, x: number, width: number) => void
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const activeText = active ? _(msg` (active)`) : ''
return (
@@ -573,12 +522,32 @@ let Tab = ({
}>
<Button
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
variant={active ? 'solid' : 'outline'}
color={active ? 'primary' : 'secondary'}
size="small"
onPress={() => onSelectTab(index)}>
<ButtonIcon icon={SearchIcon} />
<ButtonText>{interestsDisplayName}</ButtonText>
{({hovered, pressed, focused}) => (
<View
style={[
a.rounded_full,
a.px_lg,
a.py_sm,
a.border,
active || hovered || pressed || focused
? [
t.atoms.bg_contrast_25,
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
]
: [t.atoms.bg, t.atoms.border_contrast_low],
]}>
<Text
style={[
/* TODO: medium weight */
active || hovered || pressed || focused
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{interestsDisplayName}
</Text>
</View>
)}
</Button>
</View>
)
@@ -588,49 +557,18 @@ Tab = memo(Tab)
let FollowProfileCard = ({
profile,
moderationOpts,
isSuggestion,
setSuggestedAccounts,
noBorder,
}: {
profile: AppBskyActorDefs.ProfileView
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
isSuggestion: boolean
setSuggestedAccounts: (
updater: (
v: Map<string, AppBskyActorDefs.ProfileView[]>,
) => Map<string, AppBskyActorDefs.ProfileView[]>,
) => void
noBorder?: boolean
}): React.ReactNode => {
const [hasFollowed, setHasFollowed] = useState(false)
const followupSuggestion = useSuggestedFollowsByActorQuery({
did: profile.did,
enabled: hasFollowed,
})
const candidates = followupSuggestion.data?.suggestions
useEffect(() => {
// TODO: Move out of effect.
if (hasFollowed && candidates && candidates.length > 0) {
setSuggestedAccounts(suggestions => {
const newSuggestions = new Map(suggestions)
newSuggestions.set(profile.did, candidates)
return newSuggestions
})
}
}, [hasFollowed, profile.did, candidates, setSuggestedAccounts])
return (
<LayoutAnimationConfig skipEntering={!isSuggestion}>
<Animated.View entering={native(ZoomInEasyDown)}>
<FollowProfileCardInner
profile={profile}
moderationOpts={moderationOpts}
onFollow={() => setHasFollowed(true)}
noBorder={noBorder}
/>
</Animated.View>
</LayoutAnimationConfig>
<FollowProfileCardInner
profile={profile}
moderationOpts={moderationOpts}
noBorder={noBorder}
/>
)
}
FollowProfileCard = memo(FollowProfileCard)
@@ -641,7 +579,7 @@ function FollowProfileCardInner({
onFollow,
noBorder,
}: {
profile: AppBskyActorDefs.ProfileView
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
onFollow?: () => void
noBorder?: boolean
@@ -658,7 +596,7 @@ function FollowProfileCardInner({
style={[
a.flex_1,
noBorder && a.border_t_0,
(hovered || pressed) && t.atoms.border_contrast_high,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
@@ -822,7 +760,7 @@ function Empty({message}: {message: string}) {
)
}
function boostInterests(boosts?: string[]) {
export function boostInterests(boosts?: string[]) {
return (_a: string, _b: string) => {
const indexA = boosts?.indexOf(_a) ?? -1
const indexB = boosts?.indexOf(_b) ?? -1
@@ -6,12 +6,12 @@ import React, {
} from 'react'
import {
findNodeHandle,
ListRenderItemInfo,
StyleProp,
type ListRenderItemInfo,
type StyleProp,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {AppBskyGraphDefs} from '@atproto/api'
import {type AppBskyGraphDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -20,11 +20,11 @@ import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useEmail} from '#/lib/hooks/useEmail'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {parseStarterPackUri} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
import {List, ListRef} from '#/view/com/util/List'
import {List, type ListRef} from '#/view/com/util/List'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, ios, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -75,8 +75,14 @@ export const ProfileStarterPacks = React.forwardRef<
const t = useTheme()
const bottomBarOffset = useBottomBarOffset(100)
const [isPTRing, setIsPTRing] = useState(false)
const {data, refetch, isFetching, hasNextPage, fetchNextPage} =
useActorStarterPacksQuery({did, enabled})
const {
data,
refetch,
isError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useActorStarterPacksQuery({did, enabled})
const {isTabletOrDesktop} = useWebMediaQueries()
const items = data?.pages.flatMap(page => page.starterPacks)
@@ -95,15 +101,14 @@ export const ProfileStarterPacks = React.forwardRef<
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = useCallback(async () => {
if (isFetching || !hasNextPage) return
const onEndReached = React.useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more starter packs', {message: err})
}
}, [isFetching, hasNextPage, fetchNextPage])
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
useEffect(() => {
if (enabled && scrollElRef.current) {
@@ -112,21 +117,21 @@ export const ProfileStarterPacks = React.forwardRef<
}
}, [enabled, scrollElRef, setScrollViewTag])
const renderItem = ({
item,
index,
}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => {
return (
<View
style={[
a.p_lg,
(isTabletOrDesktop || index !== 0) && a.border_t,
t.atoms.border_contrast_low,
]}>
<StarterPackCard starterPack={item} />
</View>
)
}
const renderItem = useCallback(
({item, index}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => {
return (
<View
style={[
a.p_lg,
(isTabletOrDesktop || index !== 0) && a.border_t,
t.atoms.border_contrast_low,
]}>
<StarterPackCard starterPack={item} />
</View>
)
},
[isTabletOrDesktop, t.atoms.border_contrast_low],
)
return (
<View testID={testID} style={style}>
+34 -3
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {msg} from '@lingui/macro'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -13,7 +13,10 @@ import {precacheStarterPack} from '#/state/queries/starter-packs'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack'
import {Link as BaseLink, LinkProps as BaseLinkProps} from '#/components/Link'
import {
Link as BaseLink,
type LinkProps as BaseLinkProps,
} from '#/components/Link'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
@@ -95,13 +98,41 @@ export function Card({
) : null}
{!!joinedAllTimeCount && joinedAllTimeCount >= 50 && (
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
{joinedAllTimeCount} users have joined!
<Trans comment="Number of users (always at least 50) who have joined Bluesky using a specific starter pack">
<Plural value={joinedAllTimeCount} other="# users have" /> joined!
</Trans>
</Text>
)}
</View>
)
}
export function useStarterPackLink({
view,
}: {
view: bsky.starterPack.AnyStarterPackView
}) {
const {_} = useLingui()
const qc = useQueryClient()
const {rkey, handleOrDid} = React.useMemo(() => {
const rkey = new AtUri(view.uri).rkey
const {creator} = view
return {rkey, handleOrDid: creator.handle || creator.did}
}, [view])
const precache = () => {
precacheResolvedUri(qc, view.creator.handle, view.creator.did)
precacheStarterPack(qc, view)
}
return {
to: `/starter-pack/${handleOrDid}/${rkey}`,
label: AppBskyGraphStarterpack.isRecord(view.record)
? _(msg`Navigate to ${view.record.name}`)
: _(msg`Navigate to starter pack`),
precache,
}
}
export function Link({
starterPack,
children,
+34
View File
@@ -0,0 +1,34 @@
import {View} from 'react-native'
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
export function SubtleHover({style, hover}: ViewStyleProp & {hover: boolean}) {
const t = useTheme()
let opacity: number
switch (t.name) {
case 'dark':
opacity = 0.4
break
case 'dim':
opacity = 0.45
break
case 'light':
opacity = 0.5
break
}
return (
<View
style={[
a.absolute,
a.inset_0,
a.pointer_events_none,
a.transition_opacity,
t.atoms.bg_contrast_25,
style,
{opacity: hover ? opacity : 0},
]}
/>
)
}
+25 -78
View File
@@ -1,22 +1,10 @@
import React from 'react'
import {Keyboard} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, {
cancelAnimation,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
export function ActionsWrapper({
message,
@@ -28,71 +16,30 @@ export function ActionsWrapper({
children: React.ReactNode
}) {
const {_} = useLingui()
const playHaptic = useHaptics()
const menuControl = useMenuControl()
const scale = useSharedValue(1)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{scale: scale.get()}],
}))
const open = React.useCallback(() => {
playHaptic()
Keyboard.dismiss()
menuControl.open()
}, [menuControl, playHaptic])
const shrink = React.useCallback(() => {
'worklet'
cancelAnimation(scale)
scale.set(() => withTiming(1, {duration: 200}))
}, [scale])
const doubleTapGesture = Gesture.Tap()
.numberOfTaps(2)
.hitSlop(HITSLOP_10)
.onEnd(open)
.runOnJS(true)
const pressAndHoldGesture = Gesture.LongPress()
.onStart(() => {
'worklet'
scale.set(() =>
withTiming(1.05, {duration: 200}, finished => {
if (!finished) return
runOnJS(open)()
shrink()
}),
)
})
.onTouchesUp(shrink)
.onTouchesMove(shrink)
.cancelsTouchesInView(false)
const composedGestures = Gesture.Exclusive(
doubleTapGesture,
pressAndHoldGesture,
)
return (
<GestureDetector gesture={composedGestures}>
<Animated.View
style={[
{
maxWidth: '80%',
},
isFromSelf ? a.self_end : a.self_start,
animatedStyle,
]}
accessible={true}
accessibilityActions={[
{name: 'activate', label: _(msg`Open message options`)},
]}
onAccessibilityAction={open}>
{children}
<MessageMenu message={message} control={menuControl} />
</Animated.View>
</GestureDetector>
<MessageContextMenu message={message}>
{trigger =>
// will always be true, since this file is platform split
trigger.isNative && (
<View style={[a.flex_1, a.relative]}>
<View
style={[
{maxWidth: '80%'},
isFromSelf
? [a.self_end, a.align_end]
: [a.self_start, a.align_start],
]}
accessible={true}
accessibilityActions={[
{name: 'activate', label: _(msg`Open message options`)},
]}
onAccessibilityAction={() => trigger.control.open('full')}>
{children}
</View>
</View>
)
}
</MessageContextMenu>
)
}
+104 -41
View File
@@ -1,10 +1,19 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto/api'
import {useCallback, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import type React from 'react'
import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
import {useConvoActive} from '#/state/messages/convo'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util'
export function ActionsWrapper({
message,
@@ -15,26 +24,53 @@ export function ActionsWrapper({
isFromSelf: boolean
children: React.ReactNode
}) {
const menuControl = useMenuControl()
const viewRef = React.useRef(null)
const viewRef = useRef(null)
const t = useTheme()
const {_} = useLingui()
const convo = useConvoActive()
const {currentAccount} = useSession()
const [showActions, setShowActions] = React.useState(false)
const [showActions, setShowActions] = useState(false)
const onMouseEnter = React.useCallback(() => {
const onMouseEnter = useCallback(() => {
setShowActions(true)
}, [])
const onMouseLeave = React.useCallback(() => {
const onMouseLeave = useCallback(() => {
setShowActions(false)
}, [])
// We need to handle the `onFocus` separately because we want to know if there is a related target (the element
// that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
const onFocus = React.useCallback<React.FocusEventHandler>(e => {
const onFocus = useCallback<React.FocusEventHandler>(e => {
if (e.nativeEvent.relatedTarget == null) return
setShowActions(true)
}, [])
const onEmojiSelect = useCallback(
(emoji: string) => {
if (
message.reactions?.find(
reaction =>
reaction.value === emoji &&
reaction.sender.did === currentAccount?.did,
)
) {
convo
.removeReaction(message.id, emoji)
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo
.addReaction(message.id, emoji)
.catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
)
}
},
[_, convo, message, currentAccount?.did],
)
return (
<View
// @ts-expect-error web only
@@ -42,39 +78,66 @@ export function ActionsWrapper({
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onMouseLeave}
style={StyleSheet.flatten([a.flex_1, a.flex_row])}
style={[a.flex_1, isFromSelf ? a.flex_row : a.flex_row_reverse]}
ref={viewRef}>
{isFromSelf && (
<View
style={[
a.mr_xl,
a.justify_center,
{
marginLeft: 'auto',
},
]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
/>
</View>
)}
<View
style={{
maxWidth: '80%',
}}>
style={[
a.justify_center,
a.flex_row,
a.align_center,
isFromSelf
? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse]
: [a.ml_xs, {marginRight: 'auto'}],
]}>
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
{({props, state, isNative, control}) => {
// always false, file is platform split
if (isNative) return null
const showMenuTrigger = showActions || control.isOpen ? 1 : 0
return (
<Pressable
{...props}
style={[
{opacity: showMenuTrigger},
a.p_xs,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<EmojiSmileIcon
size="md"
style={t.atoms.text_contrast_medium}
/>
</Pressable>
)
}}
</EmojiReactionPicker>
<MessageContextMenu message={message}>
{({props, state, isNative, control}) => {
// always false, file is platform split
if (isNative) return null
const showMenuTrigger = showActions || control.isOpen ? 1 : 0
return (
<Pressable
{...props}
style={[
{opacity: showMenuTrigger},
a.p_xs,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<DotsHorizontalIcon
size="md"
style={t.atoms.text_contrast_medium}
/>
</Pressable>
)
}}
</MessageContextMenu>
</View>
<View
style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}>
{children}
</View>
{!isFromSelf && (
<View style={[a.flex_row, a.align_center, a.ml_xl]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
/>
</View>
)}
</View>
)
}
+82
View File
@@ -0,0 +1,82 @@
import {useState} from 'react'
import {Modal, Pressable, View} from 'react-native'
// @ts-expect-error internal component, not supposed to be used directly
// waiting on more customisability: https://github.com/okwasniewski/react-native-emoji-popup/issues/1#issuecomment-2737463753
import EmojiPopupView from 'react-native-emoji-popup/src/EmojiPopupViewNativeComponent'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
export function EmojiPopup({
children,
onEmojiSelected,
}: {
children: React.ReactNode
onEmojiSelected: (emoji: string) => void
}) {
const [modalVisible, setModalVisible] = useState(false)
const {_} = useLingui()
const t = useTheme()
return (
<>
<Pressable
accessibilityLabel={_(msg`Open full emoji list`)}
accessibilityHint=""
accessibilityRole="button"
onPress={() => setModalVisible(true)}>
{children}
</Pressable>
<Modal
animationType="slide"
transparent={true}
visible={modalVisible}
onRequestClose={() => setModalVisible(false)}>
<View style={[a.flex_1, {backgroundColor: t.palette.white}]}>
<View
style={[
t.atoms.bg,
a.pl_lg,
a.pr_md,
a.py_sm,
a.w_full,
a.align_center,
a.flex_row,
a.justify_between,
a.border_b,
t.atoms.border_contrast_low,
]}>
<Text style={[a.font_bold, a.text_md]}>
<Trans>Add Reaction</Trans>
</Text>
<Button
label={_(msg`Close`)}
onPress={() => setModalVisible(false)}
size="small"
variant="ghost"
color="secondary"
shape="round">
<ButtonIcon icon={TimesLarge_Stroke2_Corner0_Rounded} />
</Button>
</View>
<EmojiPopupView
onEmojiSelected={({
nativeEvent: {emoji},
}: {
nativeEvent: {emoji: string}
}) => {
setModalVisible(false)
onEmojiSelected(emoji)
}}
style={[a.flex_1, a.w_full]}
/>
</View>
</Modal>
</>
)
}
+1
View File
@@ -0,0 +1 @@
export {EmojiPopup} from 'react-native-emoji-popup'
+137
View File
@@ -0,0 +1,137 @@
import {useMemo, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
import {atoms as a, tokens, useTheme} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu'
import {
useContextMenuContext,
useContextMenuMenuContext,
} from '#/components/ContextMenu/context'
import {
EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyesIcon,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon,
} from '#/components/icons/Emoji'
import {type TriggerProps} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
import {EmojiPopup} from './EmojiPopup'
import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
export function EmojiReactionPicker({
message,
onEmojiSelect,
}: {
message: ChatBskyConvoDefs.MessageView
children?: TriggerProps['children']
onEmojiSelect: (emoji: string) => void
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const t = useTheme()
const isFromSelf = message.sender?.did === currentAccount?.did
const {measurement, close} = useContextMenuContext()
const {align} = useContextMenuMenuContext()
const [layout, setLayout] = useState({width: 0, height: 0})
const {width: screenWidth} = useWindowDimensions()
// 1 in 100 chance of showing heart eyes icon
const EmojiIcon = useMemo(() => {
return Math.random() < 0.01 ? EmojiHeartEyesIcon : EmojiSmileIcon
}, [])
const position = useMemo(() => {
return {
x: align === 'left' ? 12 : screenWidth - layout.width - 12,
y: (measurement?.y ?? 0) - tokens.space.xs - layout.height,
height: layout.height,
width: layout.width,
}
}, [measurement, align, screenWidth, layout])
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
const bgColor = t.scheme === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25
return (
<View
onLayout={evt => setLayout(evt.nativeEvent.layout)}
style={[
bgColor,
a.rounded_full,
a.absolute,
{bottom: '100%'},
isFromSelf ? a.right_0 : a.left_0,
a.flex_row,
a.p_xs,
a.gap_xs,
a.mb_xs,
a.z_20,
a.border,
t.atoms.border_contrast_low,
a.shadow_md,
]}>
{['👍', '😆', '❤️', '👀', '😢'].map(emoji => {
const alreadyReacted = hasAlreadyReacted(
message,
currentAccount?.did,
emoji,
)
return (
<ContextMenu.Item
position={position}
label={_(msg`React with ${emoji}`)}
key={emoji}
onPress={() => onEmojiSelect(emoji)}
unstyled
disabled={limitReacted ? !alreadyReacted : false}>
{hovered => (
<View
style={[
a.rounded_full,
hovered
? {
backgroundColor: alreadyReacted
? t.palette.negative_100
: t.palette.primary_500,
}
: alreadyReacted
? {backgroundColor: t.palette.primary_200}
: bgColor,
{height: 40, width: 40},
a.justify_center,
a.align_center,
]}>
<Text style={[a.text_center, {fontSize: 30}]} emoji>
{emoji}
</Text>
</View>
)}
</ContextMenu.Item>
)
})}
<EmojiPopup
onEmojiSelected={emoji => {
close()
onEmojiSelect(emoji)
}}>
<View
style={[
a.rounded_full,
t.scheme === 'light'
? t.atoms.bg_contrast_25
: t.atoms.bg_contrast_50,
{height: 40, width: 40},
a.justify_center,
a.align_center,
a.border,
t.atoms.border_contrast_low,
]}>
<EmojiIcon size="xl" fill={t.palette.contrast_400} />
</View>
</EmojiPopup>
</View>
)
}
@@ -0,0 +1,131 @@
import {useState} from 'react'
import {Pressable, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import EmojiPicker from '@emoji-mart/react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {useSession} from '#/state/session'
import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji'
import {atoms as a, flatten, useTheme} from '#/alf'
import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
import * as Menu from '#/components/Menu'
import {type TriggerProps} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
export function EmojiReactionPicker({
message,
children,
onEmojiSelect,
}: {
message: ChatBskyConvoDefs.MessageView
children?: TriggerProps['children']
onEmojiSelect: (emoji: string) => void
}) {
if (!children)
throw new Error('EmojiReactionPicker requires the children prop on web')
const {_} = useLingui()
return (
<Menu.Root>
<Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger>
<Menu.Outer>
<MenuInner message={message} onEmojiSelect={onEmojiSelect} />
</Menu.Outer>
</Menu.Root>
)
}
function MenuInner({
message,
onEmojiSelect,
}: {
message: ChatBskyConvoDefs.MessageView
onEmojiSelect: (emoji: string) => void
}) {
const t = useTheme()
const {control} = Menu.useMenuContext()
const {currentAccount} = useSession()
useWebPreloadEmoji({immediate: true})
const [expanded, setExpanded] = useState(false)
const handleEmojiPickerResponse = (emoji: Emoji) => {
handleEmojiSelect(emoji.native)
}
const handleEmojiSelect = (emoji: string) => {
control.close()
onEmojiSelect(emoji)
}
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
return expanded ? (
<EmojiPicker onEmojiSelect={handleEmojiPickerResponse} autoFocus={true} />
) : (
<Menu.Outer style={[a.rounded_full]}>
<View style={[a.flex_row, a.gap_xs]}>
{['👍', '😆', '❤️', '👀', '😢'].map(emoji => {
const alreadyReacted = hasAlreadyReacted(
message,
currentAccount?.did,
emoji,
)
return (
<DropdownMenu.Item
key={emoji}
className={[
'EmojiReactionPicker__Pressable',
alreadyReacted && '__selected',
limitReacted && '__disabled',
]
.filter(Boolean)
.join(' ')}
onSelect={() => handleEmojiSelect(emoji)}
style={flatten([
a.flex,
a.flex_col,
a.rounded_full,
a.justify_center,
a.align_center,
a.transition_transform,
{
width: 34,
height: 34,
},
alreadyReacted && {
backgroundColor: t.atoms.bg_contrast_100.backgroundColor,
},
])}>
<Text style={[a.text_center, {fontSize: 28}]} emoji>
{emoji}
</Text>
</DropdownMenu.Item>
)
})}
<DropdownMenu.Item
asChild
className="EmojiReactionPicker__PickerButton">
<Pressable
accessibilityRole="button"
role="button"
onPress={() => setExpanded(true)}
style={flatten([
a.rounded_full,
{height: 34, width: 34},
a.justify_center,
a.align_center,
])}>
<DotGridIcon size="lg" style={t.atoms.text_contrast_medium} />
</Pressable>
</DropdownMenu.Item>
</View>
</Menu.Outer>
)
}
+194
View File
@@ -0,0 +1,194 @@
import {memo, useCallback} from 'react'
import {LayoutAnimation} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types'
import {ReportDialog} from '#/components/dms/ReportDialog'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({
message,
children,
}: {
message: ChatBskyConvoDefs.MessageView
children: TriggerProps['children']
}): React.ReactNode => {
const {_} = useLingui()
const {currentAccount} = useSession()
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const langPrefs = useLanguagePrefs()
const openLink = useOpenLink()
const isFromSelf = message.sender?.did === currentAccount?.did
const onCopyMessage = useCallback(() => {
const str = richTextToString(
new RichText({
text: message.text,
facets: message.facets,
}),
true,
)
Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}, [_, message.text, message.facets])
const onPressTranslateMessage = useCallback(() => {
const translatorUrl = getTranslatorLink(
message.text,
langPrefs.primaryLanguage,
)
openLink(translatorUrl, true)
logger.metric('translate', {
sourceLanguages: [],
targetLanguage: langPrefs.primaryLanguage,
textLength: message.text.length,
})
}, [langPrefs.primaryLanguage, message.text, openLink])
const onDelete = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
.then(() =>
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
)
.catch(() => Toast.show(_(msg`Failed to delete message`)))
}, [_, convo, message.id])
const onEmojiSelect = useCallback(
(emoji: string) => {
if (
message.reactions?.find(
reaction =>
reaction.value === emoji &&
reaction.sender.did === currentAccount?.did,
)
) {
convo
.removeReaction(message.id, emoji)
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo
.addReaction(message.id, emoji)
.catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
)
}
},
[_, convo, message, currentAccount?.did],
)
const sender = convo.convo.members.find(
member => member.did === message.sender.did,
)
return (
<>
<ContextMenu.Root>
{isNative && (
<ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}>
<EmojiReactionPicker
message={message}
onEmojiSelect={onEmojiSelect}
/>
</ContextMenu.AuxiliaryView>
)}
<ContextMenu.Trigger
label={_(msg`Message options`)}
contentLabel={_(
msg`Message from @${
sender?.handle ?? // should always be defined
'unknown'
}: ${message.text}`,
)}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Outer align={isFromSelf ? 'right' : 'left'}>
{message.text.length > 0 && (
<>
<ContextMenu.Item
testID="messageDropdownTranslateBtn"
label={_(msg`Translate`)}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemText>{_(msg`Translate`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Translate} position="right" />
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={_(msg`Copy message text`)}
onPress={onCopyMessage}>
<ContextMenu.ItemText>
{_(msg`Copy message text`)}
</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="right" />
</ContextMenu.Item>
<ContextMenu.Divider />
</>
)}
<ContextMenu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
onPress={() => deleteControl.open()}>
<ContextMenu.ItemText>{_(msg`Delete for me`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Trash} position="right" />
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={() => reportControl.open()}>
<ContextMenu.ItemText>{_(msg`Report`)}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={Warning} position="right" />
</ContextMenu.Item>
)}
</ContextMenu.Outer>
</ContextMenu.Root>
<ReportDialog
currentScreen="conversation"
params={{type: 'convoMessage', convoId: convo.convo.id, message}}
control={reportControl}
/>
<Prompt.Basic
control={deleteControl}
title={_(msg`Delete message`)}
description={_(
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`,
)}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
</>
)
}
MessageContextMenu = memo(MessageContextMenu)
+91 -15
View File
@@ -1,29 +1,37 @@
import React, {useCallback, useMemo, useRef} from 'react'
import React, {useCallback, useMemo} from 'react'
import {
GestureResponderEvent,
LayoutAnimation,
StyleProp,
TextStyle,
type GestureResponderEvent,
type StyleProp,
type TextStyle,
View,
} from 'react-native'
import Animated, {
LayoutAnimationConfig,
LinearTransition,
ZoomIn,
ZoomOut,
} from 'react-native-reanimated'
import {
AppBskyEmbedRecord,
ChatBskyConvoDefs,
RichText as RichTextAPI,
} from '@atproto/api'
import {I18n} from '@lingui/core'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ConvoItem} from '#/state/messages/convo/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isNative} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, native, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {RichText} from '../RichText'
import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
import {localDateString} from './util'
@@ -35,6 +43,8 @@ let MessageItem = ({
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
const {_} = useLingui()
const {convo} = useConvoActive()
const {message, nextMessage, prevMessage} = item
const isPending = item.type === 'pending-message'
@@ -88,18 +98,80 @@ let MessageItem = ({
return true
}, [message, nextMessage, isPending])
const lastInGroupRef = useRef(isLastInGroup)
if (lastInGroupRef.current !== isLastInGroup) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
lastInGroupRef.current = isLastInGroup
}
const pendingColor = t.palette.primary_200
const rt = useMemo(() => {
return new RichTextAPI({text: message.text, facets: message.facets})
}, [message.text, message.facets])
const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting>
{message.reactions && message.reactions.length > 0 && (
<View
style={[isFromSelf ? a.align_end : a.align_start, a.px_sm, a.pb_2xs]}>
<View
style={[
a.flex_row,
a.gap_2xs,
a.py_xs,
a.px_xs,
a.justify_center,
isFromSelf ? a.justify_end : a.justify_start,
a.flex_wrap,
a.pb_xs,
t.atoms.bg_contrast_25,
a.border,
t.atoms.border_contrast_low,
a.rounded_lg,
t.atoms.shadow_sm,
{
// vibe coded number
transform: [{translateY: -11}],
},
]}>
{message.reactions.map((reaction, _i, reactions) => {
let label
if (reaction.sender.did === currentAccount?.did) {
label = _(msg`You reacted ${reaction.value}`)
} else {
const senderDid = reaction.sender.did
const sender = convo.members.find(
member => member.did === senderDid,
)
if (sender) {
label = _(
msg`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${reaction.value}`,
)
} else {
label = _(msg`Someone reacted ${reaction.value}`)
}
}
return (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
exiting={reactions.length > 1 && native(ZoomOut.delay(200))}
layout={native(LinearTransition.delay(300))}
key={reaction.sender.did + reaction.value}
style={[a.p_2xs]}
accessible={true}
accessibilityLabel={label}
accessibilityHint={_(
msg`Double tap or long press the message to add a reaction`,
)}>
<Text emoji style={[a.text_sm]}>
{reaction.value}
</Text>
</Animated.View>
)
})}
</View>
</View>
)}
</LayoutAnimationConfig>
)
return (
<>
{isNewDay && <DateDivider date={message.sentAt} />}
@@ -144,8 +216,12 @@ let MessageItem = ({
/>
</View>
)}
{isNative && appliedReactions}
</ActionsWrapper>
{!isNative && appliedReactions}
{isLastInGroup && (
<MessageItemMetadata
item={item}
+25 -8
View File
@@ -1,9 +1,9 @@
import React from 'react'
import {View} from 'react-native'
import {useWindowDimensions, View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, useTheme} from '#/alf'
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
import {MessageContextProvider} from './MessageContext'
let MessageItemEmbed = ({
@@ -12,15 +12,32 @@ let MessageItemEmbed = ({
embed: AppBskyEmbedRecord.View
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
return (
<MessageContextProvider>
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
<View
style={[
a.my_xs,
t.atoms.bg,
a.rounded_md,
native({
flexBasis: 0,
width: Math.min(screen.width, 600) / 1.4,
}),
web({
width: '100%',
minWidth: 280,
maxWidth: 360,
}),
]}>
<View style={{marginTop: tokens.space.sm * -1}}>
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
</View>
</MessageContextProvider>
)
-161
View File
@@ -1,161 +0,0 @@
import React from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {isWeb} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {ReportDialog} from '#/components/dms/ReportDialog'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
export let MessageMenu = ({
message,
control,
triggerOpacity,
}: {
triggerOpacity?: number
message: ChatBskyConvoDefs.MessageView
control: Menu.MenuControlProps
}): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const langPrefs = useLanguagePrefs()
const openLink = useOpenLink()
const isFromSelf = message.sender?.did === currentAccount?.did
const onCopyMessage = React.useCallback(() => {
const str = richTextToString(
new RichText({
text: message.text,
facets: message.facets,
}),
true,
)
Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}, [_, message.text, message.facets])
const onPressTranslateMessage = React.useCallback(() => {
const translatorUrl = getTranslatorLink(
message.text,
langPrefs.primaryLanguage,
)
openLink(translatorUrl, true)
}, [langPrefs.primaryLanguage, message.text, openLink])
const onDelete = React.useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
.then(() =>
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
)
.catch(() => Toast.show(_(msg`Failed to delete message`)))
}, [_, convo, message.id])
return (
<>
<Menu.Root control={control}>
{isWeb && (
<View style={{opacity: triggerOpacity}}>
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
style={[
a.p_sm,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<DotsHorizontal size="md" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
</View>
)}
<Menu.Outer>
{message.text.length > 0 && (
<>
<Menu.Group>
<Menu.Item
testID="messageDropdownTranslateBtn"
label={_(msg`Translate`)}
onPress={onPressTranslateMessage}>
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
<Menu.Item
testID="messageDropdownCopyBtn"
label={_(msg`Copy message text`)}
onPress={onCopyMessage}>
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
</>
)}
<Menu.Group>
<Menu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
onPress={() => deleteControl.open()}>
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
{!isFromSelf && (
<Menu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={() => reportControl.open()}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
)}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<ReportDialog
currentScreen="conversation"
params={{type: 'convoMessage', convoId: convo.convo.id, message}}
control={reportControl}
/>
<Prompt.Basic
control={deleteControl}
title={_(msg`Delete message`)}
description={_(
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`,
)}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
</>
)
}
MessageMenu = React.memo(MessageMenu)
+30 -1
View File
@@ -1,4 +1,7 @@
import * as bsky from '#/types/bsky'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import type * as bsky from '#/types/bsky'
export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
switch (profile.associated?.chat?.allowIncoming) {
@@ -25,3 +28,29 @@ export function localDateString(date: Date) {
// not padding with 0s because it's not necessary, it's just used for comparison
return `${yyyy}-${mm}-${dd}`
}
export function hasAlreadyReacted(
message: ChatBskyConvoDefs.MessageView,
myDid: string | undefined,
emoji: string,
): boolean {
if (!message.reactions) {
return false
}
return !!message.reactions.find(
reaction => reaction.value === emoji && reaction.sender.did === myDid,
)
}
export function hasReachedReactionLimit(
message: ChatBskyConvoDefs.MessageView,
myDid: string | undefined,
): boolean {
if (!message.reactions) {
return false
}
const myReactions = message.reactions.filter(
reaction => reaction.sender.did === myDid,
)
return myReactions.length >= EMOJI_REACTION_LIMIT
}
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Flame_Stroke2_Corner1_Rounded = createSinglePathSVG({
path: 'M11.158 2.879c.584-.835 1.757-1.137 2.673-.507.951.654 2.597 1.92 4.013 3.694S20.5 10.194 20.5 13c0 4.997-3.752 9-8.5 9s-8.5-4.003-8.5-9c0-2.035.874-4.636 2.578-6.712.746-.91 2.034-.855 2.786-.133l2.294-3.276Zm-3.04 15.758C6.538 17.386 5.5 15.37 5.5 13c0-1.511.666-3.616 2.042-5.342.87.797 2.254.653 2.939-.325l2.286-3.265c.871.606 2.299 1.723 3.514 3.246C17.53 8.879 18.5 10.804 18.5 13c0 2.369-1.038 4.386-2.618 5.637q.117-.518.118-1.061c0-2.601-2.038-4.382-2.911-5.04a1.8 1.8 0 0 0-2.177 0C10.038 13.195 8 14.976 8 17.577q0 .543.118 1.061ZM12 14.222c-.825.648-2 1.859-2 3.354C10 19.043 11.016 20 12 20s2-.957 2-2.424c0-1.495-1.175-2.706-2-3.354Z',
})
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'm18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z',
})
export const Trending3_Stroke2_Corner1_Rounded = createSinglePathSVG({
path: 'M15 7a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9.414L14.414 15a2 2 0 0 1-2.828 0L9 12.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L7.586 11a2 2 0 0 1 2.828 0L13 13.586 18.586 8H16a1 1 0 0 1-1-1Z',
})
+3 -3
View File
@@ -1,5 +1,5 @@
import {StyleSheet, TextProps} from 'react-native'
import type {PathProps, SvgProps} from 'react-native-svg'
import {StyleSheet, type TextProps} from 'react-native'
import {type PathProps, type SvgProps} from 'react-native-svg'
import {Defs, LinearGradient, Stop} from 'react-native-svg'
import {nanoid} from 'nanoid/non-secure'
@@ -19,7 +19,7 @@ export const sizes = {
lg: 24,
xl: 28,
'2xl': 32,
}
} as const
export function useCommonSVGProps(props: Props) {
const t = useTheme()
+1 -1
View File
@@ -15,7 +15,7 @@ import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
import * as Prompt from '#/components/Prompt'
import {TrendingTopicLink} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography'
@@ -16,7 +16,7 @@ import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
+3 -1
View File
@@ -114,7 +114,9 @@ export function PostHider({
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
</Pressable>
<Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}>
<Text
style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]}
numberOfLines={1}>
{desc.name}
</Text>
{!modui.noOverride && (
+2 -2
View File
@@ -1,4 +1,4 @@
import {version} from '../../package.json'
import packageDotJson from '../../package.json'
export const IS_TESTFLIGHT = false
export const IS_INTERNAL = __DEV__
@@ -14,5 +14,5 @@ export const BUNDLE_DATE = __DEV__
? 0
: Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
export const appVersion = version
export const appVersion = packageDotJson.version
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${__DEV__ ? 'dev' : 'prod'})`
+1 -2
View File
@@ -3,7 +3,6 @@ export const isSafari = /^((?!chrome|android).)*safari/i.test(
navigator.userAgent,
)
export const isFirefox = /firefox|fxios/i.test(navigator.userAgent)
export const isTouchDevice =
'ontouchstart' in window || navigator.maxTouchPoints > 1
export const isTouchDevice = window.matchMedia('(pointer: coarse)').matches
export const isAndroidWeb =
/android/i.test(navigator.userAgent) && isTouchDevice
+14 -2
View File
@@ -1,5 +1,5 @@
import {Insets, Platform} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {type Insets, Platform} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
@@ -23,6 +23,16 @@ export const STARTER_PACK_MAX_SIZE = 150
// -prf
export const JOINED_THIS_WEEK = 560000 // estimate as of 12/18/24
export const DISCOVER_DEBUG_DIDS: Record<string, true> = {
'did:plc:oisofpd7lj26yvgiivf3lxsi': true, // hailey.at
'did:plc:p2cp5gopk7mgjegy6wadk3ep': true, // samuel.bsky.team
'did:plc:ragtjsm2j2vknwkz3zp4oxrd': true, // pfrazee.com
'did:plc:vpkhqolt662uhesyj6nxm7ys': true, // why.bsky.team
'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol
'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social
}
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({
email,
@@ -180,3 +190,5 @@ export const SUPPORTED_MIME_TYPES = [
] as const
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
export const EMOJI_REACTION_LIMIT = 5
+1 -1
View File
@@ -1,4 +1,4 @@
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
import {type StyleProp, type TextStyle, type ViewStyle} from 'react-native'
import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg'
// Copyright (c) 2020 Refactoring UI Inc.
+1 -1
View File
@@ -1,6 +1,6 @@
export class VideoTooLargeError extends Error {
constructor() {
super('Videos cannot be larger than 50mb')
super('Videos cannot be larger than 100 MB')
this.name = 'VideoTooLargeError'
}
}
+4 -3
View File
@@ -1,7 +1,7 @@
import {NavigationState, PartialState} from '@react-navigation/native'
import type {NativeStackNavigationProp} from '@react-navigation/native-stack'
import {type NavigationState, type PartialState} from '@react-navigation/native'
import {type NativeStackNavigationProp} from '@react-navigation/native-stack'
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
export type {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -51,6 +51,7 @@ export type CommonNavigatorParams = {
AccountSettings: undefined
PrivacyAndSecuritySettings: undefined
ContentAndMediaSettings: undefined
SettingsInterests: undefined
AboutSettings: undefined
AppIconSettings: undefined
Search: {q?: string}
+1
View File
@@ -2,6 +2,7 @@ export type Gate =
// Keep this alphabetic please.
| 'debug_show_feedcontext'
| 'debug_subscriptions'
| 'explore_show_suggested_feeds'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'remove_show_latest_button'
+2 -2
View File
@@ -323,7 +323,7 @@ export function createBskyAppAbsoluteUrl(path: string): string {
export function createProxiedUrl(url: string): string {
let u
try {
u = URL.parse(url)
u = new URL(url)
} catch {
return url
}
@@ -332,7 +332,7 @@ export function createProxiedUrl(url: string): string {
return url
}
return `https://go.bsky.app/redirect?u=${url}`
return `https://go.bsky.app/redirect?u=${encodeURIComponent(url)}`
}
export function isShortLink(url: string): boolean {
+1 -1
View File
@@ -169,7 +169,7 @@ export const LANGUAGES: Language[] = [
{code3: 'cad', code2: '', name: 'Caddo'},
{code3: 'cai', code2: '', name: 'Central American Indian languages'},
{code3: 'car', code2: '', name: 'Galibi Carib'},
{code3: 'cat', code2: 'ca', name: 'Catalan'},
{code3: 'cat', code2: 'ca', name: 'Catalan-Valencian'},
{code3: 'cau', code2: '', name: 'Caucasian languages'},
{code3: 'ceb', code2: '', name: 'Cebuano'},
{code3: 'cel', code2: '', name: 'Celtic languages'},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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