Compare commits

...

118 Commits

Author SHA1 Message Date
Eric Bailey ebee10c6f2 Cleanup 2024-09-15 15:34:34 -05:00
Eric Bailey 5945eeda3b Fix overflow, add bg to no-thumb state 2024-09-15 15:30:36 -05:00
Eric Bailey 644717fa03 Borders when no thumb 2024-09-15 15:22:30 -05:00
Eric Bailey feedaf8a82 Remove overflow hidden 2024-09-15 13:53:14 -05:00
Hailey f8658f0795 bump (#5348) 2024-09-14 19:38:51 +01:00
Paul Frazee 701ddfb171 Release 1.91.1 prep (#5339)
* Fixes to tests

* intl extract
2024-09-13 16:55:01 -07:00
Hailey d8b80309bd [Video] Fix regression on audio session at launch (#5338) 2024-09-13 16:48:17 -07:00
Eric Bailey e767c50f65 Don't open composer via hotkey if other dialog is already open (#5334)
* Don't open composer via hotkey if other dialog is already open

* Check for lightbox also

* Check for drawer
2024-09-13 18:14:46 -05:00
Eric Bailey d76f9abdd7 "N" keyboard shortcut to open a new post modal (#5197)
* feat: Add hook on web app to open composer with 'N' keyboard shortcut

* Extract, don't fire open composer if already open

* Ignore interactive elements

---------

Co-authored-by: João Gabriel <joaog@nocorp.io>
Co-authored-by: Hailey <me@haileyok.com>
2024-09-13 16:48:28 -05:00
Hailey cac43127f0 [Video] Bump video (#5333) 2024-09-13 14:46:02 -07:00
dan ce3893d816 Apply Following settings to Lists (#5313)
* Apply Following settings to Lists

* Remove dead code
2024-09-13 22:30:09 +01:00
Eric Bailey 88813f57c9 Always display next button on login page (#5326)
Co-authored-by: Vinícius Souza <39967235+vinifsouza@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
2024-09-13 14:21:45 -07:00
Hailey 533382173c [Video] Don't require email verification on self-host (#5332) 2024-09-13 14:08:45 -07:00
Hailey 843f9925f5 [Video] Remember mute state while scrolling (#5331) 2024-09-13 14:07:13 -07:00
dan 791bc7afbe Fix lexicon validation in PWI Discover (#5329) 2024-09-13 21:11:17 +01:00
Hailey 26508cfe6a [Video] Remove expo-video, use bluesky-video (#5282)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2024-09-13 12:44:42 -07:00
Eric Bailey 78a531f5ff Disable pointer events on media border (#5327) 2024-09-13 19:02:47 +01:00
Paul Frazee 9163f676f5 Merge branch 'events' into main 2024-09-13 10:30:55 -07:00
Eric Bailey 08ac3a27c2 Add events 2024-09-13 12:05:15 -05:00
Eric Bailey b3381da1c1 Image/video border + tweaks (#5324)
* Image/video border (#5253)

* Update AutoSizedImage.tsx

* Update AutoSizedImage.tsx

* Update Gallery.tsx

* Update ExternalLinkEmbed.tsx

* Update MediaPreview.tsx

* Update UserAvatar.tsx

* Update ExternalLinkEmbed.tsx

* Update ExternalPlayerEmbed.tsx

* Update ExternalGifEmbed.tsx

* Update GifEmbed.tsx

* Update ExternalGifEmbed.tsx

* Update GifEmbed.tsx

* Update UserAvatar.tsx

* Update ExternalPlayerEmbed.tsx

* Update ExternalPlayerEmbed.tsx

* video

* Update QuoteEmbed.tsx

* Tweaks, abstract components

---------

Co-authored-by: Minseo Lee <itoupluk427@gmail.com>
2024-09-13 18:02:58 +01:00
Paul Frazee c7231537f1 Merge branch 'ten-milly' into main 2024-09-13 08:57:41 -07:00
dan 1dc7ef137c Fix notification->post jump for real (#5314)
* Revert "Fix notification scroll jump (#5297)"

This reverts commit e0d9e75407.

* Query notifications first
2024-09-13 15:01:09 +01:00
Eric Bailey 0315814edd Separate alt/crop, use new icon (#5321) 2024-09-13 14:56:54 +01:00
Eric Bailey b47bac965f Merge remote-tracking branch 'origin/ten-milly-android-save' into ten-milly
* origin/ten-milly-android-save:
  On android, change ten milly nux secondary action to save instead of share
2024-09-12 21:24:57 -05:00
Eric Bailey 4637c66390 Let display name wrap 2024-09-12 21:24:32 -05:00
Eric Bailey 003f9e06d4 Fallback snoozing 2024-09-12 21:24:32 -05:00
dan fddb1255ec Enable video_upload gate (#5311) 2024-09-12 17:37:07 -07:00
Eric Bailey 47d99b8712 Adjust image sizing (#5302) 2024-09-12 22:35:59 +01:00
Eric Bailey d60a8f26c4 Suggested follows by actor (on profiles) updates (#5243)
* If fallback, return nothing

* Compress size a bit

* Hide on own profile

* Match load state

* Remove gcTime

* Filter out followed users

* Feedback
2024-09-12 22:34:10 +01:00
Paul Frazee 36ac551347 Add ja and pt-BR translations 2024-09-12 13:11:30 -07:00
Paul Frazee 7acf0e1284 On android, change ten milly nux secondary action to save instead of share 2024-09-12 12:57:08 -07:00
Eric Bailey cc8e7b5ae5 Handle display name 2024-09-12 13:56:25 -05:00
Eric Bailey 1f2e4b26c0 Handle overflow of bottom text 2024-09-12 13:54:20 -05:00
Eric Bailey 7bba213e1a Add retry 2024-09-12 13:45:58 -05:00
Eric Bailey bd79ce7ea0 Ensure dialog shows for all accounts without snoozing 2024-09-12 13:45:06 -05:00
Eric Bailey 46402fd010 Add gate 2024-09-12 13:14:51 -05:00
Paul Frazee 47bea32061 Add a hard-coded redirect for lulaoficial.bsky.social (#5303) 2024-09-12 11:09:58 -07:00
dan 897d8ba744 Revert "Show some known OP replies in Following (#5049)" (#5301)
This reverts commit bff6aedecf.
2024-09-12 18:45:19 +01:00
Wesley 86abeb80b9 Renaming the Follow button to "Follow back" when followed by user (#5281)
* Renaming the follow button to follow back when followed by user

* Fixing conditions and reusing existing translation
2024-09-12 15:59:12 +01:00
dan e0d9e75407 Fix notification scroll jump (#5297) 2024-09-12 15:39:04 +01:00
Minseo Lee 4d22adbcf6 Tabular numbers to video timestamp (#5293) 2024-09-12 15:30:57 +01:00
Samuel Newman 7da3ddbe24 [Video] speculative .vtt file selection fix (#5296)
* speculative vtt fix

* add logging
2024-09-12 15:16:38 +01:00
Eric Bailey 63f85f6493 Copy 2024-09-12 08:03:58 -05:00
Eric Bailey c99e43d6c6 Protect against other exit methods, protect against multiple fetches 2024-09-11 21:56:20 -05:00
Eric Bailey 45c8d89d92 Protect against 3p PDSs and bad responses 2024-09-11 21:52:26 -05:00
Eric Bailey 6e78ce53d7 Dev helpers, string cleanup 2024-09-11 21:47:25 -05:00
Eric Bailey c8b133863d Fix some nux types 2024-09-11 21:28:34 -05:00
Eric Bailey 9bb385a4dd Refactor, integrate nux, snoozing 2024-09-11 21:20:39 -05:00
Eric Bailey 63444052e8 Rename 2024-09-11 20:01:27 -05:00
Eric Bailey 77d60a5b80 Hook up data 2024-09-11 19:58:17 -05:00
Eric Bailey f8edd11bc5 Don't open for logged out users 2024-09-11 19:58:17 -05:00
Eric Bailey 2ee68e4f4d Copy 2024-09-11 19:58:16 -05:00
Eric Bailey 11ecea22d4 Add badges, clean up spacing 2024-09-11 19:58:16 -05:00
Eric Bailey f7db14f32f Disable avi 2024-09-11 19:58:16 -05:00
Eric Bailey eaf0081623 WIP, avi not working on web 2024-09-11 19:58:16 -05:00
Eric Bailey 3c8b3b4782 Progress on desktoip 2024-09-11 19:58:16 -05:00
Eric Bailey 76c584d981 WIP 2024-09-11 19:58:16 -05:00
Eric Bailey ae71f5ce84 NUX API (#5278)
* Set up nux API

* Bump SDK

* Naming

* Imports
2024-09-11 19:56:00 -05:00
Eduardo Tachotte cff7cbb4aa Add autoCapitalize to password field (#5216) 2024-09-12 00:28:23 +01:00
Gargaj 67f56af6d4 remove double closing tag (#5257) 2024-09-11 23:42:21 +01:00
Samuel Newman 8a6d83de3b make container relative (#5280) 2024-09-11 23:04:40 +01:00
Samuel Newman 991202966e [Video] Fix web autoplay (#5274) 2024-09-11 11:36:54 -07:00
Hailey dd2d0e6233 add (#5273) 2024-09-11 18:57:58 +01:00
Samuel Newman f943239894 fix min size for compression (#5272) 2024-09-11 18:50:36 +01:00
Samuel Newman 24b07c6cf4 [Video] Cap duration (#5270) 2024-09-11 10:33:57 -07:00
Samuel Newman a19c91d90e [Video] TEMP disable skip compression (#5271) 2024-09-11 10:33:10 -07:00
Samuel Newman 580b67ba37 disable autoplay within messages and trim feelers (#5260) 2024-09-11 16:20:32 +01:00
Samuel Newman b04ecbe54d hls buffering tweaks (#5266) 2024-09-11 16:20:20 +01:00
Samuel Newman db38438549 increase target area of scrubber (#5265) 2024-09-11 16:20:08 +01:00
Samuel Newman fc25992070 [Video] make hover state stick around if tapped (#5259) 2024-09-10 20:19:37 -07:00
Samuel Newman c22492147b remove scrollbar-gutter in fullscreen (#5258) 2024-09-10 23:18:08 +01:00
Hailey b37b64fb49 Verify Identical Domains Emit Origin (#5255) 2024-09-10 08:16:41 -07:00
Samuel Newman 08f5f37b34 [Video] Allow drag-and-drop & pasting video (#5252)
* allow DnD/pasting video

* rm await
2024-09-10 16:14:28 +01:00
Samuel Newman 6bc5a05f4b [Video] Much simpler fix to fullscreen bug (#5251)
* much simpler fix

* allow old behaviour on firefox

* rm logs
2024-09-10 16:10:13 +01:00
Frudrax Cheng db9cf92d87 Adjust the translated strings to the correct max size. (#5248)
* Update pt-BR localization

* Update ru localization
2024-09-10 11:21:28 +01:00
Samuel Newman 66239ba11d [Video] Fix fullscreen on Chrome (#5246) 2024-09-09 22:37:57 -07:00
Samuel Newman 0f6be244a6 max 1 subtitle file (#5244) 2024-09-10 02:02:53 +01:00
Samuel Newman 436e30fded [Video] use correct max size (#5245)
Co-authored-by: Hailey <me@haileyok.com>
2024-09-09 17:01:40 -07:00
Hailey 723a5e488e [Video] Add a string for localization (#5240) 2024-09-09 12:44:41 -07:00
Jaz 9e1eb1f9f1 Add CORS to bskyweb (#5221) 2024-09-09 18:39:28 +01:00
surfdude29 f240893980 Add context to Mute and Unmute labels on video control (#5234)
Co-authored-by: Marco Buono <thecoreh@gmail.com>
2024-09-09 09:29:14 -07:00
Samuel Newman 44f1cd9fb5 [Video] Media preview improvements (#5229)
* background color for images that haven't loaded

* fix recordwithmedia not appearing
2024-09-09 16:41:27 +01:00
Samuel Newman 6c6a76b193 [Video] Upload tweaks (#5228)
* use correct mime type

* fix wheel progress
2024-09-08 08:27:50 -07:00
surfdude29 95aee146b6 Update dates.ts (#5220) 2024-09-07 21:23:51 -07:00
Paul Frazee a6a3d203fd Release 1.91 prep (#5215)
* Run intl:extract

* Test fixes

* Update pt-BR translations for video
2024-09-07 13:52:00 -07:00
Kirill 63ab16a62d Add Russian translation (#3875)
* Add Russian translation

* Update messages.po

* Update messages.po (draft)

* Добавлены новые строки для перевода

В ручную объеденил исправленный RU с самым новым EN. Могут быть ошибки но быстрый тест проблем не выявил.

* Переведены не переведенные строки, некоторые исправления перевода.

* Еще небольшие правки

* Update messages.po (draft)

* Update messages.po (stage)

* Update messages.po

* Init lingui compiling ru language

* Update messages.po

* Update messages.po (clear)

* Update messages.po

* Update messages.po

* change await import to await Promise.all

* Update messages.po

* Update messages.po (clear)

---------

Co-authored-by: DearFox <59219907+DearFox@users.noreply.github.com>
2024-09-07 13:50:18 -07:00
Hailey 9b8d62ca25 [Video] Tweak order of elements in composer (#5213) 2024-09-07 13:29:27 -07:00
Hailey f1877e44f2 [Video] Fix type on web (#5211) 2024-09-07 13:03:53 -07:00
Takayuki KUSANO 0a61b06580 Update Japanese Translation (#5031)
* Update translation

* Update translation

* Fixed wording

* Change the translation of QRcode of starterpack

* Update translation

* Update translation

* Update translation

* Update translation
2024-09-07 12:29:37 -07:00
Minseo Lee 4037e7a50d Update Korean localization (#5035)
* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po
2024-09-07 12:29:08 -07:00
Frudrax Cheng 373735ac49 Update Chinese Localization (#5036)
* CN: Update Translates

* CN: Remove superseded strings

* CN: Update Translates#1

* TW: Update Translates & Remove superseded strings

* CN: Update Translates#2

* TW: Update and clean

* CN: Update Translates

* Both: Run intl:extract

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* CN: Fix typos

* Update Translates

* TW: Update Translates

* CN: Update Translates

* CN: Update Translates

* CN: Remove superseded strings

* TW: Update and clean

* CN: Update Translates

---------

Co-authored-by: Kuwa Lee <kuwalee1069@gmail.com>
Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>
2024-09-07 12:28:36 -07:00
miyun 665766b0d5 Overhaul IT locale (#5069)
* Overhaul IT locale

* Various corrections to the IT locale (thanks to surfdude29!)
2024-09-07 12:25:07 -07:00
Ivan Beà 2a7b333553 Update catalan messages.po (#5067)
* Update catalan messages.po

Another batch, take a look please @jordimas @darccio @surfdude29 @rortan134

* Update catalan messages.po

apply @jordimas corrections

* Update src/locale/locales/ca/messages.po

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

* Update src/locale/locales/ca/messages.po

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

* Update src/locale/locales/ca/messages.po

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

* Update src/locale/locales/ca/messages.po

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

* Update src/locale/locales/ca/messages.po

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

* Update src/locale/locales/ca/messages.po

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

* Update messages.po

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2024-09-07 12:23:50 -07:00
Samuel Newman 1b4fee3e43 [Video] Open the floodgates (partially) (#5208) 2024-09-07 12:15:15 -07:00
Hailey 51259e7c42 Revert "[Video] Ensure loop doesn't stop" (#5209) 2024-09-07 12:11:18 -07:00
Hailey 10cdc436b8 [Video] Ensure loop doesn't stop (#5207) 2024-09-07 11:54:51 -07:00
Hailey 2842f661db Add intent for verifying email (#5120) 2024-09-07 11:54:39 -07:00
Samuel Newman 45a719b256 [Video] Check upload limits before uploading (#5153)
* DRY up video service auth code

* throw error if over upload limits

* use token

* xmark on toast

* errors with nice translatable error messages

* Update src/state/queries/video/video.ts

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-09-07 19:27:32 +01:00
Samuel Newman b7d78fe59b [Video] Only compress if >25mb or unknown format (#5187)
Co-authored-by: Hailey <me@haileyok.com>
2024-09-07 11:22:44 -07:00
dan 42fb92064a Set onboarding_minimum_interests to false (#5204)
Co-authored-by: Hailey <me@haileyok.com>
2024-09-07 11:22:34 -07:00
dan 292117804f Set show_follow_suggestions_in_profile to true (#5205) 2024-09-07 17:08:19 +02:00
dan 7d7431d14e Set fixed_bottom_bar to true (#5203) 2024-09-07 17:07:30 +02:00
Hailey c8be9b78c6 [Statsig] Add more events to downsample, increase downsample rate (#5198)
* add some events for sampling

* include downsample rate in metadata

* fix metadata logic

* uncomment debug
2024-09-07 13:13:51 +02:00
jlca adef9cff10 fix: remove duplicate style rounded_sm (#5201) 2024-09-06 22:29:36 -07:00
nicofercavv 275f2bb004 Add cursor pointer to 'New post' button (#5109) 2024-09-06 18:18:31 -05:00
Eric Bailey 543be17674 Add emoji picker to chat composer (#5196)
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Adrov Igor <nucleartux@gmail.com>
2024-09-06 15:58:47 -07:00
Jaz 30d2ab8dd3 Merge pull request #5195 from brianolson/ipcc-handler
quick integration of ipcc service
2024-09-06 15:21:27 -07:00
Hailey 7e4f8cabd3 [Video] Handle push/pop on Android for autoplay (#5194) 2024-09-06 15:01:05 -07:00
Jaz dc6b04b66f nvm 2024-09-06 15:00:13 -07:00
Jaz 6620ee421b fix action 2024-09-06 14:59:02 -07:00
Jaz d41a00b373 Trigger a build maybe 2024-09-06 14:58:19 -07:00
Igor Adrov 00ce95893d Fix starter packs scroll (#5190) 2024-09-06 16:32:58 -05:00
Eric Bailey c5faa60344 Redesign play button (#5192) 2024-09-06 21:55:23 +01:00
Brian Olson eec93c4e74 cleanup 2024-09-06 16:34:57 -04:00
Brian Olson 6d4ae3d719 quick integration of ipcc service 2024-09-06 16:19:55 -04:00
Eric Bailey cd9c3bf498 Bump joined stat (#5188)
* Bump joined stat

* Ope, actually more
2024-09-06 12:34:51 -05:00
Hailey 60182cd874 [Video] Add disable autoplay for native, more tweaking (#5178) 2024-09-06 09:31:01 -07:00
Eric Bailey bdff8752fb Add support for autofill of one-time-code for email verification (#5186) 2024-09-06 11:22:29 -05:00
144 changed files with 24747 additions and 9871 deletions
+1 -13
View File
@@ -74,8 +74,7 @@ appId: xyz.blueskyweb.app
- tapOn: "Delete List"
- tapOn:
id: "confirmBtn"
- assertVisible:
id: "listsEmpty"
- assertVisible: "This list is empty!"
- tapOn:
label: "Create a new curatelist"
@@ -161,17 +160,6 @@ appId: xyz.blueskyweb.app
- assertNotVisible:
id: "userAddRemoveListsModal"
- tapOn:
label: "Shows the curatelist on my profile"
id: "bottomBarProfileBtn"
- swipe:
from:
id: "profilePager-selector"
direction: LEFT
- tapOn:
id: "profilePager-selector-6"
- tapOn: "Good Ppl"
- tapOn:
label: "Adds and removes users on curatelists from the profile"
id: "bottomBarSearchBtn"
+6 -1
View File
@@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Pin alice's feed
- extendedWaitUntil:
visible:
id: "viewHeaderDrawerBtn"
- tapOn:
id: "bottomBarProfileBtn"
id: "viewHeaderDrawerBtn"
- tapOn:
id: "profileCardButton"
- swipe:
from:
id: "profilePager-selector"
+3 -10
View File
@@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "e2eSignInAlice"
- extendedWaitUntil:
visible:
text: "Feeds ✨"
- tapOn:
label: "Can go to feeds page using feeds button in tab bar"
text: "Feeds ✨"
@@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app
- tapOn:
label: "Can like posts"
id: "likeBtn"
- assertVisible:
id: "likeCount"
text: "1"
- tapOn:
id: "likeBtn"
- assertNotVisible:
id: "likeCount"
- tapOn:
label: "Can repost posts"
id: "repostBtn"
- tapOn: "Repost"
- assertVisible:
id: "repostCount"
text: "1"
- tapOn:
id: "repostBtn"
- tapOn: "Remove repost"
- assertNotVisible:
id: "repostCount"
- tapOn:
label: "Can delete posts"
+3
View File
@@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app
# Navigate to my profile
- extendedWaitUntil:
visible:
id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarProfileBtn"
+3
View File
@@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Navigate to another user profile
- extendedWaitUntil:
visible:
id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarSearchBtn"
- tapOn:
+4 -12
View File
@@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app
# Navigate to thread
- extendedWaitUntil:
visible: "Thread root"
- tapOn: "Thread root"
- assertVisible: "Thread reply"
@@ -19,32 +21,22 @@ appId: xyz.blueskyweb.app
id: "likeBtn"
childOf:
id: "postThreadItem-by-bob.test"
- assertVisible:
id: "likeCount-expanded"
- assertVisible: "1 like"
- tapOn:
id: "likeBtn"
childOf:
id: "postThreadItem-by-bob.test"
- assertNotVisible:
id: "likeCount-expanded"
- assertNotVisible: "1 like"
# Can like a reply post
- tapOn:
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
- assertVisible:
id: "likeCount"
childOf:
id: "postThreadItem-by-carla.test"
- tapOn:
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
- assertNotVisible:
id: "likeCount"
childOf:
id: "postThreadItem-by-carla.test"
# Can repost the root post
- tapOn:
-1
View File
@@ -211,7 +211,6 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
},
],
'expo-video',
'react-native-compressor',
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js',
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 3a1 1 0 0 1 1 1v8.086l1.793-1.793a1 1 0 1 1 1.414 1.414l-3.5 3.5a1 1 0 0 1-1.414 0l-3.5-3.5a1 1 0 1 1 1.414-1.414L11 12.086V4a1 1 0 0 1 1-1ZM4 14a1 1 0 0 1 1 1v4h14v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 378 B

+1 -1
View File
@@ -372,7 +372,7 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
return (
<div
className="w-full overflow-hidden rounded-lg aspect-square"
className="w-full overflow-hidden rounded-lg aspect-square relative"
style={{aspectRatio: `${aspectRatio} / 1`}}>
<img
src={content.thumbnail}
+13
View File
@@ -60,6 +60,12 @@ func run(args []string) {
Value: "",
EnvVars: []string{"LINK_HOST"},
},
&cli.StringFlag{
Name: "ipcc-host",
Usage: "scheme, hostname, and port of ipcc service",
Value: "https://localhost:8730",
EnvVars: []string{"IPCC_HOST"},
},
&cli.BoolFlag{
Name: "debug",
Usage: "Enable debug mode",
@@ -74,6 +80,13 @@ func run(args []string) {
Value: "",
EnvVars: []string{"BASIC_AUTH_PASSWORD"},
},
&cli.StringSliceFlag{
Name: "cors-allowed-origins",
Usage: "list of allowed origins for CORS requests",
Required: false,
Value: cli.NewStringSlice("https://bsky.app", "https://main.bsky.dev", "https://app.staging.bsky.dev"),
EnvVars: []string{"CORS_ALLOWED_ORIGINS"},
},
},
},
}
+76
View File
@@ -1,12 +1,17 @@
package main
import (
"bytes"
"context"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"net/netip"
"net/url"
"os"
"os/signal"
@@ -41,6 +46,7 @@ type Config struct {
appviewHost string
ogcardHost string
linkHost string
ipccHost string
}
func serve(cctx *cli.Context) error {
@@ -49,7 +55,9 @@ func serve(cctx *cli.Context) error {
appviewHost := cctx.String("appview-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
ipccHost := cctx.String("ipcc-host")
basicAuthPassword := cctx.String("basic-auth-password")
corsOrigins := cctx.StringSlice("cors-allowed-origins")
// Echo
e := echo.New()
@@ -91,6 +99,7 @@ func serve(cctx *cli.Context) error {
appviewHost: appviewHost,
ogcardHost: ogcardHost,
linkHost: linkHost,
ipccHost: ipccHost,
},
}
@@ -160,6 +169,12 @@ func serve(cctx *cli.Context) error {
RedirectCode: http.StatusFound,
}))
// CORS middleware
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: corsOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
}))
//
// configure routes
//
@@ -261,6 +276,9 @@ func serve(cctx *cli.Context) error {
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
// ipcc
e.GET("/ipcc", server.WebIpCC)
if linkHost != "" {
linkUrl, err := url.Parse(linkHost)
if err != nil {
@@ -520,3 +538,61 @@ func (srv *Server) WebProfile(c echo.Context) error {
data["requestHost"] = req.Host
return c.Render(http.StatusOK, "profile.html", data)
}
type IPCCRequest struct {
IP string `json:"ip"`
}
type IPCCResponse struct {
CC string `json:"countryCode"`
}
func (srv *Server) WebIpCC(c echo.Context) error {
realIP := c.RealIP()
addr, err := netip.ParseAddr(realIP)
if err != nil {
log.Warnf("could not parse IP %q %s", realIP, err)
return c.JSON(400, IPCCResponse{})
}
var request []byte
if addr.Is4() {
ip4 := addr.As4()
var dest [8]byte
base64.StdEncoding.Encode(dest[:], ip4[:])
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
} else if addr.Is6() {
ip6 := addr.As16()
var dest [24]byte
base64.StdEncoding.Encode(dest[:], ip6[:])
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
}
ipccUrlBuilder, err := url.Parse(srv.cfg.ipccHost)
if err != nil {
log.Errorf("ipcc misconfigured bad url %s", err)
return c.JSON(500, IPCCResponse{})
}
ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup"
ipccUrl := ipccUrlBuilder.String()
cl := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
postBodyReader := bytes.NewReader(request)
response, err := cl.Post(ipccUrl, "application/json", postBodyReader)
if err != nil {
log.Warnf("ipcc backend error %s", err)
return c.JSON(500, IPCCResponse{})
}
defer response.Body.Close()
dec := json.NewDecoder(response.Body)
var outResponse IPCCResponse
err = dec.Decode(&outResponse)
if err != nil {
log.Warnf("ipcc bad response %s", err)
return c.JSON(500, IPCCResponse{})
}
return c.JSON(200, outResponse)
}
-1
View File
@@ -259,7 +259,6 @@
pointer-events: none !important;
}
</style>
</style>
{% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
+1
View File
@@ -14,6 +14,7 @@ module.exports = {
'ja',
'ko',
'pt-BR',
'ru',
'tr',
'uk',
'zh-CN',
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.91.0",
"version": "1.91.1",
"private": true,
"engines": {
"node": ">=18"
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "0.13.5",
"@atproto/api": "^0.13.7",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -68,6 +68,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@haileyok/bluesky-video": "0.1.8",
"@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.7.1",
"@miblanchard/react-native-slider": "^2.3.1",
@@ -139,7 +140,6 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
"expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
+54 -51
View File
@@ -52,16 +52,17 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext'
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
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 {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
SplashScreen.preventAutoHideAsync()
@@ -105,52 +106,51 @@ function InnerApp() {
}, [_])
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<ActiveVideoProvider>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</ActiveVideoProvider>
</Splash>
</ThemeProvider>
</Alf>
</SafeAreaProvider>
<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}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
<NuxDialogs />
</GestureHandlerRootView>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</VideoVolumeProvider>
</RootSiblingParent>
</Splash>
</ThemeProvider>
</Alf>
)
}
@@ -158,8 +158,6 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
PlatformInfo.setAudioActive(false)
initPersistedState().then(() => setReady(true))
}, [])
@@ -184,7 +182,12 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</SafeAreaProvider>
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
+47 -36
View File
@@ -35,18 +35,22 @@ import {
} from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
import * as Toast from '#/view/com/util/Toast'
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 {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -59,6 +63,8 @@ function InnerApp() {
useIntentHandler()
const hasCheckedReferrer = useStarterPackEntry()
useComposerKeyboardShortcut()
// init
useEffect(() => {
async function onLaunch(account?: SessionAccount) {
@@ -93,41 +99,44 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<RootSiblingParent>
<ActiveVideoProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
</ActiveVideoProvider>
<VideoVolumeProvider>
<ActiveVideoProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<Shell />
<NuxDialogs />
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
</ActiveVideoProvider>
</VideoVolumeProvider>
</RootSiblingParent>
</ThemeProvider>
</Alf>
@@ -162,7 +171,9 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
+4 -5
View File
@@ -661,16 +661,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING}
theme={theme}
onStateChange={() => {
logEvent('router:navigate:sampled', {
from: prevLoggedRouteName.current,
})
prevLoggedRouteName.current = getCurrentRouteName()
const routeName = getCurrentRouteName()
if (routeName === 'Notifications') {
logEvent('router:navigate:notifications:sampled', {})
}
}}
onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime()
onReady()
logEvent('router:navigate:sampled', {})
}}>
{children}
</NavigationContainer>
+15 -3
View File
@@ -18,9 +18,17 @@ export * from '#/alf/util/themeSelector'
export const Context = React.createContext<{
themeName: ThemeName
theme: Theme
themes: ReturnType<typeof createThemes>
}>({
themeName: 'light',
theme: defaultTheme,
themes: createThemes({
hues: {
primary: BLUE_HUE,
negative: RED_HUE,
positive: GREEN_HUE,
},
}),
})
export function ThemeProvider({
@@ -42,18 +50,22 @@ export function ThemeProvider({
<Context.Provider
value={React.useMemo(
() => ({
themes,
themeName: themeName,
theme: theme,
}),
[theme, themeName],
[theme, themeName, themes],
)}>
{children}
</Context.Provider>
)
}
export function useTheme() {
return React.useContext(Context).theme
export function useTheme(theme?: ThemeName) {
const ctx = React.useContext(Context)
return React.useMemo(() => {
return theme ? ctx.themes[theme] : ctx.theme
}, [theme, ctx])
}
export function useBreakpoints() {
+1 -1
View File
@@ -256,7 +256,7 @@ export const ScrollableInner = React.forwardRef<
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
flatten(style),
style,
]}
contentContainerStyle={a.pb_4xl}
ref={ref}>
+32 -34
View File
@@ -8,7 +8,6 @@ import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -61,16 +60,13 @@ function CardOuter({
export function SuggestedFollowPlaceholder() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<CardOuter style={[a.gap_md, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder />
<ProfileCard.NameAndHandlePlaceholder />
</ProfileCard.Header>
<View style={[a.py_xs]}>
<ProfileCard.NameAndHandlePlaceholder />
</View>
<ProfileCard.DescriptionPlaceholder />
<ProfileCard.DescriptionPlaceholder numberOfLines={2} />
</CardOuter>
)
}
@@ -177,13 +173,13 @@ function useExperimentalSuggestedUsersQuery() {
}
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
const gate = useGate()
const [feedType, feedUri] = feed.split('|')
const {currentAccount} = useSession()
const [feedType, feedUriOrDid] = feed.split('|')
if (feedType === 'author') {
if (gate('show_follow_suggestions_in_profile')) {
return <SuggestedFollowsProfile did={feedUri} />
} else {
if (currentAccount?.did === feedUriOrDid) {
return null
} else {
return <SuggestedFollowsProfile did={feedUriOrDid} />
}
} else {
return <SuggestedFollowsHome />
@@ -203,6 +199,7 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isSuggestionsLoading={isSuggestionsLoading}
profiles={data?.suggestions ?? []}
error={error}
viewContext="profile"
/>
)
}
@@ -218,6 +215,7 @@ export function SuggestedFollowsHome() {
isSuggestionsLoading={isSuggestionsLoading}
profiles={profiles}
error={error}
viewContext="feed"
/>
)
}
@@ -226,10 +224,12 @@ export function ProfileGrid({
isSuggestionsLoading,
error,
profiles,
viewContext = 'feed',
}: {
isSuggestionsLoading: boolean
profiles: AppBskyActorDefs.ProfileViewDetailed[]
error: Error | null
viewContext: 'profile' | 'feed'
}) {
const t = useTheme()
const {_} = useLingui()
@@ -286,7 +286,7 @@ export function ProfileGrid({
shape="round"
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} />
<ProfileCard.Description profile={profile} numberOfLines={2} />
</ProfileCard.Outer>
</CardOuter>
)}
@@ -303,33 +303,31 @@ export function ProfileGrid({
return (
<View
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
<Text
style={[
a.flex_1,
a.text_lg,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Suggested for you</Trans>
<View
style={[
a.p_lg,
a.pb_xs,
a.flex_row,
a.align_center,
a.justify_between,
]}>
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
{viewContext === 'profile' ? (
<Trans>Similar accounts</Trans>
) : (
<Trans>Suggested for you</Trans>
)}
</Text>
<Person fill={t.atoms.text_contrast_low.color} />
<Person fill={t.atoms.text_contrast_low.color} size="sm" />
</View>
{gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
<View style={[a.flex_1, a.px_lg, a.pt_sm, a.pb_lg, a.gap_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_sm]}>
{content}
</View>
<View
style={[
a.flex_row,
a.justify_end,
a.align_center,
a.pt_xs,
a.gap_md,
]}>
<View style={[a.flex_row, a.justify_end, a.align_center, a.gap_md]}>
<InlineLinkText
label={_(msg`Browse more suggestions`)}
to="/search"
@@ -345,7 +343,7 @@ export function ProfileGrid({
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
<View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
{content}
<Button
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
import {View} from 'react-native'
import {atoms as a, ViewStyleProp} from '#/alf'
export function Fill({
children,
style,
}: {children?: React.ReactNode} & ViewStyleProp) {
return <View style={[a.absolute, a.inset_0, style]}>{children}</View>
}
+45
View File
@@ -0,0 +1,45 @@
import React from 'react'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import {Fill} from '#/components/Fill'
/**
* Applies and thin border within a bounding box. Used to contrast media from
* bg of the container.
*/
export function MediaInsetBorder({
children,
style,
opaque,
}: {
children?: React.ReactNode
/**
* Used where this border needs to match adjacent borders, such as in
* external link previews
*/
opaque?: boolean
} & ViewStyleProp) {
const t = useTheme()
const isLight = t.name === 'light'
return (
<Fill
style={[
a.rounded_sm,
a.border,
opaque
? [t.atoms.border_contrast_low]
: [
isLight
? t.atoms.border_contrast_low
: t.atoms.border_contrast_high,
{opacity: 0.6},
],
{
pointerEvents: 'none',
},
style,
]}>
{children}
</Fill>
)
}
+11 -8
View File
@@ -10,7 +10,8 @@ import {
import {Trans} from '@lingui/macro'
import {parseTenorGif} from '#/lib/strings/embed-player'
import {atoms as a} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -43,10 +44,10 @@ export function Embed({
))}
</Outer>
)
} else if (AppBskyEmbedExternal.isView(embed) && embed.external.thumb) {
} else if (AppBskyEmbedExternal.isView(media) && media.external.thumb) {
let url: URL | undefined
try {
url = new URL(embed.external.uri)
url = new URL(media.external.uri)
} catch {}
if (url) {
const {success} = parseTenorGif(url)
@@ -54,17 +55,17 @@ export function Embed({
return (
<Outer style={style}>
<GifItem
thumbnail={embed.external.thumb}
alt={embed.external.title}
thumbnail={media.external.thumb}
alt={media.external.title}
/>
</Outer>
)
}
}
} else if (AppBskyEmbedVideo.isView(embed)) {
} else if (AppBskyEmbedVideo.isView(media)) {
return (
<Outer style={style}>
<VideoItem thumbnail={embed.thumbnail} alt={embed.alt} />
<VideoItem thumbnail={media.thumbnail} alt={media.alt} />
</Outer>
)
}
@@ -91,18 +92,20 @@ export function ImageItem({
alt?: string
children?: React.ReactNode
}) {
const t = useTheme()
return (
<View style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
<Image
key={thumbnail}
source={{uri: thumbnail}}
style={[a.flex_1, a.rounded_xs]}
style={[a.flex_1, a.rounded_xs, t.atoms.bg_contrast_25]}
contentFit="cover"
accessible={true}
accessibilityIgnoresInvertColors
accessibilityHint={alt}
accessibilityLabel=""
/>
<MediaInsetBorder style={[a.rounded_xs]} />
{children}
</View>
)
+24 -19
View File
@@ -220,8 +220,10 @@ export function NameAndHandlePlaceholder() {
export function Description({
profile: profileUnshadowed,
numberOfLines = 3,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
numberOfLines?: number
}) {
const profile = useProfileShadow(profileUnshadowed)
const {description} = profile
@@ -244,31 +246,34 @@ export function Description({
<RichText
value={rt}
style={[a.leading_snug]}
numberOfLines={3}
numberOfLines={numberOfLines}
disableLinks
/>
</View>
)
}
export function DescriptionPlaceholder() {
export function DescriptionPlaceholder({
numberOfLines = 3,
}: {
numberOfLines?: number
}) {
const t = useTheme()
return (
<View style={[a.gap_xs]}>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_50,
{height: 12, width: 100},
]}
/>
<View style={[{gap: 8}]}>
{Array(numberOfLines)
.fill(0)
.map((_, i) => (
<View
key={i}
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_50,
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
]}
/>
))}
</View>
)
}
@@ -276,8 +281,8 @@ export function DescriptionPlaceholder() {
export type FollowButtonProps = {
profile: AppBskyActorDefs.ProfileViewBasic
moderationOpts: ModerationOpts
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
logContext: LogEvents['profile:follow:sampled']['logContext'] &
LogEvents['profile:unfollow:sampled']['logContext']
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
@@ -18,7 +18,7 @@ interface ProfilesListProps {
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
const feed: FeedDescriptor = `list|${listUri}|as_following`
const feed: FeedDescriptor = `list|${listUri}`
const {_} = useLingui()
const onScrollToTop = useCallback(() => {
@@ -40,7 +40,7 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
ref,
) {
const t = useTheme()
const bottomBarOffset = useBottomBarOffset(200)
const bottomBarOffset = useBottomBarOffset(300)
const initialNumToRender = useInitialNumToRender()
const {currentAccount} = useSession()
const {data, refetch, isError} = useAllListMembersQuery(listUri)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,655 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {FadeIn} from 'react-native-reanimated'
import ViewShot from 'react-native-view-shot'
import {Image} from 'expo-image'
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
import * as MediaLibrary from 'expo-media-library'
import {moderateProfile} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {networkRetry} from '#/lib/async/retry'
import {getCanvas} from '#/lib/canvas'
import {shareUrl} from '#/lib/sharing'
import {logEvent} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isIOS, isNative} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from 'state/shell'
import {formatCount} from '#/view/com/util/numeric/format'
import {Logomark} from '#/view/icons/Logomark'
import * as Toast from 'view/com/util/Toast'
import {
atoms as a,
ThemeProvider,
tokens,
useBreakpoints,
useTheme,
} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {OnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/OnePercent'
import {PointOnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/PointOnePercent'
import {TenPercent} from '#/components/dialogs/nuxs/TenMillion/icons/TenPercent'
import {Divider} from '#/components/Divider'
import {GradientFill} from '#/components/GradientFill'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {Download_Stroke2_Corner0_Rounded as Download} from '#/components/icons/Download'
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
const DEBUG = false
const RATIO = 8 / 10
const WIDTH = 2000
const HEIGHT = WIDTH * RATIO
function getFontSize(count: number) {
const length = count.toString().length
if (length < 7) {
return 80
} else if (length < 5) {
return 100
} else {
return 70
}
}
function getPercentBadge(percent: number) {
if (percent <= 0.001) {
return PointOnePercent
} else if (percent <= 0.01) {
return OnePercent
} else if (percent <= 0.1) {
return TenPercent
}
return null
}
function Frame({children}: {children: React.ReactNode}) {
return (
<View
style={[
a.relative,
a.w_full,
a.overflow_hidden,
{
paddingTop: '80%',
},
]}>
{children}
</View>
)
}
export function TenMillion() {
const agent = useAgent()
const nuxDialogs = useNuxDialogContext()
const [userNumber, setUserNumber] = React.useState<number>(0)
const fetching = React.useRef(false)
React.useEffect(() => {
async function fetchUserNumber() {
const isBlueskyHosted = agent.sessionManager.pdsUrl
?.toString()
.includes('bsky.network')
if (isBlueskyHosted && agent.session?.accessJwt) {
const res = await fetch(
`https://bsky.social/xrpc/com.atproto.temp.getSignupNumber`,
{
headers: {
Authorization: `Bearer ${agent.session.accessJwt}`,
},
},
)
if (!res.ok) {
throw new Error('Network request failed')
}
const data = await res.json()
if (data.number) {
setUserNumber(data.number)
} else {
// should be rare
nuxDialogs.dismissActiveNux()
}
}
}
if (!fetching.current) {
fetching.current = true
networkRetry(3, fetchUserNumber).catch(() => {
nuxDialogs.dismissActiveNux()
})
}
}, [
agent.sessionManager.pdsUrl,
agent.session?.accessJwt,
setUserNumber,
nuxDialogs.dismissActiveNux,
nuxDialogs,
])
return userNumber ? <TenMillionInner userNumber={userNumber} /> : null
}
export function TenMillionInner({userNumber}: {userNumber: number}) {
const t = useTheme()
const lightTheme = useTheme('light')
const {_, i18n} = useLingui()
const control = Dialog.useDialogControl()
const {gtMobile} = useBreakpoints()
const {openComposer} = useComposerControls()
const {currentAccount} = useSession()
const {
isLoading: isProfileLoading,
data: profile,
error: profileError,
} = useProfileQuery({
did: currentAccount!.did,
})
const moderationOpts = useModerationOpts()
const nuxDialogs = useNuxDialogContext()
const moderation = React.useMemo(() => {
return profile && moderationOpts
? moderateProfile(profile, moderationOpts)
: undefined
}, [profile, moderationOpts])
const [uri, setUri] = React.useState<string | null>(null)
const percent = userNumber / 10_000_000
const Badge = getPercentBadge(percent)
const isLoadingData = isProfileLoading || !moderation || !profile
const isLoadingImage = !uri
const error: string = React.useMemo(() => {
if (profileError) {
return _(
msg`Oh no! We weren't able to generate an image for you to share. Rest assured, we're glad you're here 🦋`,
)
}
return ''
}, [_, profileError])
/*
* Opening and closing
*/
React.useEffect(() => {
const timeout = setTimeout(() => {
control.open()
}, 3e3)
return () => {
clearTimeout(timeout)
}
}, [control])
const onClose = React.useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
/*
* Actions
*/
const sharePost = React.useCallback(() => {
if (uri) {
control.close(() => {
setTimeout(() => {
logEvent('tmd:post', {})
openComposer({
text: _(
msg`Bluesky now has over 10 million users, and I was #${i18n.number(
userNumber,
)}!`,
), // TODO
imageUris: [
{
uri,
width: WIDTH,
height: HEIGHT,
},
],
})
}, 1e3)
})
}
}, [_, i18n, control, openComposer, uri, userNumber])
const onNativeShare = React.useCallback(() => {
if (uri) {
control.close(() => {
logEvent('tmd:share', {})
shareUrl(uri)
})
}
}, [uri, control])
const onNativeDownload = React.useCallback(async () => {
if (uri) {
const res = await requestMediaLibraryPermissionsAsync()
if (!res) {
Toast.show(
_(
msg`You must grant access to your photo library to save the image.`,
),
'xmark',
)
return
}
try {
await MediaLibrary.createAssetAsync(uri)
logEvent('tmd:download', {})
Toast.show(_(msg`Image saved to your camera roll!`))
} catch (e: unknown) {
console.log(e)
Toast.show(_(msg`An error occurred while saving the image!`), 'xmark')
return
}
}
}, [_, uri])
const onWebDownload = React.useCallback(async () => {
if (uri) {
const canvas = await getCanvas(uri)
const imgHref = canvas
.toDataURL('image/png')
.replace('image/png', 'image/octet-stream')
const link = document.createElement('a')
link.setAttribute('download', `Bluesky 10M Users.png`)
link.setAttribute('href', imgHref)
link.click()
logEvent('tmd:download', {})
}
}, [uri])
/*
* Canvas stuff
*/
const imageRef = React.useRef<ViewShot>(null)
const captureInProgress = React.useRef(false)
const onCanvasReady = React.useCallback(async () => {
if (
imageRef.current &&
imageRef.current.capture &&
!captureInProgress.current
) {
captureInProgress.current = true
const uri = await imageRef.current.capture()
setUri(uri)
}
}, [setUri])
const canvas = isLoadingData ? null : (
<View
style={[
a.absolute,
a.overflow_hidden,
DEBUG
? {
width: 600,
height: 600 * RATIO,
}
: {
width: 1,
height: 1,
},
]}>
<View style={{width: 600}}>
<ThemeProvider theme="light">
<Frame>
<ViewShot
ref={imageRef}
options={{width: WIDTH, height: HEIGHT}}
style={[a.absolute, a.inset_0]}>
<View
onLayout={onCanvasReady}
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
{
top: -1,
bottom: -1,
left: -1,
right: -1,
paddingVertical: 48,
paddingHorizontal: 48,
},
]}>
<GradientFill gradient={tokens.gradients.bonfire} />
<View
style={[
a.flex_1,
a.w_full,
a.align_center,
a.justify_center,
a.rounded_md,
{
backgroundColor: 'white',
shadowRadius: 32,
shadowOpacity: 0.1,
elevation: 24,
shadowColor: tokens.gradients.bonfire.values[0][1],
},
]}>
<View
style={[
a.absolute,
a.px_xl,
a.py_xl,
{
top: 0,
left: 0,
},
]}>
<Logomark fill={t.palette.primary_500} width={36} />
</View>
{/* Centered content */}
<View
style={[
{
paddingBottom: isNative ? 0 : 24,
},
]}>
<Text
style={[
a.text_md,
a.font_bold,
a.text_center,
a.pb_sm,
lightTheme.atoms.text_contrast_medium,
]}>
<Trans>
Celebrating {formatCount(i18n, 10000000)} users
</Trans>{' '}
🎉
</Text>
<View style={[a.flex_row, a.align_start]}>
<Text
style={[
a.absolute,
{
color: t.palette.primary_500,
fontSize: 32,
fontWeight: '900',
width: 32,
top: isNative ? -10 : 0,
left: 0,
transform: [
{
translateX: -16,
},
],
},
]}>
#
</Text>
<Text
style={[
a.relative,
a.text_center,
{
fontStyle: 'italic',
fontSize: getFontSize(userNumber),
lineHeight: getFontSize(userNumber),
fontWeight: '900',
letterSpacing: -2,
},
]}>
{i18n.number(userNumber)}
</Text>
</View>
{Badge && (
<View
style={[
a.absolute,
{
width: 64,
height: 64,
top: isNative ? 75 : 85,
right: '5%',
transform: [
{
rotate: '8deg',
},
],
},
]}>
<Badge fill={t.palette.primary_500} />
</View>
)}
</View>
{/* End centered content */}
<View
style={[
a.absolute,
a.px_xl,
a.py_xl,
{
bottom: 0,
left: 0,
right: 0,
},
]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
{/*
<UserAvatar
size={36}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
onLoad={onCanvasReady}
/>
*/}
<View style={[a.gap_2xs, a.flex_1]}>
<Text
style={[
a.flex_1,
a.text_sm,
a.font_bold,
a.leading_tight,
{maxWidth: '60%'},
]}>
{sanitizeDisplayName(
profile.displayName ||
sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
<View
style={[a.flex_row, a.justify_between, a.gap_4xl]}>
<Text
numberOfLines={1}
style={[
a.flex_1,
a.text_sm,
a.font_semibold,
a.leading_snug,
lightTheme.atoms.text_contrast_medium,
]}>
{sanitizeHandle(profile.handle, '@')}
</Text>
{profile.createdAt && (
<Text
numberOfLines={1}
ellipsizeMode="head"
style={[
a.flex_1,
a.text_sm,
a.font_semibold,
a.leading_snug,
a.text_right,
lightTheme.atoms.text_contrast_low,
]}>
<Trans>
Joined{' '}
{i18n.date(profile.createdAt, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</Trans>
</Text>
)}
</View>
</View>
</View>
</View>
</View>
</View>
</ViewShot>
</Frame>
</ThemeProvider>
</View>
</View>
)
return (
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.ScrollableInner
label={_(msg`Ten Million`)}
style={[
{
padding: 0,
paddingTop: 0,
},
]}>
<View
style={[
a.rounded_md,
a.overflow_hidden,
isNative && {
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
]}>
<Frame>
<View
style={[a.absolute, a.inset_0, a.align_center, a.justify_center]}>
<GradientFill gradient={tokens.gradients.bonfire} />
{error ? (
<View>
<Text
style={[
a.text_md,
a.leading_snug,
a.text_center,
a.pb_md,
{
maxWidth: 300,
},
]}>
(°°)
</Text>
<Text
style={[
a.text_xl,
a.font_bold,
a.leading_snug,
a.text_center,
{
maxWidth: 300,
},
]}>
{error}
</Text>
</View>
) : isLoadingData || isLoadingImage ? (
<Loader size="xl" fill="white" />
) : (
<Animated.View
entering={FadeIn.duration(150)}
style={[a.w_full, a.h_full]}>
<Image
accessibilityIgnoresInvertColors
source={{uri}}
style={[a.w_full, a.h_full]}
/>
</Animated.View>
)}
</View>
</Frame>
{canvas}
<View style={[gtMobile ? a.p_2xl : a.p_xl]}>
<Text
style={[
a.text_5xl,
a.leading_tight,
a.pb_lg,
{
fontWeight: '900',
},
]}>
<Trans>Thanks for being one of our first 10 million users.</Trans>
</Text>
<Text style={[a.leading_snug, a.text_lg, a.pb_xl]}>
<Trans>
Together, we're rebuilding the social internet. We're glad
you're here.
</Trans>
</Text>
<Divider />
<View
style={[
a.flex_row,
a.align_center,
a.justify_end,
a.gap_md,
a.pt_xl,
]}>
<Text style={[a.text_md, a.italic, t.atoms.text_contrast_medium]}>
<Trans>Brag a little!</Trans>
</Text>
<Button
disabled={isLoadingImage}
label={
isNative && isIOS
? _(msg`Share image externally`)
: _(msg`Download image`)
}
size="large"
variant="solid"
color="secondary"
shape="square"
onPress={
isNative
? isIOS
? onNativeShare
: onNativeDownload
: onWebDownload
}>
<ButtonIcon icon={isNative && isIOS ? Share : Download} />
</Button>
<Button
disabled={isLoadingImage}
label={_(msg`Share image in post`)}
size="large"
variant="solid"
color="primary"
onPress={sharePost}>
<ButtonText>{_(msg`Share`)}</ButtonText>
<ButtonIcon position="right" icon={ImageIcon} />
</Button>
</View>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+151
View File
@@ -0,0 +1,151 @@
import React from 'react'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {
Nux,
useNuxs,
useRemoveNuxsMutation,
useUpsertNuxMutation,
} from '#/state/queries/nuxs'
import {useSession} from '#/state/session'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {TenMillion} from '#/components/dialogs/nuxs/TenMillion'
import {IS_DEV} from '#/env'
type Context = {
activeNux: Nux | undefined
dismissActiveNux: () => void
}
/**
* If we fail to complete a NUX here, it may show again on next reload,
* or if prefs state updates. If `true`, this fallback ensures that the last
* shown NUX won't show again, at least for this session.
*
* This is temporary, and only needed for the 10Milly dialog rn, since we
* aren't snoozing that one in device storage.
*/
let __isSnoozedFallback = false
const queuedNuxs: {
id: Nux
enabled(props: {gate: ReturnType<typeof useGate>}): boolean
/**
* TEMP only intended for use with the 10Milly dialog rn, since there are no
* other NUX dialogs configured
*/
unsafe_disableSnooze: boolean
}[] = [
{
id: Nux.TenMillionDialog,
enabled({gate}) {
return gate('ten_million_dialog')
},
unsafe_disableSnooze: true,
},
]
const Context = React.createContext<Context>({
activeNux: undefined,
dismissActiveNux: () => {},
})
export function useNuxDialogContext() {
return React.useContext(Context)
}
export function NuxDialogs() {
const {hasSession} = useSession()
return hasSession ? <Inner /> : null
}
function Inner() {
const gate = useGate()
const {nuxs} = useNuxs()
const [snoozed, setSnoozed] = React.useState(() => {
return isSnoozed()
})
const [activeNux, setActiveNux] = React.useState<Nux | undefined>()
const {mutateAsync: upsertNux} = useUpsertNuxMutation()
const {mutate: removeNuxs} = useRemoveNuxsMutation()
const snoozeNuxDialog = React.useCallback(() => {
snooze()
setSnoozed(true)
}, [setSnoozed])
const dismissActiveNux = React.useCallback(() => {
if (!activeNux) return
setActiveNux(undefined)
}, [activeNux, setActiveNux])
if (IS_DEV && typeof window !== 'undefined') {
// @ts-ignore
window.clearNuxDialog = (id: Nux) => {
if (!IS_DEV || !id) return
removeNuxs([id])
unsnooze()
}
}
React.useEffect(() => {
if (__isSnoozedFallback) return
if (snoozed) return
if (!nuxs) return
for (const {id, enabled, unsafe_disableSnooze} of queuedNuxs) {
const nux = nuxs.find(nux => nux.id === id)
// check if completed first
if (nux && nux.completed) continue
// then check gate (track exposure)
if (!enabled({gate})) continue
// we have a winner
setActiveNux(id)
/**
* TEMP only intended for use with the 10Milly dialog rn, since there are no
* other NUX dialogs configured
*/
if (!unsafe_disableSnooze) {
// immediately snooze for a day
snoozeNuxDialog()
}
// immediately update remote data (affects next reload)
upsertNux({
id,
completed: true,
data: undefined,
}).catch(e => {
logger.error(`NUX dialogs: failed to upsert '${id}' NUX`, {
safeMessage: e.message,
})
/*
* TEMP only intended for use with the 10Milly dialog rn
*/
if (unsafe_disableSnooze) {
__isSnoozedFallback = true
}
})
break
}
}, [nuxs, snoozed, snoozeNuxDialog, upsertNux, gate])
const ctx = React.useMemo(() => {
return {
activeNux,
dismissActiveNux,
}
}, [activeNux, dismissActiveNux])
return (
<Context.Provider value={ctx}>
{activeNux === Nux.TenMillionDialog && <TenMillion />}
</Context.Provider>
)
}
+22
View File
@@ -0,0 +1,22 @@
import {simpleAreDatesEqual} from '#/lib/strings/time'
import {device} from '#/storage'
export function snooze() {
device.set(['lastNuxDialog'], new Date().toISOString())
}
export function unsnooze() {
device.set(['lastNuxDialog'], undefined)
}
export function isSnoozed() {
const lastNuxDialog = device.get(['lastNuxDialog'])
if (!lastNuxDialog) return false
const last = new Date(lastNuxDialog)
const now = new Date()
// already snoozed today
if (simpleAreDatesEqual(last, now)) {
return true
}
return false
}
+17
View File
@@ -0,0 +1,17 @@
import React from 'react'
const MessageContext = React.createContext(false)
export function MessageContextProvider({
children,
}: {
children: React.ReactNode
}) {
return (
<MessageContext.Provider value={true}>{children}</MessageContext.Provider>
)
}
export function useIsWithinMessage() {
return React.useContext(MessageContext)
}
+10 -7
View File
@@ -4,6 +4,7 @@ import {AppBskyEmbedRecord} from '@atproto/api'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, useTheme} from '#/alf'
import {MessageContextProvider} from './MessageContext'
let MessageItemEmbed = ({
embed,
@@ -13,13 +14,15 @@ let MessageItemEmbed = ({
const t = useTheme()
return (
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
<MessageContextProvider>
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
</MessageContextProvider>
)
}
MessageItemEmbed = React.memo(MessageItemEmbed)
+2
View File
@@ -21,6 +21,7 @@ import {
ja,
ko,
ptBR,
ru,
tr,
uk,
zhCN,
@@ -47,6 +48,7 @@ const locales: Record<AppLanguage, Locale | undefined> = {
ja,
ko,
['pt-BR']: ptBR,
ru,
tr,
uk,
['zh-CN']: zhCN,
+2 -2
View File
@@ -15,8 +15,8 @@ export function useFollowMethods({
logContext,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
logContext: LogEvents['profile:follow:sampled']['logContext'] &
LogEvents['profile:unfollow:sampled']['logContext']
}) {
const {_} = useLingui()
const requireAuth = useRequireAuth()
+53
View File
@@ -0,0 +1,53 @@
import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import {isFirefox, isSafari} from '#/lib/browser'
import {isWeb} from '#/platform/detection'
function fullscreenSubscribe(onChange: () => void) {
document.addEventListener('fullscreenchange', onChange)
return () => document.removeEventListener('fullscreenchange', onChange)
}
export function useFullscreen(ref?: React.RefObject<HTMLElement>) {
if (!isWeb) throw new Error("'useFullscreen' is a web-only hook")
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
Boolean(document.fullscreenElement),
)
const scrollYRef = useRef<null | number>(null)
const [prevIsFullscreen, setPrevIsFullscreen] = useState(isFullscreen)
const toggleFullscreen = useCallback(() => {
if (isFullscreen) {
document.exitFullscreen()
} else {
if (!ref) throw new Error('No ref provided')
if (!ref.current) return
scrollYRef.current = window.scrollY
ref.current.requestFullscreen()
}
}, [isFullscreen, ref])
useEffect(() => {
if (prevIsFullscreen === isFullscreen) return
setPrevIsFullscreen(isFullscreen)
// Chrome has an issue where it doesn't scroll back to the top after exiting fullscreen
// Let's play it safe and do it if not FF or Safari, since anything else will probably be chromium
if (prevIsFullscreen && !isFirefox && !isSafari) {
setTimeout(() => {
if (scrollYRef.current !== null) {
window.scrollTo(0, scrollYRef.current)
scrollYRef.current = null
}
}, 100)
}
}, [isFullscreen, prevIsFullscreen])
return [isFullscreen, toggleFullscreen] as const
}
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Download_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 3a1 1 0 0 1 1 1v8.086l1.793-1.793a1 1 0 1 1 1.414 1.414l-3.5 3.5a1 1 0 0 1-1.414 0l-3.5-3.5a1 1 0 1 1 1.414-1.414L11 12.086V4a1 1 0 0 1 1-1ZM4 14a1 1 0 0 1 1 1v4h14v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1Z',
})
+37
View File
@@ -0,0 +1,37 @@
import React from 'react'
import * as Dialog from '#/components/Dialog'
import {DialogControlProps} from '#/components/Dialog'
import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
interface Context {
verifyEmailDialogControl: DialogControlProps
verifyEmailState: {code: string} | undefined
setVerifyEmailState: (state: {code: string} | undefined) => void
}
const Context = React.createContext({} as Context)
export const useIntentDialogs = () => React.useContext(Context)
export function Provider({children}: {children: React.ReactNode}) {
const verifyEmailDialogControl = Dialog.useDialogControl()
const [verifyEmailState, setVerifyEmailState] = React.useState<
{code: string} | undefined
>()
const value = React.useMemo(
() => ({
verifyEmailDialogControl,
verifyEmailState,
setVerifyEmailState,
}),
[verifyEmailDialogControl, verifyEmailState, setVerifyEmailState],
)
return (
<Context.Provider value={value}>
{children}
<VerifyEmailIntentDialog />
</Context.Provider>
)
}
@@ -0,0 +1,140 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgent, useSession} from 'state/session'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {DialogControlProps} from '#/components/Dialog'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function VerifyEmailIntentDialog() {
const {verifyEmailDialogControl: control} = useIntentDialogs()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Inner control={control} />
</Dialog.Outer>
)
}
function Inner({control}: {control: DialogControlProps}) {
const {_} = useLingui()
const {verifyEmailState: state} = useIntentDialogs()
const [status, setStatus] = React.useState<
'loading' | 'success' | 'failure' | 'resent'
>('loading')
const [sending, setSending] = React.useState(false)
const agent = useAgent()
const {currentAccount} = useSession()
React.useEffect(() => {
;(async () => {
if (!state?.code) {
return
}
try {
await agent.com.atproto.server.confirmEmail({
email: (currentAccount?.email || '').trim(),
token: state.code.trim(),
})
setStatus('success')
} catch (e) {
setStatus('failure')
}
})()
}, [agent.com.atproto.server, currentAccount?.email, state?.code])
const onPressResendEmail = async () => {
setSending(true)
await agent.com.atproto.server.requestEmailConfirmation()
setSending(false)
setStatus('resent')
}
return (
<Dialog.ScrollableInner label={_(msg`Verify email dialog`)}>
<Dialog.Close />
<View style={[a.gap_xl]}>
{status === 'loading' ? (
<View style={[a.py_2xl, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
) : status === 'success' ? (
<>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>Email Verified</Trans>
</Text>
<Text style={[a.text_md, a.leading_tight]}>
<Trans>
Thanks, you have successfully verified your email address.
</Trans>
</Text>
</>
) : status === 'failure' ? (
<>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>Invalid Verification Code</Trans>
</Text>
<Text style={[a.text_md, a.leading_tight]}>
<Trans>
The verification code you have provided is invalid. Please make
sure that you have used the correct verification link or request
a new one.
</Trans>
</Text>
</>
) : (
<>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>Email Resent</Trans>
</Text>
<Text style={[a.text_md, a.leading_tight]}>
<Trans>
We have sent another verification email to{' '}
<Text style={[a.text_md, a.font_bold]}>
{currentAccount?.email}
</Text>
.
</Trans>
</Text>
</>
)}
{status !== 'loading' ? (
<View style={[a.w_full, a.flex_row, a.gap_sm, {marginLeft: 'auto'}]}>
<Button
label={_(msg`Close`)}
onPress={() => control.close()}
variant="solid"
color={status === 'failure' ? 'secondary' : 'primary'}
size="medium"
style={{marginLeft: 'auto'}}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
{status === 'failure' ? (
<Button
label={_(msg`Resend Verification Email`)}
onPress={onPressResendEmail}
variant="solid"
color="primary"
size="medium"
disabled={sending}>
<ButtonText>
<Trans>Resend Email</Trans>
</ButtonText>
{sending ? <Loader size="sm" style={{color: 'white'}} /> : null}
</Button>
) : null}
</View>
) : null}
</View>
</Dialog.ScrollableInner>
)
}
+29 -6
View File
@@ -2,24 +2,47 @@ import React from 'react'
import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
export function PlayButtonIcon({size = 44}: {size?: number}) {
export function PlayButtonIcon({size = 32}: {size?: number}) {
const t = useTheme()
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
return (
<View
style={[
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
t.atoms.shadow_lg,
{
backgroundColor: t.palette.primary_500,
width: size + 16,
height: size + 16,
width: size + size / 1.5,
height: size + size / 1.5,
},
]}>
<PlayIcon height={size} width={size} style={{color: 'white'}} />
<View
style={[
a.absolute,
a.inset_0,
{
backgroundColor: bg,
opacity: 0.7,
},
]}
/>
<PlayIcon
width={size}
fill={fg}
style={[
a.relative,
a.z_10,
{
left: size / 50,
},
]}
/>
</View>
)
}
+6 -21
View File
@@ -379,7 +379,11 @@ export class FeedTuner {
): FeedViewPostsSlice[] => {
for (let i = 0; i < slices.length; i++) {
const slice = slices[i]
if (slice.isReply && !shouldDisplayReplyInFollowing(slice, userDid)) {
if (
slice.isReply &&
!slice.isRepost &&
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
) {
slices.splice(i, 1)
i--
}
@@ -443,13 +447,9 @@ function areSameAuthor(authors: AuthorContext): boolean {
}
function shouldDisplayReplyInFollowing(
slice: FeedViewPostsSlice,
authors: AuthorContext,
userDid: string,
): boolean {
if (slice.isRepost) {
return true
}
const authors = slice.getAuthors()
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
if (!isSelfOrFollowing(author, userDid)) {
// Only show replies from self or people you follow.
@@ -463,21 +463,6 @@ function shouldDisplayReplyInFollowing(
// Always show self-threads.
return true
}
if (
parentAuthor &&
parentAuthor.did !== author.did &&
rootAuthor &&
rootAuthor.did === author.did &&
slice.items.length > 2
) {
// If you follow A, show A -> someone[>0 likes] -> A chains too.
// This is different from cases below because you only know one person.
const parentPost = slice.items[1].post
const parentLikeCount = parentPost.likeCount ?? 0
if (parentLikeCount > 0) {
return true
}
}
// From this point on we need at least one more reason to show it.
if (
parentAuthor &&
+3 -2
View File
@@ -2,6 +2,7 @@ import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
BskyAgent,
jsonStringToLex,
} from '@atproto/api'
import {getContentLanguages} from '#/state/preferences/languages'
@@ -111,7 +112,7 @@ async function loggedOutFetch({
}&limit=${limit}&lang=${contentLangs}`,
{method: 'GET', headers: {'Accept-Language': contentLangs}},
)
let data = res.ok ? await res.json() : null
let data = res.ok ? jsonStringToLex(await res.text()) : null
if (data?.feed?.length) {
return {
success: true,
@@ -126,7 +127,7 @@ async function loggedOutFetch({
}&limit=${limit}`,
{method: 'GET', headers: {'Accept-Language': ''}},
)
data = res.ok ? await res.json() : null
data = res.ok ? jsonStringToLex(await res.text()) : null
if (data?.feed?.length) {
return {
success: true,
+15
View File
@@ -0,0 +1,15 @@
export const getCanvas = (base64: string): Promise<HTMLCanvasElement> => {
return new Promise(resolve => {
const image = new Image()
image.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const ctx = canvas.getContext('2d')
ctx?.drawImage(image, 0, 0)
resolve(canvas)
}
image.src = base64
})
}
+4 -1
View File
@@ -21,7 +21,7 @@ export const STARTER_PACK_MAX_SIZE = 150
// code and update this number with each release until we can get the
// server route done.
// -prf
export const JOINED_THIS_WEEK = 50676 // as of Aug 17, 2024
export const JOINED_THIS_WEEK = 3060000 // estimate as of 9/6/24
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({
@@ -137,6 +137,9 @@ export const GIF_FEATURED = (params: string) =>
export const MAX_LABELERS = 20
export const VIDEO_SERVICE = 'https://video.bsky.app'
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
export const SUPPORTED_MIME_TYPES = [
'video/mp4',
'video/mpeg',
+15 -12
View File
@@ -1,17 +1,20 @@
import React from 'react'
export const useDedupe = () => {
export const useDedupe = (timeout = 250) => {
const canDo = React.useRef(true)
return React.useCallback((cb: () => unknown) => {
if (canDo.current) {
canDo.current = false
setTimeout(() => {
canDo.current = true
}, 250)
cb()
return true
}
return false
}, [])
return React.useCallback(
(cb: () => unknown) => {
if (canDo.current) {
canDo.current = false
setTimeout(() => {
canDo.current = true
}, timeout)
cb()
return true
}
return false
},
[timeout],
)
}
+37 -3
View File
@@ -6,15 +6,17 @@ import {isNative} from 'platform/detection'
import {useSession} from 'state/session'
import {useComposerControls} from 'state/shell'
import {useCloseAllActiveElements} from 'state/util'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
type IntentType = 'compose'
type IntentType = 'compose' | 'verify-email'
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
export function useIntentHandler() {
const incomingUrl = Linking.useURL()
const composeIntent = useComposeIntent()
const verifyEmailIntent = useVerifyEmailIntent()
React.useEffect(() => {
const handleIncomingURL = (url: string) => {
@@ -51,15 +53,25 @@ export function useIntentHandler() {
text: params.get('text'),
imageUrisStr: params.get('imageUris'),
})
return
}
case 'verify-email': {
const code = params.get('code')
if (!code) return
verifyEmailIntent(code)
return
}
default: {
return
}
}
}
if (incomingUrl) handleIncomingURL(incomingUrl)
}, [incomingUrl, composeIntent])
}, [incomingUrl, composeIntent, verifyEmailIntent])
}
function useComposeIntent() {
export function useComposeIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {openComposer} = useComposerControls()
const {hasSession} = useSession()
@@ -85,6 +97,10 @@ function useComposeIntent() {
if (part.includes('https://') || part.includes('http://')) {
return false
}
console.log({
part,
text: VALID_IMAGE_REGEX.test(part),
})
// We also should just filter out cases that don't have all the info we need
return VALID_IMAGE_REGEX.test(part)
})
@@ -103,3 +119,21 @@ function useComposeIntent() {
[hasSession, closeAllActiveElements, openComposer],
)
}
function useVerifyEmailIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
useIntentDialogs()
return React.useCallback(
(code: string) => {
closeAllActiveElements()
setState({
code,
})
setTimeout(() => {
control.open()
}, 1000)
},
[closeAllActiveElements, control, setState],
)
}
+18 -3
View File
@@ -1,9 +1,14 @@
import {getVideoMetaData, Video} from 'react-native-compressor'
import {ImagePickerAsset} from 'expo-image-picker'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {extToMime} from '#/state/queries/video/util'
import {CompressedVideo} from './types'
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
export async function compressVideo(
file: string,
file: ImagePickerAsset,
opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
@@ -11,12 +16,22 @@ export async function compressVideo(
): Promise<CompressedVideo> {
const {onProgress, signal} = opts || {}
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
file.mimeType as SupportedMimeTypes,
)
const minimumFileSizeForCompress = isAcceptableFormat
? MIN_SIZE_FOR_COMPRESSION
: 0
const compressed = await Video.compress(
file,
file.uri,
{
compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps
maxSize: 1920,
// WARNING: this ONE SPECIFIC ARG is in MB -sfn
minimumFileSizeForCompress,
getCancellationId: id => {
if (signal) {
signal.addEventListener('abort', () => {
@@ -30,5 +45,5 @@ export async function compressVideo(
const info = await getVideoMetaData(compressed)
return {uri: compressed, size: info.size, mimeType: `video/mp4`}
return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)}
}
+6 -4
View File
@@ -1,17 +1,19 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {VideoTooLargeError} from 'lib/media/video/errors'
import {CompressedVideo} from './types'
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
const MAX_VIDEO_SIZE = 1024 * 1024 * 50 // 50mb
// doesn't actually compress, but throws if >100MB
// doesn't actually compress, converts to ArrayBuffer
export async function compressVideo(
file: string,
asset: ImagePickerAsset,
_opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
},
): Promise<CompressedVideo> {
const {mimeType, base64} = parseDataUrl(file)
const {mimeType, base64} = parseDataUrl(asset.uri)
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
+8 -1
View File
@@ -1,6 +1,6 @@
export class VideoTooLargeError extends Error {
constructor() {
super('Videos cannot be larger than 100MB')
super('Videos cannot be larger than 50mb')
this.name = 'VideoTooLargeError'
}
}
@@ -11,3 +11,10 @@ export class ServerError extends Error {
this.name = 'ServerError'
}
}
export class UploadLimitError extends Error {
constructor(message: string) {
super(message)
this.name = 'UploadLimitError'
}
}
+11 -7
View File
@@ -25,7 +25,7 @@ export type LogEvents = {
secondsActive: number
}
'state:foreground:sampled': {}
'router:navigate:sampled': {}
'router:navigate:notifications:sampled': {}
'deepLink:referrerReceived': {
to: string
referrer: string
@@ -127,25 +127,25 @@ export type LogEvents = {
langs: string
logContext: 'Composer'
}
'post:like': {
'post:like:sampled': {
doesLikerFollowPoster: boolean | undefined
doesPosterFollowLiker: boolean | undefined
likerClout: number | undefined
postClout: number | undefined
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:repost': {
'post:repost:sampled': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:unlike': {
'post:unlike:sampled': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:unrepost': {
'post:unrepost:sampled': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:mute': {}
'post:unmute': {}
'profile:follow': {
'profile:follow:sampled': {
didBecomeMutual: boolean | undefined
followeeClout: number | undefined
followerClout: number | undefined
@@ -162,7 +162,7 @@ export type LogEvents = {
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
}
'profile:unfollow': {
'profile:unfollow:sampled': {
logContext:
| 'RecommendedFollowsItem'
| 'PostThreadItem'
@@ -225,4 +225,8 @@ export type LogEvents = {
'test:gate1:sometimes': {}
'test:gate2:always': {}
'test:gate2:sometimes': {}
'tmd:share': {}
'tmd:download': {}
'tmd:post': {}
}
+1 -6
View File
@@ -1,10 +1,5 @@
export type Gate =
// Keep this alphabetic please.
| 'debug_show_feedcontext'
| 'fixed_bottom_bar'
| 'onboarding_minimum_interests'
| 'suggested_feeds_interstitial'
| 'show_follow_suggestions_in_profile'
| 'video_debug' // not recommended
| 'video_upload' // upload videos
| 'video_view_on_posts' // see posted videos
| 'ten_million_dialog'
+14 -3
View File
@@ -89,8 +89,9 @@ export function toClout(n: number | null | undefined): number | undefined {
}
}
const DOWNSAMPLE_RATE = 0.95 // 95% likely
const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
'router:navigate:sampled',
'router:navigate:notifications:sampled',
'state:background:sampled',
'state:foreground:sampled',
'home:feedDisplayed:sampled',
@@ -99,8 +100,14 @@ const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
'discover:clickthrough:sampled',
'discover:engaged:sampled',
'discover:seen:sampled',
'post:like:sampled',
'post:unlike:sampled',
'post:repost:sampled',
'post:unrepost:sampled',
'profile:follow:sampled',
'profile:unfollow:sampled',
])
const isDownsampledSession = Math.random() < 0.9 // 90% likely
const isDownsampledSession = Math.random() < DOWNSAMPLE_RATE
export function logEvent<E extends keyof LogEvents>(
eventName: E & string,
@@ -117,12 +124,16 @@ export function logEvent<E extends keyof LogEvents>(
)
}
if (isDownsampledSession && DOWNSAMPLED_EVENTS.has(eventName)) {
const isDownsampledEvent = DOWNSAMPLED_EVENTS.has(eventName)
if (isDownsampledSession && isDownsampledEvent) {
return
}
const fullMetadata = {
...rawMetadata,
} as Record<string, string> // Statsig typings are unnecessarily strict here.
if (isDownsampledEvent) {
fullMetadata.downsampleRate = DOWNSAMPLE_RATE.toString()
}
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
if (Statsig.initializeCalled()) {
Statsig.logEvent(eventName, null, fullMetadata)
+2
View File
@@ -143,6 +143,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.ko
case 'pt-BR':
return AppLanguage.pt_BR
case 'ru':
return AppLanguage.ru
case 'tr':
return AppLanguage.tr
case 'uk':
+9
View File
@@ -24,6 +24,7 @@ import {messages as messagesIt} from '#/locale/locales/it/messages'
import {messages as messagesJa} from '#/locale/locales/ja/messages'
import {messages as messagesKo} from '#/locale/locales/ko/messages'
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
import {messages as messagesRu} from '#/locale/locales/ru/messages'
import {messages as messagesTr} from '#/locale/locales/tr/messages'
import {messages as messagesUk} from '#/locale/locales/uk/messages'
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
@@ -131,6 +132,14 @@ export async function dynamicActivate(locale: AppLanguage) {
])
break
}
case AppLanguage.ru: {
i18n.loadAndActivate({locale, messages: messagesRu})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ru'),
import('@formatjs/intl-numberformat/locale-data/ru'),
])
break
}
case AppLanguage.tr: {
i18n.loadAndActivate({locale, messages: messagesTr})
await Promise.all([
+4
View File
@@ -60,6 +60,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/pt-BR/messages`)
break
}
case AppLanguage.ru: {
mod = await import(`./locales/ru/messages`)
break
}
case AppLanguage.tr: {
mod = await import(`./locales/tr/messages`)
break
+2
View File
@@ -18,6 +18,7 @@ export enum AppLanguage {
ja = 'ja',
ko = 'ko',
pt_BR = 'pt-BR',
ru = 'ru',
tr = 'tr',
uk = 'uk',
zh_CN = 'zh-CN',
@@ -43,6 +44,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.ja, name: '日本語 Japanese'},
{code2: AppLanguage.ko, name: '한국어 Korean'},
{code2: AppLanguage.pt_BR, name: 'Português (BR) Portuguese (BR)'},
{code2: AppLanguage.ru, name: 'Русский Russian'},
{code2: AppLanguage.tr, name: 'Türkçe Turkish'},
{code2: AppLanguage.uk, name: 'Українська Ukrainian'},
{code2: AppLanguage.zh_CN, name: '简体中文(中国)– Chinese (Simplified)'},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -144,8 +144,7 @@ export const ForgotPasswordForm = ({
variant="solid"
color={'primary'}
size="medium"
onPress={onPressNext}
disabled={!email}>
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
+9 -22
View File
@@ -60,7 +60,6 @@ export const LoginForm = ({
const {track} = useAnalytics()
const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [isReady, setIsReady] = useState<boolean>(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
useState<boolean>(false)
const identifierValueRef = useRef<string>(initialHandle || '')
@@ -83,12 +82,18 @@ export const LoginForm = ({
Keyboard.dismiss()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setError('')
setIsProcessing(true)
const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current
const authFactorToken = authFactorTokenValueRef.current
if (!identifier || !password) {
setError(_(msg`Invalid username or password`))
return
}
setIsProcessing(true)
try {
// try to guess the handle if the user just gave their own username
let fullIdent = identifier
@@ -157,22 +162,6 @@ export const LoginForm = ({
}
}
const checkIsReady = () => {
if (
!!serviceDescription &&
!!identifierValueRef.current &&
!!passwordValueRef.current
) {
if (!isReady) {
setIsReady(true)
}
} else {
if (isReady) {
setIsReady(false)
}
}
}
return (
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
<View>
@@ -204,7 +193,6 @@ export const LoginForm = ({
defaultValue={initialHandle || ''}
onChangeText={v => {
identifierValueRef.current = v
checkIsReady()
}}
onSubmitEditing={() => {
passwordRef.current?.focus()
@@ -233,7 +221,6 @@ export const LoginForm = ({
clearButtonMode="while-editing"
onChangeText={v => {
passwordValueRef.current = v
checkIsReady()
}}
onSubmitEditing={onPressNext}
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
@@ -325,7 +312,7 @@ export const LoginForm = ({
<Trans>Connecting...</Trans>
</Text>
</>
) : isReady ? (
) : (
<Button
testID="loginNextButton"
label={_(msg`Next`)}
@@ -339,7 +326,7 @@ export const LoginForm = ({
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
) : undefined}
)}
</View>
</FormContainer>
)
@@ -23,6 +23,7 @@ import {
useSaveMessageDraft,
} from '#/state/messages/message-drafts'
import {isIOS} from 'platform/detection'
import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {useSharedInputStyles} from '#/components/forms/TextField'
@@ -41,6 +42,7 @@ export function MessageInput({
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) {
const {_} = useLingui()
const t = useTheme()
@@ -12,9 +12,16 @@ import {
} from '#/state/messages/message-drafts'
import {isSafari, isTouchDevice} from 'lib/browser'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
Emoji,
EmojiPickerPosition,
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useSharedInputStyles} from '#/components/forms/TextField'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
@@ -23,11 +30,13 @@ export function MessageInput({
hasEmbed,
setEmbed,
children,
openEmojiPicker,
}: {
onSendMessage: (message: string) => void
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) {
const {isTabletOrDesktop} = useWebMediaQueries()
const {_} = useLingui()
@@ -40,6 +49,7 @@ export function MessageInput({
const [isFocused, setIsFocused] = React.useState(false)
const [isHovered, setIsHovered] = React.useState(false)
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
const textAreaRef = React.useRef<HTMLTextAreaElement>(null)
const onSubmit = React.useCallback(() => {
if (!hasEmbed && message.trim() === '') {
@@ -94,6 +104,23 @@ export function MessageInput({
[],
)
const onEmojiInserted = React.useCallback(
(emoji: Emoji) => {
const position = textAreaRef.current?.selectionStart ?? 0
setMessage(
message =>
message.slice(0, position) + emoji.native + message.slice(position),
)
},
[setMessage],
)
React.useEffect(() => {
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
return () => {
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
}
}, [onEmojiInserted])
useSaveMessageDraft(message)
useExtractEmbedFromFacets(message, setEmbed)
@@ -106,7 +133,7 @@ export function MessageInput({
t.atoms.bg_contrast_25,
{
paddingRight: a.p_sm.padding - 2,
paddingLeft: a.p_md.padding - 2,
paddingLeft: a.p_sm.padding - 2,
borderWidth: 1,
borderRadius: 23,
borderColor: 'transparent',
@@ -118,7 +145,44 @@ export function MessageInput({
// @ts-expect-error web only
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
<Button
onPress={e => {
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
openEmojiPicker?.({top: py, left: px, right: px, bottom: py})
})
}}
style={[
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
{
marginTop: 5,
height: 30,
width: 30,
},
]}
label={_(msg`Open emoji picker`)}>
{state => (
<View
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
{
backgroundColor:
state.hovered || state.focused || state.pressed
? t.atoms.bg.backgroundColor
: undefined,
},
]}>
<EmojiSmile size="lg" />
</View>
)}
</Button>
<TextareaAutosize
ref={textAreaRef}
style={StyleSheet.flatten([
a.flex_1,
a.px_sm,
@@ -29,6 +29,10 @@ import {useAgent} from '#/state/session'
import {clamp} from 'lib/numbers'
import {ScrollProvider} from 'lib/ScrollContext'
import {isWeb} from 'platform/detection'
import {
EmojiPicker,
EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {List} from 'view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
@@ -97,6 +101,12 @@ export function MessagesList({
startContentOffset: 0,
})
const [emojiPickerState, setEmojiPickerState] =
React.useState<EmojiPickerState>({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0},
})
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
// the bottom.
@@ -422,13 +432,22 @@ export function MessagesList({
<MessageInput
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}>
setEmbed={setEmbed}
openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageInput>
</>
)}
</KeyboardStickyView>
{isWeb && (
<EmojiPicker
pinToTop
state={emojiPickerState}
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
</>
)
+2 -69
View File
@@ -6,10 +6,8 @@ import {useQuery} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {useGate} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {
@@ -29,23 +27,16 @@ import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
const PROMPT_HEIGHT = isWeb ? 42 : 36
// matches the padding of the OnboardingControls.Portal
const PROMPT_OFFSET = isWeb ? a.pb_2xl.paddingBottom : a.pb_lg.paddingBottom
const MIN_INTERESTS = 3
export function StepInterests() {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const gate = useGate()
const interestsDisplayNames = useInterestsDisplayNames()
const {state, dispatch} = React.useContext(Context)
@@ -143,12 +134,6 @@ export function StepInterests() {
track('OnboardingV2:StepInterests:Start')
}, [track])
const isMinimumInterestsEnabled =
gate('onboarding_minimum_interests') && data?.interests.length !== 0
const meetsMinimumRequirement = isMinimumInterestsEnabled
? interests.length >= MIN_INTERESTS
: true
const title = isError ? (
<Trans>Oh no! Something went wrong.</Trans>
) : (
@@ -186,13 +171,8 @@ export function StepInterests() {
<TitleText>{title}</TitleText>
<DescriptionText>{description}</DescriptionText>
{isMinimumInterestsEnabled && (
<DescriptionText style={[a.pt_sm]}>
<Trans>Choose 3 or more:</Trans>
</DescriptionText>
)}
<View style={[a.w_full, isMinimumInterestsEnabled ? a.pt_md : a.pt_2xl]}>
<View style={[a.w_full, a.pt_2xl]}>
{isLoading ? (
<Loader size="xl" />
) : isError || !data ? (
@@ -268,7 +248,7 @@ export function StepInterests() {
</View>
) : (
<Button
disabled={saving || !data || !meetsMinimumRequirement}
disabled={saving || !data}
variant="gradient"
color="gradient_sky"
size="large"
@@ -283,53 +263,6 @@ export function StepInterests() {
/>
</Button>
)}
{!meetsMinimumRequirement && (
<View
style={[
a.align_center,
a.absolute,
{
top: 0,
left: 0,
right: 0,
margin: 'auto',
transform: [
{
translateY:
-1 *
(PROMPT_OFFSET + PROMPT_HEIGHT + a.pb_lg.paddingBottom),
},
],
},
]}>
<View
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.rounded_full,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_medium,
{
height: PROMPT_HEIGHT,
...t.atoms.shadow_sm,
shadowOpacity: 0.1,
},
isWeb
? [a.py_md, a.px_lg, a.pr_xl]
: [a.py_sm, a.px_md, a.pr_lg],
]}>
<CircleInfo />
<Text>
<Trans>
Choose at least {MIN_INTERESTS - interests.length} more
</Trans>
</Text>
</View>
</View>
)}
</OnboardingControls.Portal>
</View>
)
@@ -6,11 +6,9 @@ import {
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
@@ -23,10 +21,9 @@ import {useRequireAuth, useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
@@ -59,8 +56,6 @@ let ProfileHeaderStandard = ({
}: Props): React.ReactNode => {
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
useProfileShadow(profileUnshadowed)
const t = useTheme()
const gate = useGate()
const {currentAccount, hasSession} = useSession()
const {_} = useLingui()
const {openModal} = useModalControls()
@@ -69,7 +64,6 @@ let ProfileHeaderStandard = ({
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'ProfileHeader',
@@ -202,34 +196,7 @@ let ProfileHeaderStandard = ({
)
) : !profile.viewer?.blockedBy ? (
<>
{hasSession && (
<>
<MessageProfileButton profile={profile} />
{!gate('show_follow_suggestions_in_profile') && (
<Button
testID="suggestedFollowsBtn"
size="small"
color={showSuggestedFollows ? 'primary' : 'secondary'}
variant="solid"
shape="round"
onPress={() =>
setShowSuggestedFollows(!showSuggestedFollows)
}
label={_(msg`Show follows similar to ${profile.handle}`)}
style={{width: 36, height: 36}}>
<FontAwesomeIcon
icon="user-plus"
style={
showSuggestedFollows
? {color: t.palette.white}
: t.atoms.text
}
size={14}
/>
</Button>
)}
</>
)}
{hasSession && <MessageProfileButton profile={profile} />}
<Button
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
@@ -252,6 +219,8 @@ let ProfileHeaderStandard = ({
<ButtonText>
{profile.viewer?.following ? (
<Trans>Following</Trans>
) : profile.viewer?.followedBy ? (
<Trans>Follow Back</Trans>
) : (
<Trans>Follow</Trans>
)}
@@ -294,19 +263,6 @@ let ProfileHeaderStandard = ({
</>
)}
</View>
{showSuggestedFollows && (
<ProfileHeaderSuggestedFollows
actorDid={profile.did}
requestDismiss={() => {
if (showSuggestedFollows) {
setShowSuggestedFollows(false)
} else {
track('ProfileHeader:SuggestedFollowsOpened')
setShowSuggestedFollows(true)
}
}}
/>
)}
<Prompt.Basic
control={unblockPromptControl}
title={_(msg`Unblock Account?`)}
+1
View File
@@ -172,6 +172,7 @@ export function StepInfo({
defaultValue={state.password}
secureTextEntry
autoComplete="new-password"
autoCapitalize="none"
/>
</TextField.Root>
</View>
+1 -25
View File
@@ -21,31 +21,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
if (feedDesc.startsWith('feedgen')) {
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
}
if (feedDesc.startsWith('list')) {
let feedTuners = []
if (feedDesc.endsWith('|as_following')) {
// Same as Following tuners below, copypaste for now.
feedTuners.push(FeedTuner.removeOrphans)
if (preferences?.feedViewPrefs.hideReposts) {
feedTuners.push(FeedTuner.removeReposts)
}
if (preferences?.feedViewPrefs.hideReplies) {
feedTuners.push(FeedTuner.removeReplies)
} else {
feedTuners.push(
FeedTuner.followedRepliesOnly({
userDid: currentAccount?.did || '',
}),
)
}
if (preferences?.feedViewPrefs.hideQuotePosts) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
feedTuners.push(FeedTuner.dedupThreads)
}
return feedTuners
}
if (feedDesc === 'following') {
if (feedDesc === 'following' || feedDesc.startsWith('list')) {
const feedTuners = [FeedTuner.removeOrphans]
if (preferences?.feedViewPrefs.hideReposts) {
+18
View File
@@ -0,0 +1,18 @@
import zod from 'zod'
import {BaseNux} from '#/state/queries/nuxs/types'
export enum Nux {
TenMillionDialog = 'TenMillionDialog',
}
export const nuxNames = new Set(Object.values(Nux))
export type AppNux = BaseNux<{
id: Nux.TenMillionDialog
data: undefined
}>
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.TenMillionDialog]: undefined,
}
+85
View File
@@ -0,0 +1,85 @@
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {AppNux, Nux} from '#/state/queries/nuxs/definitions'
import {parseAppNux, serializeAppNux} from '#/state/queries/nuxs/util'
import {
preferencesQueryKey,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
export {Nux} from '#/state/queries/nuxs/definitions'
export function useNuxs() {
const {data, ...rest} = usePreferencesQuery()
if (data && rest.isSuccess) {
const nuxs = data.bskyAppState.nuxs
?.map(parseAppNux)
?.filter(Boolean) as AppNux[]
if (nuxs) {
return {
nuxs,
...rest,
}
}
}
return {
nuxs: undefined,
...rest,
}
}
export function useNux<T extends Nux>(id: T) {
const {nuxs, ...rest} = useNuxs()
if (nuxs && rest.isSuccess) {
const nux = nuxs.find(nux => nux.id === id)
if (nux) {
return {
nux: nux as Extract<AppNux, {id: T}>,
...rest,
}
}
}
return {
nux: undefined,
...rest,
}
}
export function useUpsertNuxMutation() {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
retry: 3,
mutationFn: async (nux: AppNux) => {
await agent.bskyAppUpsertNux(serializeAppNux(nux))
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
export function useRemoveNuxsMutation() {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
retry: 3,
mutationFn: async (ids: string[]) => {
await agent.bskyAppRemoveNuxs(ids)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
+7
View File
@@ -0,0 +1,7 @@
import {AppBskyActorDefs} from '@atproto/api'
export type Data = Record<string, unknown> | undefined
export type BaseNux<
T extends Pick<AppBskyActorDefs.Nux, 'id' | 'expiresAt'> & {data: Data},
> = Pick<AppBskyActorDefs.Nux, 'id' | 'completed' | 'expiresAt'> & T
+52
View File
@@ -0,0 +1,52 @@
import {AppBskyActorDefs, nuxSchema} from '@atproto/api'
import {
AppNux,
Nux,
nuxNames,
NuxSchemas,
} from '#/state/queries/nuxs/definitions'
export function parseAppNux(nux: AppBskyActorDefs.Nux): AppNux | undefined {
if (!nuxNames.has(nux.id as Nux)) return
if (!nuxSchema.safeParse(nux).success) return
const {data, ...rest} = nux
const schema = NuxSchemas[nux.id as Nux]
if (schema && data) {
const parsedData = JSON.parse(data)
if (!schema.safeParse(parsedData).success) return
return {
...rest,
data: parsedData,
} as AppNux
}
return {
...rest,
data: undefined,
} as AppNux
}
export function serializeAppNux(nux: AppNux): AppBskyActorDefs.Nux {
const {data, ...rest} = nux
const schema = NuxSchemas[nux.id as Nux]
const result: AppBskyActorDefs.Nux = {
...rest,
data: undefined,
}
if (schema) {
schema.parse(data)
result.data = JSON.stringify(data)
}
nuxSchema.parse(result)
return result
}
-2
View File
@@ -51,7 +51,6 @@ type AuthorFilter =
| 'posts_with_media'
type FeedUri = string
type ListUri = string
type ListFilter = 'as_following' // Applies current Following settings. Currently client-side.
export type FeedDescriptor =
| 'following'
@@ -59,7 +58,6 @@ export type FeedDescriptor =
| `feedgen|${FeedUri}`
| `likes|${ActorDid}`
| `list|${ListUri}`
| `list|${ListUri}|${ListFilter}`
export interface FeedParams {
mergeFeedEnabled?: boolean
mergeFeedSources?: string[]
+6 -2
View File
@@ -408,10 +408,14 @@ export function* findAllPostsInQueryData(
}
}
}
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
// Check notifications first. If you have a post in notifications,
// it's often due to a like or a repost, and we want to prioritize
// a post object with >0 likes/reposts over a stale version with no
// metrics in order to avoid a notification->post scroll jump.
yield postViewToPlaceholderThread(post)
}
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
yield postViewToPlaceholderThread(post)
}
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
+12 -12
View File
@@ -99,8 +99,8 @@ export function useGetPosts() {
export function usePostLikeMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>,
logContext: LogEvents['post:like']['logContext'] &
LogEvents['post:unlike']['logContext'],
logContext: LogEvents['post:like:sampled']['logContext'] &
LogEvents['post:unlike:sampled']['logContext'],
) {
const queryClient = useQueryClient()
const postUri = post.uri
@@ -158,7 +158,7 @@ export function usePostLikeMutationQueue(
}
function usePostLikeMutation(
logContext: LogEvents['post:like']['logContext'],
logContext: LogEvents['post:like:sampled']['logContext'],
post: Shadow<AppBskyFeedDefs.PostView>,
) {
const {currentAccount} = useSession()
@@ -175,7 +175,7 @@ function usePostLikeMutation(
if (currentAccount) {
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
}
logEvent('post:like', {
logEvent('post:like:sampled', {
logContext,
doesPosterFollowLiker: postAuthor.viewer
? Boolean(postAuthor.viewer.followedBy)
@@ -200,12 +200,12 @@ function usePostLikeMutation(
}
function usePostUnlikeMutation(
logContext: LogEvents['post:unlike']['logContext'],
logContext: LogEvents['post:unlike:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation<void, Error, {postUri: string; likeUri: string}>({
mutationFn: ({likeUri}) => {
logEvent('post:unlike', {logContext})
logEvent('post:unlike:sampled', {logContext})
return agent.deleteLike(likeUri)
},
onSuccess() {
@@ -216,8 +216,8 @@ function usePostUnlikeMutation(
export function usePostRepostMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>,
logContext: LogEvents['post:repost']['logContext'] &
LogEvents['post:unrepost']['logContext'],
logContext: LogEvents['post:repost:sampled']['logContext'] &
LogEvents['post:unrepost:sampled']['logContext'],
) {
const queryClient = useQueryClient()
const postUri = post.uri
@@ -273,7 +273,7 @@ export function usePostRepostMutationQueue(
}
function usePostRepostMutation(
logContext: LogEvents['post:repost']['logContext'],
logContext: LogEvents['post:repost:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation<
@@ -282,7 +282,7 @@ function usePostRepostMutation(
{uri: string; cid: string} // the post's uri and cid
>({
mutationFn: post => {
logEvent('post:repost', {logContext})
logEvent('post:repost:sampled', {logContext})
return agent.repost(post.uri, post.cid)
},
onSuccess() {
@@ -292,12 +292,12 @@ function usePostRepostMutation(
}
function usePostUnrepostMutation(
logContext: LogEvents['post:unrepost']['logContext'],
logContext: LogEvents['post:unrepost:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation<void, Error, {postUri: string; repostUri: string}>({
mutationFn: ({repostUri}) => {
logEvent('post:unrepost', {logContext})
logEvent('post:unrepost:sampled', {logContext})
return agent.deleteRepost(repostUri)
},
onSuccess() {
+1
View File
@@ -37,5 +37,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
bskyAppState: {
queuedNudges: [],
activeProgressGuide: undefined,
nuxs: [],
},
}
+6 -6
View File
@@ -219,8 +219,8 @@ export function useProfileUpdateMutation() {
export function useProfileFollowMutationQueue(
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext'],
logContext: LogEvents['profile:follow:sampled']['logContext'] &
LogEvents['profile:follow:sampled']['logContext'],
) {
const agent = useAgent()
const queryClient = useQueryClient()
@@ -291,7 +291,7 @@ export function useProfileFollowMutationQueue(
}
function useProfileFollowMutation(
logContext: LogEvents['profile:follow']['logContext'],
logContext: LogEvents['profile:follow:sampled']['logContext'],
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
) {
const {currentAccount} = useSession()
@@ -306,7 +306,7 @@ function useProfileFollowMutation(
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
}
captureAction(ProgressGuideAction.Follow)
logEvent('profile:follow', {
logEvent('profile:follow:sampled', {
logContext,
didBecomeMutual: profile.viewer
? Boolean(profile.viewer.followedBy)
@@ -323,12 +323,12 @@ function useProfileFollowMutation(
}
function useProfileUnfollowMutation(
logContext: LogEvents['profile:unfollow']['logContext'],
logContext: LogEvents['profile:unfollow:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation<void, Error, {did: string; followUri: string}>({
mutationFn: async ({followUri}) => {
logEvent('profile:unfollow', {logContext})
logEvent('profile:unfollow:sampled', {logContext})
track('Profile:Unfollow', {username: followUri})
return await agent.deleteFollow(followUri)
},
+5 -2
View File
@@ -106,13 +106,16 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
gcTime: 0,
queryKey: suggestedFollowsByActorQueryKey(did),
queryFn: async () => {
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
actor: did,
})
return res.data
const data = res.data.isFallback ? {suggestions: []} : res.data
data.suggestions = data.suggestions.filter(profile => {
return !profile.viewer?.following
})
return data
},
})
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function useCompressVideoMutation({
mutationKey: ['video', 'compress'],
mutationFn: cancelable(
(asset: ImagePickerAsset) =>
compressVideo(asset.uri, {
compressVideo(asset, {
onProgress: num => onProgress(trunc2dp(num)),
signal,
}),
+18 -5
View File
@@ -1,15 +1,13 @@
import {useMemo} from 'react'
import {AtpAgent} from '@atproto/api'
import {SupportedMimeTypes} from '#/lib/constants'
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
export const createVideoEndpointUrl = (
route: string,
params?: Record<string, string>,
) => {
const url = new URL(`${UPLOAD_ENDPOINT}`)
const url = new URL(VIDEO_SERVICE)
url.pathname = route
if (params) {
for (const key in params) {
@@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
export function useVideoAgent() {
return useMemo(() => {
return new AtpAgent({
service: UPLOAD_ENDPOINT,
service: VIDEO_SERVICE,
})
}, [])
}
@@ -41,3 +39,18 @@ export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
throw new Error(`Unsupported mime type: ${mimeType}`)
}
}
export function extToMime(ext: string) {
switch (ext) {
case 'mp4':
return 'video/mp4'
case 'webm':
return 'video/webm'
case 'mpeg':
return 'video/mpeg'
case 'mov':
return 'video/quicktime'
default:
throw new Error(`Unsupported file extension: ${ext}`)
}
}
@@ -0,0 +1,73 @@
import {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {VIDEO_SERVICE_DID} from '#/lib/constants'
import {UploadLimitError} from '#/lib/media/video/errors'
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
import {useAgent} from '#/state/session'
import {useVideoAgent} from './util'
export function useServiceAuthToken({
aud,
lxm,
exp,
}: {
aud?: string
lxm: string
exp?: number
}) {
const agent = useAgent()
return useCallback(async () => {
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!pdsAud) {
throw new Error('Agent does not have a PDS URL')
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
aud: aud ?? pdsAud,
lxm,
exp,
})
return serviceAuth.token
}, [agent, aud, lxm, exp])
}
export function useVideoUploadLimits() {
const agent = useVideoAgent()
const getToken = useServiceAuthToken({
lxm: 'app.bsky.video.getUploadLimits',
aud: VIDEO_SERVICE_DID,
})
const {_} = useLingui()
return useCallback(async () => {
const {data: limits} = await agent.app.bsky.video
.getUploadLimits(
{},
{headers: {Authorization: `Bearer ${await getToken()}`}},
)
.catch(err => {
if (err instanceof Error) {
throw new UploadLimitError(err.message)
} else {
throw err
}
})
if (!limits.canUpload) {
if (limits.message) {
throw new UploadLimitError(limits.message)
} else {
throw new UploadLimitError(
_(
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
),
)
}
}
}, [agent, _, getToken])
}
+10 -18
View File
@@ -9,8 +9,8 @@ import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
import {useSession} from '#/state/session'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
@@ -24,38 +24,30 @@ export const useUploadVideoMutation = ({
signal: AbortSignal
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const getToken = useServiceAuthToken({
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
const checkLimits = useVideoUploadLimits()
const {_} = useLingui()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!serviceAuthAud) {
throw new Error('Agent does not have a PDS URL')
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
{
aud: serviceAuthAud,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
},
)
const uploadTask = createUploadTask(
uri,
video.uri,
{
headers: {
'content-type': video.mimeType,
Authorization: `Bearer ${serviceAuth.token}`,
Authorization: `Bearer ${await getToken()}`,
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
+12 -19
View File
@@ -8,8 +8,8 @@ import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
import {useSession} from '#/state/session'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
@@ -23,37 +23,30 @@ export const useUploadVideoMutation = ({
signal: AbortSignal
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const getToken = useServiceAuthToken({
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
const checkLimits = useVideoUploadLimits()
const {_} = useLingui()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!serviceAuthAud) {
throw new Error('Agent does not have a PDS URL')
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
{
aud: serviceAuthAud,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
},
)
let bytes = video.bytes
if (!bytes) {
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
}
const token = await getToken()
const xhr = new XMLHttpRequest()
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
(resolve, reject) => {
@@ -76,7 +69,7 @@ export const useUploadVideoMutation = ({
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType)
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.send(bytes)
},
)

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