Compare commits

...

126 Commits

Author SHA1 Message Date
Eric Bailey 15e51c09bc Add support for autofill of one-time-code for email verification 2024-09-06 10:42:40 -05:00
Hailey a1969faf8e [Video] Fix alt text dialog on iOS and Android (#5177)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2024-09-06 08:26:37 -07:00
Eric Bailey 64b50ba69a Go full width on native and mobile web (#5184) 2024-09-06 17:19:29 +02:00
Eric Bailey b90cd68359 Remove record-with-media side-by-side layout (#5182) 2024-09-06 09:58:27 -05:00
Samuel Newman 55468595d0 [Video] Error banner improvements (#5163) 2024-09-05 21:25:56 -07:00
Hailey 18133483fe [Video] Bump expo-video (#5173) 2024-09-05 15:13:53 -07:00
Hailey 6eabedd037 [Video] More adjustments for loading state jank (#5171) 2024-09-05 14:54:09 -07:00
Hailey 7eb4cd89c5 Downgrade MMKV to 2.x (#5170) 2024-09-05 12:55:10 -07:00
Eric Bailey 8a66883df8 Add MMKV interface (#5169) 2024-09-05 12:31:24 -07:00
Eric Bailey 2265fedd2a Constrain image heights in feeds and threads (#5129)
* Limit height of images within posts

* Add some future-proofness

* Comments, improve a11y

* Adjust ALT, add crop icon

* Fix disableCrop in record-with-media posts

* Clean up aspect ratios, handle very tall images

* Handle record-with-media separately, clarify intent using enums

* Adjust spacing

* Adjust rwm embed image size on mobile

* Only do reduced layout if images embed

* Adjust gap in small embed variant

* Clean up grid layout

* Hide badge on small variant with one image

* Remove crop icon from image grid, leave on single image

* Fix sizing in Firefox

* Fix fullBleed variant
2024-09-05 13:45:13 -05:00
Samuel Newman 117926357d [Video] require email to post videos (#5152)
Co-authored-by: Hailey <me@haileyok.com>
2024-09-05 11:36:19 -07:00
Hailey 91c3dbd812 [Video] Fix CI (#5168) 2024-09-05 10:21:52 -07:00
Marco Buono 824206b95f Load number formatting data when activating locales (#5128) 2024-09-05 10:34:24 -05:00
Hailey 93c171b403 [Video] Use expo-video from fork (#5159) 2024-09-05 08:27:28 -07:00
Marco Buono 6d8ed5c3c8 Add quick access to quote action on long press (#5123) 2024-09-05 10:14:04 -05:00
Samuel Newman 4e6b6740f7 [Video] Enter/exit animations for video in composer (#5164)
* enter/exit animations for video in composer

* use zoom out animation

* unify margin between different steps

* skip animation when posting
2024-09-05 16:07:06 +01:00
Samuel Newman 428607d9a3 [Video] throw HLS errors to be caught by error boundary (#5166)
* throw HLS errors to be caught by error boundary

* wording tweak

* do the same on native

* fix type error
2024-09-05 16:03:00 +01:00
Samuel Newman 60b74f7ab8 [Video] Disable autoplay option (preview + web player) (#5167)
* rename setting

* preview (web)

* preview (native)

* improve autoplay disabled behaviour on web
2024-09-05 15:56:10 +01:00
gabrielsiilva d846f5bbf0 fix on 'reposted by you' translation to ptbr (#5146) 2024-09-04 21:00:07 -07:00
Hailey 2556698427 [Video] Add loading state to player (#5149) 2024-09-04 16:46:01 -07:00
Eric Bailey 76f493c279 Ensure profile labels can be appealed separately from account labels (#5154) 2024-09-04 18:34:19 -05:00
Eric Bailey 4d97a2aa16 Add misleading report type to posts (#5150)
* Add misleading report type to posts

* Update copy

* Update copy
2024-09-04 18:22:57 -05:00
Hailey 86de0dda02 Tweak animation to not roll 0 -> 1, overflow hidden (#5148) 2024-09-04 12:36:20 -07:00
Hailey 0ef17a464d Use new player icon for external video embeds (#5147) 2024-09-04 12:20:21 -07:00
Samuel Newman 5dcb52015c add video to embed (#5145) 2024-09-04 19:57:16 +01:00
Samuel Newman fcf27f0512 [Video] content fit cover on native (#5140) 2024-09-04 19:56:02 +01:00
Samuel Newman e8eaf2f4a7 allow only posting video (#5142) 2024-09-04 19:42:28 +01:00
Marco Buono c36c47d49a Add slight spacing between Post and CW button (#5125) 2024-09-04 11:04:08 -07:00
Hailey 6382a91fb0 [Video] Use same play button for gifs and videos (#5144) 2024-09-04 10:59:06 -07:00
Samuel Newman 5f5c14d044 Replace ImageHorzList 🤮 with MediaPreview (#5143) 2024-09-04 10:52:41 -07:00
Frudrax Cheng 82ca0b16b6 Fix a missing curly brace in pt-BR (#5130)
thanks!
2024-09-04 10:50:54 -07:00
Hailey 12b4a250d2 [Video] withRepeat for spinner (#5141) 2024-09-04 09:39:34 -07:00
Hailey 45bb2477d8 [Video] Show better progress (#5133) 2024-09-04 09:17:14 -07:00
Hailey d94ff2695d [Video] Throw error when playback fails (#5132) 2024-09-04 08:06:58 -07:00
Hailey dee28f378a [Video] Only allow one VideoView to be active at a time, regardless of source (#5131) 2024-09-04 08:06:45 -07:00
Hailey 21e48bb2d8 [Video] Tweak playback handling (#5127) 2024-09-04 08:00:53 -07:00
Samuel Newman 515f87ed24 fail video if cannot load preview (#5138) 2024-09-04 15:56:29 +01:00
Samuel Newman 3eef62d995 log errors (#5139) 2024-09-04 15:29:20 +01:00
dan e2a244b998 Disable in-thread deduping for reposted replies (#5135) 2024-09-04 15:42:22 +02:00
dan 8860890a85 Don't log extra background events (#5134) 2024-09-04 15:41:42 +02:00
Samuel Newman 39f74ced5c close keyboard before opening modal (#5124) 2024-09-03 23:13:25 +01:00
Samuel Newman 3ee5ef32d9 [Video] Error handling in composer, fix auto-send (#5122)
* tweak

* error state for upload toolbar

* catch errors in upload status query

* stop query on error

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-09-03 22:49:19 +01:00
Samuel Newman 0bd0146efb fix mime checks (#5118) 2024-09-03 20:55:10 +01:00
Fabio Nobre ea4d8bc1ab Updating some messages in the pt-BR locale file - [2] (#5099)
Co-authored-by: Gleydson Rodrigues <gleydsonsr@gmail.com>
Co-authored-by: Hailey <me@haileyok.com>
2024-09-03 12:14:38 -07:00
Eric Bailey ddda612c11 Fix border on some listing screens (#5115)
* Fix border on PostQuotes, add debug atom

* Add util_screen_outer, apply to h_full_vh here needed
2024-09-03 13:50:48 -05:00
Samuel Newman 5c2e9f09bf reset playback rate on exit fullscreen (#5116) 2024-09-03 19:46:37 +01:00
Hailey 7f26ad508e [Video] Toggle controls when entering fullscreen (#5113) 2024-09-03 11:04:46 -07:00
Hailey 5d300bec90 Adjust render properties for Feed List (#5080) 2024-09-03 10:56:00 -07:00
Hailey 0014d4363f Only animate the like icon when from an actual toggle (#5096) 2024-09-03 10:55:53 -07:00
Samuel Newman bd42f770b8 show video in quote (#5093) 2024-09-03 18:33:16 +01:00
Hailey dde72b48e1 [Video] Manage foreground/background playback on the native side (#5104) 2024-09-03 08:41:14 -07:00
Hailey 05e61346b8 [Video] Say No to Audio (Disable expo-video AudioSession management) (#5101) 2024-09-03 08:33:21 -07:00
Samuel Newman 0e1de19903 [Video] Upload errors and UI improvements (#5092)
* surface errors in UI

* style progress indicator

* remove job status progress

* rm log

* fix webm ext
2024-09-03 15:09:09 +01:00
Samuel Newman f9d736653c [Video] Hide mouse when inactive (#5094) 2024-09-03 11:51:16 +01:00
Samuel Newman 0469ca6cb4 [Embeds] Format big numbers (#5087) 2024-09-02 13:04:26 -07:00
Fabio Nobre f3f7dfc3e6 Add some Portuguese (PT-BR) translation (#5057) 2024-09-02 04:09:17 -07:00
Hailey 4abcd65ccf More tweaks to animation (#5082) 2024-09-02 03:15:31 -07:00
Mary 05ac76fc89 Don't eagerly load all JS assets (#3929)
Co-authored-by: Hailey <me@haileyok.com>
2024-09-02 01:59:04 -07:00
Hailey 1225e84485 Improve animations for like button (#5074) 2024-09-02 01:37:24 -07:00
Samuel Newman eb868a042a [Video] Misc player style tweaks (#5064)
* use actual black rather than theme black

* adjust time/mute indicators
2024-09-02 09:33:46 +01:00
Samuel Newman 17d82a64a6 [Video] Remove hack from scrubber (#5063)
* remove mouseleave, add pointer cancel

* don't show time indicator if duration is 0
2024-09-02 09:32:51 +01:00
dan bf15fad240 Mark unhydrated replies as orphaned (#5048) 2024-08-31 13:14:53 -07:00
Hailey 2df22d8674 Add some Portuguese translation (#5052) 2024-08-31 12:52:18 -07:00
dan bff6aedecf Show some known OP replies in Following (#5049)
* Show known OP replies in Following

* Fiter by >0 parent likes
2024-08-31 03:54:14 +01:00
Eric Bailey 3d992f6bb5 Fix provider order (#5046) 2024-08-31 00:26:03 +01:00
dan ed232e69f7 Animate the like button (#5033)
* Animate the like button

* Respect reduced motion

* Move like count into animated component

* Animate text

* Fix layout on Android

* Animate text backwards too

* Fix bad copypasta

* Reflect nonlocal updates to animated values
2024-08-30 23:16:11 +01:00
Samuel Newman c41f372b3c [Video] Split 'videos' gate in two (#5037) 2024-08-30 12:53:27 -07:00
Samuel Newman ab260c3599 [Video] Minor player tweaks (#5044) 2024-08-30 12:48:23 -07:00
Samuel Newman 8647c8e9f5 [Videos] avoid using fetch for blob handling where possible (#5041)
* avoid using fetch where possible

* whoopsie wrong branch

* more import fixes
2024-08-30 19:05:38 +01:00
Samuel Newman c70ec1ce1a [Video] Captions and alt text (#5009)
* video settings modal in composer

* show done button on web

* rm download options

* fix logic for showing settings button

* add language picker (wip)

* subtitle list with language select

* send captions & alt text with video when posting

* style "ensure you have selected a language" text

* include aspect ratio with video

* filter out captions where the lang is not set

* rm log

* fix label and add hint

* minor scrubber fix
2024-08-30 18:45:49 +01:00
Samuel Newman e7954e590b [Videos] Fix uploads (#5042)
* fix pds url

* fix service auth exp

* whoopsie wrong branch
2024-08-30 18:44:00 +01:00
Eric Bailey c60e8d0772 Composer blocks (#5040)
* Move i18n provider up the stack

* Protect composer opening for a blocked post

* Protect ctrls from interacting with blocked user
2024-08-30 12:26:40 -05:00
dan dbbbba1d32 [Experiment] Suggest profiles in profile (#5030)
* Rename variable to disambiguate with parent scope

* More variables where they are used

* Inline variables

* Add suggestions in profile

* Gate it

* rm space

* Remove header suggestions under gate
2024-08-30 16:54:55 +01:00
Igor Adrov 46b7193a2b Hide back button in my profile (#4991) 2024-08-30 10:49:32 -05:00
Minseo Lee 2ef2ae1c6c Improve localization (#5008)
* Update QrCode.tsx

* Update generate-starterpack.ts
2024-08-30 09:59:49 -05:00
Samuel Newman 05b73d4d1e extend token lifetime to 30 minutes (#5019) 2024-08-30 08:32:18 +01:00
Hailey 8cddce5f30 Tweak rendering of top headers so they always appear even during load (#4982)
Co-authored-by: Eric Bailey <git@esb.lol>
2024-08-30 00:20:46 -07:00
Eric Bailey 8651f31ebb Localize dates, counts (#5027)
* refactor: consistent localized formatting

* refactor: localized date time

* refactor: localize relative time with strings

* chore: fix typo from copy-paste

* Clean up useTimeAgo

* Remove old ago

* Const

* Reuse

* Prettier

---------

Co-authored-by: Mary <git@mary.my.id>
2024-08-29 19:22:53 -05:00
Hailey d5a7618374 Force at least one item to always be initially rendered (#5029) 2024-08-29 17:20:07 -07:00
Hailey 497aacf113 fix hashtag screen side borders (#4983) 2024-08-29 15:55:17 -07:00
Hailey 69e896c221 [Video] Properly get the service auth aud from the session (#5025) 2024-08-29 13:44:47 -07:00
Hailey 91fe41670f tweak (#5024) 2024-08-29 21:44:06 +01:00
dan 0a75969475 Fix language filtering for feeds (#5026)
* Fix language filtering for feeds

* Simplify logic
2024-08-29 20:22:18 +01:00
dan e51eb391df Enable show_follow_back_label_v2 (#5022) 2024-08-29 20:09:11 +01:00
dan 9ebc2785e4 Remove logging of following prefs (#5021) 2024-08-29 20:08:48 +01:00
dan faf66f3178 Remove new_user_guided_tour and tour code (#5023) 2024-08-29 18:57:36 +01:00
Hailey 69053b1b0a [Video] Remove old env var (#5018) 2024-08-29 09:40:17 -07:00
Hailey e33b88ed7d Profile screen performance tweak - Adjust initial num to render based on header height (#5005) 2024-08-29 09:09:24 -07:00
Samuel Newman ea5ab99399 [Video] Make compress/upload cancelable (#4996)
* add abort controller to video upload system

* rm log

* rm log 2
2024-08-29 17:00:12 +01:00
Samuel Newman 551c4a4f32 [Video] Add uploaded video to post (#4884)
* video uploads!

* use video upload lexicons

* add missing postgate

* remove references to prerelease package

* fix scrubber showing a "0"

* Delete types.ts

* rm logs

* rm upload header

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
2024-08-29 16:34:41 +01:00
Hailey d52d29621e [Video] Add timeRemainingChange event to player in expo-video (#5013) 2024-08-29 08:28:41 -07:00
Samuel Newman d92731b1eb [Video] Lexicon implementation (#4881)
* implement AppBskyEmbedVideo lexicon in player

* add alt to native player

* add prerelease package

* update prerelease

* add video embed view manually from record

* fix type error on example video

* black bg + use aspect ratio on web

* add video to feeds

* fix video overflowing aspect ratio

* remove prerelease package

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
2024-08-29 15:58:22 +01:00
Eric Bailey b136c44287 Hide quote counts for quotegated posts (#5011) 2024-08-28 16:57:18 -05:00
Hailey 16d556c3c9 Ensure captcha verification code gets submitted in signup request (#5010)
Co-authored-by: Eric Bailey <git@esb.lol>
2024-08-28 11:56:04 -07:00
Samuel Newman 94d2180aaa clean up languages (#5007) 2024-08-28 16:58:19 +01:00
Hailey 5ae0d40a14 [Video] 🫧 Move logic around by platform (#5003) 2024-08-28 08:46:47 -07:00
Hailey 9aa2b2d14e bump 1.91.0 (#5002) 2024-08-28 12:23:30 +01:00
Samuel Newman b69c40da33 add indicator of time remaining (#5000) 2024-08-27 22:15:59 +01:00
Samuel Newman 9b534b968d [Video] add scrubber to the web player (#4943) 2024-08-26 22:28:45 +01:00
Paul Frazee def9dda29c Release 1.90 prep (#4988)
* Stop creating a mod-authority in e2e due to upstream conflict

* Dont require 3 interests when none come back

* Fix e2e login

* intl extract
2024-08-23 14:43:21 -07:00
Eric Bailey fc5cc189b5 Use moderatePost_wrapped for post embeds (#4981)
* Use moderatePost_wrapped

* Add lint rule
2024-08-23 13:55:18 -07:00
kodebanget b5ea87c981 Update Indonesian translation (#4875)
Co-authored-by: Indonesian <github-actions@github.com>
2024-08-23 13:45:41 -07:00
surfdude29 d646d590cc Update French localization (#4823) 2024-08-23 13:45:22 -07:00
Takayuki KUSANO 1d5e341ada Update Japanese translation (#4824)
* Updated translation

* Update translation

* Update translation

* Update translation

* Only show replies in Following if following all involved actors (#4869)

* Only show replies in Following for followed root and grandparent

* Remove now-unnecessary check

* Simplify condition

* Respect labels on feeds and lists (#4818)

* Prep

* Pass in optional moderation to FeedCard

* Compute moderation decision, filter contentList contexts, pass into card

* Let's go a different route

* Filter from within search queries

* Use same search query for starter packs

* Filter lists from profile tabs

* Cleanup

* Filter from profile feeds

* Moderate post embeds

* Memoize

* Use ScreenHider on lists

* Hide both list types

* Fix crash on iOS in screen hider, fix lineheight

* Memoize renderItem

* Reuse objects to prevent re-renders

* tweak list header (#4870)

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>

* bskyweb: optional basic auth password middleware (#4759)

* Update translation

* Revert "Update translation"

This reverts commit 3a7b74f47b.

* Revert "bskyweb: optional basic auth password middleware (#4759)"

This reverts commit bc3a27d40f.

* Revert "tweak list header (#4870)"

This reverts commit 34e7e5cba2.

* Revert "Respect labels on feeds and lists (#4818)"

This reverts commit 9ec6fde288.

* Revert "Only show replies in Following if following all involved actors (#4869)"

This reverts commit e2cc4bb4af.

* Update translation

* Update translation

* Update translation

* Updated translation

* Update translation

* Update translation

* Unified existing translations of "hidden" and "community".

* Update translation

* Update translation

* Update translation

---------

Co-authored-by: dan <dan.abramov@gmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Co-authored-by: bnewbold <bnewbold@robocracy.org>
2024-08-23 13:45:03 -07:00
Minseo Lee 632f71acc9 Update Korean localization (#4826)
* 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

* 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-08-23 13:44:29 -07:00
Ivan Beà 80320f9d7d Update catalan localization (#4851)
* Update catalan localisation

More lines translated, It will be so kind of you to check it @jordimas @darccio @surfdude29

Thanks

* 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: GSMT <samaritanojr006@gmail.com>

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

Co-authored-by: GSMT <samaritanojr006@gmail.com>

* Update messages.po

apply @jordimas corrections

* Update messages.po

change traves per través

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: GSMT <samaritanojr006@gmail.com>
2024-08-23 13:44:08 -07:00
Frudrax Cheng 3f98747925 Update Chinese Localization (#4947)
* CN: Update translates

* CN: Remove superseded strings

* TW: Update and Clean

* Both: Update translates

* CN: Update translates

* CN: Remove superseded strings

* CN: Update translates

* CN: Remove superseded strings

* TW: Run intl:extract

* CN: Update translates

* TW: Update and clean

* 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>

* 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>

* 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>

* Both: Run intl:extract

* CN: Update translates

* CN: Update translates

* Both: Run intl:extract

* CN: fix typo

* CN: fix typo#2

* CN: Update translates

* CN: Update translates#2

* TW: Update translates

* Both: Remove superseded strings

* TW: fix typo

---------

Co-authored-by: Kuwa Lee <kuwalee1069@gmail.com>
Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>
2024-08-23 13:42:59 -07:00
Hailey fa12bf5d87 Revert "Make settings account buttons a little nicer" (#4987) 2024-08-23 13:28:56 -07:00
Hailey 1f657b3ac5 fix findAll* type in post-thread (#4986) 2024-08-23 13:20:05 -07:00
Eric Bailey 425dd5f27f Optimistic hidden replies (#4977) 2024-08-23 12:35:48 -07:00
Eric Bailey 5ec8761b29 Make settings account buttons a little nicer (#4980)
* Replace account dropdown with Menu

* Replace card row and add interaction state

* Remove testID copy pasta

* Sanitize handle

* Remove hover from row
2024-08-22 20:09:55 -05:00
Eric Bailey 990bf306c5 Use RichText for sp description (#4979)
* Use RichText for sp description

* `isRecord` above

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-08-22 17:37:15 -05:00
dan b8dbb71781 Fix fixed footer experiment (#4969)
* Split minimal shell mode into headerMode and footerMode

For now, we'll always write them in sync. When we read them, we'll use headerMode as source of truth. This will let us keep footerMode independent in a future commit.

* Remove fixed_bottom_bar special cases during calculation

This isn't the right time to determine special behavior. Instead we'll adjust footerMode itself conditionally on the gate.

* Copy-paste setMode into MainScrollProvider

This lets us fork the implementation later just for this case.

* Gate footer adjustment in MainScrollProvider

This is the final piece. Normal calls to setMode() keep setting both header and footer, but MainScrollProvider adjusts the footer conditionally.
2024-08-22 23:27:33 +01:00
Hailey 2ae3ffcf78 have mock server run with development node env (#4976) 2024-08-22 14:51:35 -07:00
dan 27bb383268 Submit fix (#4978)
* Fix submit logic

* Fix type

* Align submit task creation 1:1 with callsites

* blegh. `useThrottledValue`

* make `useThrottledValue`'s time required

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-08-22 22:43:23 +01:00
Hailey df5bf28e61 update usePostThreadQuery to check quote query data (#4975)
* update `usePostThreadQuery` to check quote query data

* search notifs before quotes

* oops
2024-08-22 19:11:51 +01:00
Eric Bailey 92989282ae Fetch it (#4974) 2024-08-22 10:27:34 -07:00
Hailey 9f1c411360 add quoteCount to view creators (#4972) 2024-08-22 10:26:49 -07:00
Hailey 85d70fe3ef tweak rqkey and cache search for useAllListMembersQuery (#4971) 2024-08-22 09:32:49 -07:00
Eric Bailey d5c78b9183 Prep threadgate shadow hack (#4970)
Co-authored-by: Hailey <me@haileyok.com>
2024-08-21 20:16:03 -07:00
Hailey 61f0be705d Change size (#4957) 2024-08-21 19:35:34 -07:00
Eric Bailey 6616a6467e Detached QPs and hidden replies (#4878)
Co-authored-by: Hailey <me@haileyok.com>
2024-08-21 19:20:45 -07:00
Samuel Newman 56ab5e177f Show quote posts (#4865)
* show quote posts

* fix filter

* fix keyExtractor

* move likedby and repostedby to new file structure

* use modern list component

* remove relative imports

* update quotes count after quoting

* call `onPost` after updating quote count

* Revert "update quotes count after quoting"

This reverts commit 1f1887730a.

* implement

* update like count in quotes list

* only add `onPostReply` where needed

* Filter quotes with detached embeds

* Bump SDK

* Don't show error for no results

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2024-08-21 15:26:25 -05:00
Eric Bailey ddb0b80017 Fix hidden list for author (#4968) 2024-08-21 13:11:47 -07:00
Eric Bailey 5463e9718f Update joined stat (#4967) 2024-08-21 10:17:23 -07:00
Hailey 723896a45f Add list hidden screen (#4958)
Co-authored-by: Hailey <me@haileyok.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2024-08-20 15:43:40 -07:00
240 changed files with 34827 additions and 20863 deletions
+13
View File
@@ -71,6 +71,19 @@ module.exports = {
'simple-import-sort/exports': 'warn',
// TODO: Reenable when we figure out why it gets stuck on CI.
// 'react-compiler/react-compiler': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@atproto/api',
importNames: ['moderatePost'],
message:
'Please use `moderatePost_wrapped` from `#/lib/moderatePost_wrapped` instead.',
},
],
},
],
},
ignorePatterns: [
'**/__mocks__/*.ts',
+1 -1
View File
@@ -191,7 +191,7 @@ module.exports = function (config) {
'expo-build-properties',
{
ios: {
deploymentTarget: '14.0',
deploymentTarget: '15.1',
newArchEnabled: false,
},
android: {
@@ -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="M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 289 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 476 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z"/></svg>

After

Width:  |  Height:  |  Size: 239 B

+2 -2
View File
@@ -9,7 +9,7 @@
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
},
"dependencies": {
"@atproto/api": "0.13.1",
"@atproto/api": "0.13.6",
"@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.3.2",
"preact": "^10.4.8",
@@ -22,7 +22,7 @@
"eslint-plugin-simple-import-sort": "^12.0.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^4.0.5",
"typescript": "^5.5.4",
"vite": "^5.2.8",
"vite-tsconfig-paths": "^4.3.2"
}
+37 -1
View File
@@ -3,6 +3,7 @@ import {
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedVideo,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
@@ -14,6 +15,7 @@ import {ComponentChildren, h} from 'preact'
import {useMemo} from 'preact/hooks'
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
import starterPackIcon from '../../assets/starterPack.svg'
import {CONTENT_LABELS, labelsToInfo} from '../labels'
import {getRkey} from '../utils'
@@ -160,7 +162,12 @@ export function Embed({
return null
}
// Case 4: Record with media
// Case 4: Video
if (AppBskyEmbedVideo.isView(content)) {
return <VideoEmbed content={content} />
}
// Case 5: Record with media
if (
AppBskyEmbedRecordWithMedia.isView(content) &&
AppBskyEmbedRecord.isViewRecord(content.record.record)
@@ -354,6 +361,31 @@ function GenericWithImageEmbed({
)
}
// just the thumbnail and a play button
function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
let aspectRatio = 1
if (content.aspectRatio) {
const {width, height} = content.aspectRatio
aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
}
return (
<div
className="w-full overflow-hidden rounded-lg aspect-square"
style={{aspectRatio: `${aspectRatio} / 1`}}>
<img
src={content.thumbnail}
alt={content.alt}
className="object-cover size-full"
/>
<div className="size-24 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black/50 flex items-center justify-center">
<img src={playIcon} className="object-cover size-3/5" />
</div>
</div>
)
}
function StarterPackEmbed({
content,
}: {
@@ -410,3 +442,7 @@ function getStarterPackHref(
const handleOrDid = starterPack.creator.handle || starterPack.creator.did
return `/starter-pack/${handleOrDid}/${rkey}`
}
function clamp(num: number, min: number, max: number) {
return Math.max(min, Math.min(num, max))
}
+4 -4
View File
@@ -11,7 +11,7 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg'
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
import {CONTENT_LABELS} from '../labels'
import {getRkey, niceDate} from '../utils'
import {getRkey, niceDate, prettyNumber} from '../utils'
import {Container} from './container'
import {Embed} from './embed'
import {Link} from './link'
@@ -78,7 +78,7 @@ export function Post({thread}: Props) {
<div className="flex items-center gap-2 cursor-pointer">
<img src={likeIcon} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.likeCount}
{prettyNumber(post.likeCount)}
</p>
</div>
)}
@@ -86,7 +86,7 @@ export function Post({thread}: Props) {
<div className="flex items-center gap-2 cursor-pointer">
<img src={repostIcon} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.repostCount}
{prettyNumber(post.repostCount)}
</p>
</div>
)}
@@ -97,7 +97,7 @@ export function Post({thread}: Props) {
<div className="flex-1" />
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
{post.replyCount
? `Read ${post.replyCount} ${
? `Read ${prettyNumber(post.replyCount)} ${
post.replyCount > 1 ? 'replies' : 'reply'
} on Bluesky`
: `View on Bluesky`}
+10
View File
@@ -16,3 +16,13 @@ export function getRkey({uri}: {uri: string}): string {
const at = new AtUri(uri)
return at.rkey
}
const formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
roundingMode: 'trunc',
})
export function prettyNumber(number: number) {
return formatter.format(number)
}
+1 -1
View File
@@ -20,5 +20,5 @@
"jsxFragmentFactory": "Fragment",
"downlevelIteration": true
},
"include": ["src"]
"include": ["src", "vite.config.ts"]
}
+1 -1
View File
@@ -6,5 +6,5 @@
"strict": true,
"outDir": "dist"
},
"include": ["snippet"],
"include": ["snippet"]
}
+13 -13
View File
@@ -20,15 +20,15 @@
"@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.24"
"@atproto/api@0.13.1":
version "0.13.1"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0"
integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw==
"@atproto/api@0.13.6":
version "0.13.6"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5"
integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.6.0"
"@atproto/xrpc" "^0.6.1"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
@@ -59,10 +59,10 @@
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
"@atproto/xrpc@^0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c"
integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ==
"@atproto/xrpc@^0.6.1":
version "0.6.1"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
dependencies:
"@atproto/lexicon" "^0.4.1"
zod "^3.23.8"
@@ -4024,10 +4024,10 @@ typed-array-length@^1.0.6:
is-typed-array "^1.1.13"
possible-typed-array-names "^1.0.0"
typescript@^4.0.5:
version "4.9.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
typescript@^5.5.4:
version "5.5.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
uint8arrays@3.0.0:
version "3.0.0"
+1
View File
@@ -255,6 +255,7 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost)
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/quotes", server.WebGeneric)
// starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
+5
View File
@@ -253,6 +253,11 @@
from { opacity: 1; }
to { opacity: 0; }
}
.force-no-clicks > *,
.force-no-clicks * {
pointer-events: none !important;
}
</style>
</style>
{% include "scripts.html" %}
+25 -24
View File
@@ -79,32 +79,33 @@ export async function createServer(
plc: {port: port2},
})
// DISABLED - looks like dev-env added this and now it conflicts
// add the test mod authority
const agent = new BskyAgent({service: pdsUrl})
const res = await agent.api.com.atproto.server.createAccount({
email: 'mod-authority@test.com',
handle: 'mod-authority.test',
password: 'hunter2',
})
agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
await agent.api.app.bsky.actor.profile.create(
{repo: res.data.did},
{
displayName: 'Dev-env Moderation',
description: `The pretend version of mod.bsky.app`,
},
)
// const agent = new BskyAgent({service: pdsUrl})
// const res = await agent.api.com.atproto.server.createAccount({
// email: 'mod-authority@test.com',
// handle: 'mod-authority.test',
// password: 'hunter2',
// })
// agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
// await agent.api.app.bsky.actor.profile.create(
// {repo: res.data.did},
// {
// displayName: 'Dev-env Moderation',
// description: `The pretend version of mod.bsky.app`,
// },
// )
await agent.api.app.bsky.labeler.service.create(
{repo: res.data.did, rkey: 'self'},
{
policies: {
labelValues: ['!hide', '!warn'],
labelValueDefinitions: [],
},
createdAt: new Date().toISOString(),
},
)
// await agent.api.app.bsky.labeler.service.create(
// {repo: res.data.did, rkey: 'self'},
// {
// policies: {
// labelValues: ['!hide', '!warn'],
// labelValueDefinitions: [],
// },
// createdAt: new Date().toISOString(),
// },
// )
const pic = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.90.0",
"version": "1.91.0",
"private": true,
"engines": {
"node": ">=18"
@@ -32,7 +32,7 @@
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json",
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
"e2e:mock-server": "NODE_ENV=development ./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
"e2e:metro": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:metro-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:run": "maestro test __e2e__",
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "0.13.0",
"@atproto/api": "0.13.5",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -139,7 +139,7 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
"expo-video": "^1.2.4",
"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",
@@ -180,6 +180,7 @@
"react-native-image-crop-picker": "0.40.3",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.12.1",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^9.1.3",
"react-native-progress": "bluesky-social/react-native-progress",
@@ -199,7 +200,6 @@
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
"rn-fetch-blob": "^0.12.0",
"rn-tourguide": "bluesky-social/rn-tourguide",
"sentry-expo": "~7.0.1",
"statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7",
-135
View File
@@ -1,135 +0,0 @@
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
index 9905e13..47342ff 100644
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
setShowPreviousButton(!requireLinearPlayback)
setShowNextButton(!requireLinearPlayback)
setTimeBarInteractive(requireLinearPlayback)
+ setShowSubtitleButton(true)
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
+ val fullscreenButton =
+ findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
fullscreenButton?.visibility = if (visible) {
android.view.View.VISIBLE
} else {
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
index ec3da2a..5a1397a 100644
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
@@ -43,7 +43,9 @@ class VideoModule : Module() {
View(VideoView::class) {
Events(
"onPictureInPictureStart",
- "onPictureInPictureStop"
+ "onPictureInPictureStop",
+ "onEnterFullscreen",
+ "onExitFullscreen"
)
Prop("player") { view: VideoView, player: VideoPlayer ->
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
index a951d80..3932535 100644
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
@@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
val playerView: PlayerView = PlayerView(context.applicationContext)
val onPictureInPictureStart by EventDispatcher<Unit>()
val onPictureInPictureStop by EventDispatcher<Unit>()
+ val onEnterFullscreen by EventDispatcher()
+ val onExitFullscreen by EventDispatcher()
var willEnterPiP: Boolean = false
var isInFullscreen: Boolean = false
@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
@Suppress("DEPRECATION")
currentActivity.overridePendingTransition(0, 0)
}
+ onEnterFullscreen(mapOf())
isInFullscreen = true
}
@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
videoPlayer?.changePlayerView(playerView)
+ this.onExitFullscreen(mapOf())
isInFullscreen = false
}
diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
index cb9ca6d..60e9f4e 100644
--- a/node_modules/expo-video/build/VideoView.types.d.ts
+++ b/node_modules/expo-video/build/VideoView.types.d.ts
@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps {
* @platform ios 16.0+
*/
allowsVideoFrameAnalysis?: boolean;
+
+ onEnterFullscreen?: () => void;
+ onExitFullscreen?: () => void;
}
//# sourceMappingURL=VideoView.types.d.ts.map
diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift
index c537a12..e4a918f 100644
--- a/node_modules/expo-video/ios/VideoModule.swift
+++ b/node_modules/expo-video/ios/VideoModule.swift
@@ -16,7 +16,9 @@ public final class VideoModule: Module {
View(VideoView.self) {
Events(
"onPictureInPictureStart",
- "onPictureInPictureStop"
+ "onPictureInPictureStop",
+ "onEnterFullscreen",
+ "onExitFullscreen"
)
Prop("player") { (view, player: VideoPlayer?) in
diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift
index f4579e4..10c5908 100644
--- a/node_modules/expo-video/ios/VideoView.swift
+++ b/node_modules/expo-video/ios/VideoView.swift
@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
let onPictureInPictureStart = EventDispatcher()
let onPictureInPictureStop = EventDispatcher()
+ let onEnterFullscreen = EventDispatcher()
+ let onExitFullscreen = EventDispatcher()
public override var bounds: CGRect {
didSet {
@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
_ playerViewController: AVPlayerViewController,
willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
) {
+ onEnterFullscreen()
isFullscreen = true
}
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
if wasPlaying {
self.player?.pointer.play()
}
+ self.onExitFullscreen()
self.isFullscreen = false
}
}
diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
index 29fe5db..e1fbf59 100644
--- a/node_modules/expo-video/src/VideoView.types.ts
+++ b/node_modules/expo-video/src/VideoView.types.ts
@@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps {
* @platform ios 16.0+
*/
allowsVideoFrameAnalysis?: boolean;
+
+ onEnterFullscreen?: () => void;
+ onExitFullscreen?: () => void;
}
-6
View File
@@ -1,6 +0,0 @@
## uwu woad beawing, do not wemove
## `expo-video` Patch
This patch adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
the tin.
@@ -57,7 +57,7 @@ const withXcodeTarget = (config, {targetName}) => {
buildSettingsObj.SWIFT_VERSION = '5.0'
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1'
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
}
}
+10 -11
View File
@@ -2,7 +2,6 @@ const path = require('path')
const fs = require('fs')
const projectRoot = path.join(__dirname, '..')
const webBuildJs = path.join(projectRoot, 'web-build', 'static', 'js')
const templateFile = path.join(
projectRoot,
'bskyweb',
@@ -10,18 +9,18 @@ const templateFile = path.join(
'scripts.html',
)
const jsFiles = fs.readdirSync(webBuildJs).filter(name => name.endsWith('.js'))
jsFiles.sort((a, b) => {
// make sure main is written last
if (a.startsWith('main')) return 1
if (b.startsWith('main')) return -1
return a.localeCompare(b)
})
const {entrypoints} = require(path.join(
projectRoot,
'web-build/asset-manifest.json',
))
console.log(`Found ${jsFiles.length} js files in web-build`)
console.log(`Found ${entrypoints.length} entrypoints`)
console.log(`Writing ${templateFile}`)
const outputFile = jsFiles
.map(name => `<script defer="defer" src="/static/js/${name}"></script>`)
const outputFile = entrypoints
.map(name => {
const file = path.basename(name)
return `<script defer="defer" src="/static/js/${file}"></script>`
})
.join('\n')
fs.writeFileSync(templateFile, outputFile)
+24 -24
View File
@@ -50,8 +50,9 @@ 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 {TestCtrls} from '#/view/com/testing/TestCtrls'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
@@ -59,7 +60,6 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
@@ -122,10 +122,10 @@ function InnerApp() {
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<TourProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
@@ -133,10 +133,10 @@ function InnerApp() {
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
@@ -175,25 +175,25 @@ function App() {
<A11yProvider>
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</PrefsStateProvider>
</ShellStateProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</KeyboardProvider>
</A11yProvider>
+26 -26
View File
@@ -39,7 +39,8 @@ 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 {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
@@ -47,7 +48,6 @@ import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
function InnerApp() {
@@ -105,19 +105,19 @@ function InnerApp() {
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<TourProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</TourProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
@@ -153,25 +153,25 @@ function App() {
return (
<A11yProvider>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</PrefsStateProvider>
</ShellStateProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</A11yProvider>
)
+67 -58
View File
@@ -15,10 +15,12 @@ import {
StackActions,
} from '@react-navigation/native'
import {timeout} from 'lib/async/timeout'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {usePalette} from 'lib/hooks/usePalette'
import {buildStateObject} from 'lib/routes/helpers'
import {init as initAnalytics} from '#/lib/analytics/analytics'
import {timeout} from '#/lib/async/timeout'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {buildStateObject} from '#/lib/routes/helpers'
import {
AllNavigatorParams,
BottomTabNavigatorParams,
@@ -28,20 +30,62 @@ import {
MyProfileTabNavigatorParams,
NotificationsTabNavigatorParams,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {RouteParams, State} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings'
import {isAndroid, isNative, isWeb} from 'platform/detection'
} from '#/lib/routes/types'
import {RouteParams, State} from '#/lib/routes/types'
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
import {bskyTitle} from '#/lib/strings/headings'
import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {
shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from '#/state/shell/reminders'
import {AccessibilitySettingsScreen} from '#/view/screens/AccessibilitySettings'
import {AppPasswords} from '#/view/screens/AppPasswords'
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
import {DebugModScreen} from '#/view/screens/DebugMod'
import {FeedsScreen} from '#/view/screens/Feeds'
import {HomeScreen} from '#/view/screens/Home'
import {LanguageSettingsScreen} from '#/view/screens/LanguageSettings'
import {ListsScreen} from '#/view/screens/Lists'
import {LogScreen} from '#/view/screens/Log'
import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts'
import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
import {NotFoundScreen} from '#/view/screens/NotFound'
import {NotificationsScreen} from '#/view/screens/Notifications'
import {NotificationsSettingsScreen} from '#/view/screens/NotificationsSettings'
import {PostThreadScreen} from '#/view/screens/PostThread'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed'
import {PreferencesThreads} from '#/view/screens/PreferencesThreads'
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
import {ProfileScreen} from '#/view/screens/Profile'
import {ProfileFeedScreen} from '#/view/screens/ProfileFeed'
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
import {ProfileFollowersScreen} from '#/view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows'
import {ProfileListScreen} from '#/view/screens/ProfileList'
import {SavedFeeds} from '#/view/screens/SavedFeeds'
import {SearchScreen} from '#/view/screens/Search'
import {SettingsScreen} from '#/view/screens/Settings'
import {Storybook} from '#/view/screens/Storybook'
import {SupportScreen} from '#/view/screens/Support'
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesScreen} from '#/screens/Messages/List'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
@@ -50,51 +94,8 @@ import {
StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
import {router} from './routes'
import {MessagesConversationScreen} from './screens/Messages/Conversation'
import {MessagesScreen} from './screens/Messages/List'
import {MessagesSettingsScreen} from './screens/Messages/Settings'
import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {useSession} from './state/session'
import {
shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from './state/shell/reminders'
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
import {DebugModScreen} from './view/screens/DebugMod'
import {FeedsScreen} from './view/screens/Feeds'
import {HomeScreen} from './view/screens/Home'
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
import {ListsScreen} from './view/screens/Lists'
import {LogScreen} from './view/screens/Log'
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
import {NotFoundScreen} from './view/screens/NotFound'
import {NotificationsScreen} from './view/screens/Notifications'
import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {PostThreadScreen} from './view/screens/PostThread'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
import {ProfileScreen} from './view/screens/Profile'
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
import {ProfileListScreen} from './view/screens/ProfileList'
import {SearchScreen} from './view/screens/Search'
import {SettingsScreen} from './view/screens/Settings'
import {Storybook} from './view/screens/Storybook'
import {SupportScreen} from './view/screens/Support'
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -212,6 +213,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen
name="PostQuotes"
getComponent={() => PostQuotesScreen}
options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileFeed"
getComponent={() => ProfileFeedScreen}
@@ -482,6 +490,7 @@ function MyProfileTabNavigator() {
getComponent={() => ProfileScreen}
initialParams={{
name: 'me',
hideBackButton: true,
}}
/>
{commonScreens(MyProfileTab as typeof HomeTab)}
+28 -1
View File
@@ -1,9 +1,14 @@
import {Platform, StyleSheet} from 'react-native'
import {Platform, StyleSheet, ViewStyle} from 'react-native'
import * as tokens from '#/alf/tokens'
import {native, web} from '#/alf/util/platform'
export const atoms = {
debug: {
borderColor: 'red',
borderWidth: 1,
},
/*
* Positioning
*/
@@ -55,6 +60,19 @@ export const atoms = {
height: '100vh',
}),
/**
* Used for the outermost components on screens, to ensure that they can fill
* the screen and extend beyond.
*/
util_screen_outer: [
web({
minHeight: '100vh',
}),
native({
height: '100%',
}),
] as ViewStyle,
/*
* Theme-independent bg colors
*/
@@ -853,6 +871,7 @@ export const atoms = {
mr_auto: {
marginRight: 'auto',
},
/*
* Pointer events & user select
*/
@@ -871,6 +890,7 @@ export const atoms = {
user_select_all: {
userSelect: 'all',
},
/*
* Text decoration
*/
@@ -880,4 +900,11 @@ export const atoms = {
strike_through: {
textDecorationLine: 'line-through',
},
/*
* Display
*/
hidden: {
display: 'none',
},
} as const
+10 -53
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {Dimensions} from 'react-native'
import {useMediaQuery} from 'react-responsive'
import {createThemes, defaultTheme} from '#/alf/themes'
import {Theme, ThemeName} from '#/alf/types'
@@ -12,52 +12,15 @@ export * from '#/alf/util/flatten'
export * from '#/alf/util/platform'
export * from '#/alf/util/themeSelector'
type BreakpointName = keyof typeof breakpoints
/*
* Breakpoints
*/
const breakpoints: {
[key: string]: number
} = {
gtPhone: 500,
gtMobile: 800,
gtTablet: 1300,
}
function getActiveBreakpoints({width}: {width: number}) {
const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
breakpoint => width >= breakpoints[breakpoint],
)
return {
active: active[active.length - 1],
gtPhone: active.includes('gtPhone'),
gtMobile: active.includes('gtMobile'),
gtTablet: active.includes('gtTablet'),
}
}
/*
* Context
*/
export const Context = React.createContext<{
themeName: ThemeName
theme: Theme
breakpoints: {
active: BreakpointName | undefined
gtPhone: boolean
gtMobile: boolean
gtTablet: boolean
}
}>({
themeName: 'light',
theme: defaultTheme,
breakpoints: {
active: undefined,
gtPhone: false,
gtMobile: false,
gtTablet: false,
},
})
export function ThemeProvider({
@@ -74,18 +37,6 @@ export function ThemeProvider({
})
}, [])
const theme = themes[themeName]
const [breakpoints, setBreakpoints] = React.useState(() =>
getActiveBreakpoints({width: Dimensions.get('window').width}),
)
React.useEffect(() => {
const listener = Dimensions.addEventListener('change', ({window}) => {
const bp = getActiveBreakpoints({width: window.width})
if (bp.active !== breakpoints.active) setBreakpoints(bp)
})
return listener.remove
}, [breakpoints, setBreakpoints])
return (
<Context.Provider
@@ -93,9 +44,8 @@ export function ThemeProvider({
() => ({
themeName: themeName,
theme: theme,
breakpoints,
}),
[theme, themeName, breakpoints],
[theme, themeName],
)}>
{children}
</Context.Provider>
@@ -107,5 +57,12 @@ export function useTheme() {
}
export function useBreakpoints() {
return React.useContext(Context).breakpoints
const gtPhone = useMediaQuery({minWidth: 500})
const gtMobile = useMediaQuery({minWidth: 800})
const gtTablet = useMediaQuery({minWidth: 1300})
return {
gtPhone,
gtMobile,
gtTablet,
}
}
+4 -28
View File
@@ -2,21 +2,18 @@ import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/core'
import {StackActions} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {useGoBack} from 'lib/hooks/useGoBack'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import {router} from '#/routes'
export function Error({
title,
message,
onRetry,
onGoBack: onGoBackProp,
onGoBack,
hideBackButton,
sideBorders = true,
}: {
@@ -27,31 +24,10 @@ export function Error({
hideBackButton?: boolean
sideBorders?: boolean
}) {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const canGoBack = navigation.canGoBack()
const onGoBack = React.useCallback(() => {
if (onGoBackProp) {
onGoBackProp()
return
}
if (canGoBack) {
navigation.goBack()
} else {
navigation.navigate('HomeTab')
// Checking the state for routes ensures that web doesn't encounter errors while going back
if (navigation.getState()?.routes) {
navigation.dispatch(StackActions.push(...router.matchPath('/')))
} else {
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
}
}
}, [navigation, canGoBack, onGoBackProp])
const goBack = useGoBack(onGoBack)
return (
<CenteredView
@@ -96,7 +72,7 @@ export function Error({
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label={_(msg`Return to previous page`)}
onPress={onGoBack}
onPress={goBack}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
<ButtonText>
+56 -4
View File
@@ -1,18 +1,21 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide'
import * as userActionHistory from '#/state/userActionHistory'
@@ -173,14 +176,63 @@ function useExperimentalSuggestedUsersQuery() {
}
}
export function SuggestedFollows() {
const t = useTheme()
const {_} = useLingui()
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
const gate = useGate()
const [feedType, feedUri] = feed.split('|')
if (feedType === 'author') {
if (gate('show_follow_suggestions_in_profile')) {
return <SuggestedFollowsProfile did={feedUri} />
} else {
return null
}
} else {
return <SuggestedFollowsHome />
}
}
export function SuggestedFollowsProfile({did}: {did: string}) {
const {
isLoading: isSuggestionsLoading,
data,
error,
} = useSuggestedFollowsByActorQuery({
did,
})
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={data?.suggestions ?? []}
error={error}
/>
)
}
export function SuggestedFollowsHome() {
const {
isLoading: isSuggestionsLoading,
profiles,
error,
} = useExperimentalSuggestedUsersQuery()
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={profiles}
error={error}
/>
)
}
export function ProfileGrid({
isSuggestionsLoading,
error,
profiles,
}: {
isSuggestionsLoading: boolean
profiles: AppBskyActorDefs.ProfileViewDetailed[]
error: Error | null
}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
+42 -6
View File
@@ -1,13 +1,20 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
import {
AppBskyActorDefs,
AppBskyGraphDefs,
AtUri,
moderateUserList,
ModerationUI,
} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
import {useModerationOpts} from 'state/preferences/moderation-opts'
import {precacheList} from 'state/queries/feed'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf'
import {
Avatar,
Description,
@@ -16,6 +23,7 @@ import {
SaveButton,
} from '#/components/FeedCard'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography'
/*
@@ -43,6 +51,11 @@ type Props = {
export function Default(props: Props) {
const {view, showPinButton} = props
const moderationOpts = useModerationOpts()
const moderation = moderationOpts
? moderateUserList(view, moderationOpts)
: undefined
return (
<Link {...props}>
<Outer>
@@ -52,6 +65,7 @@ export function Default(props: Props) {
title={view.name}
creator={view.creator}
purpose={view.purpose}
modUi={moderation?.ui('contentView')}
/>
{showPinButton && view.purpose === CURATELIST && (
<SaveButton view={view} pin />
@@ -89,18 +103,40 @@ export function TitleAndByline({
title,
creator,
purpose = CURATELIST,
modUi,
}: {
title: string
creator?: AppBskyActorDefs.ProfileViewBasic
purpose?: AppBskyGraphDefs.ListView['purpose']
modUi?: ModerationUI
}) {
const t = useTheme()
const {currentAccount} = useSession()
return (
<View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
{title}
</Text>
<Hider.Outer
modui={modUi}
isContentVisibleInitialState={
creator && currentAccount?.did === creator.did
}
allowOverride={creator && currentAccount?.did === creator.did}>
<Hider.Mask>
<Text
style={[a.text_md, a.font_bold, a.leading_snug, a.italic]}
numberOfLines={1}>
<Trans>Hidden list</Trans>
</Text>
</Hider.Mask>
<Hider.Content>
<Text
style={[a.text_md, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{title}
</Text>
</Hider.Content>
</Hider.Outer>
{creator && (
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
+1 -1
View File
@@ -178,7 +178,7 @@ let ListMaybePlaceholder = ({
return (
<CenteredView
style={[
a.flex_1,
a.h_full_vh,
a.align_center,
!gtMobile ? a.justify_between : a.gap_5xl,
t.atoms.border_contrast_low,
+172
View File
@@ -0,0 +1,172 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedVideo,
} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {parseTenorGif} from '#/lib/strings/embed-player'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
/**
* Streamlined MediaPreview component which just handles images, gifs, and videos
*/
export function Embed({
embed,
style,
}: {
embed?:
| AppBskyEmbedImages.View
| AppBskyEmbedRecordWithMedia.View
| AppBskyEmbedExternal.View
| AppBskyEmbedVideo.View
| {[k: string]: unknown}
style?: StyleProp<ViewStyle>
}) {
let media = AppBskyEmbedRecordWithMedia.isView(embed) ? embed.media : embed
if (AppBskyEmbedImages.isView(media)) {
return (
<Outer style={style}>
{media.images.map(image => (
<ImageItem
key={image.thumb}
thumbnail={image.thumb}
alt={image.alt}
/>
))}
</Outer>
)
} else if (AppBskyEmbedExternal.isView(embed) && embed.external.thumb) {
let url: URL | undefined
try {
url = new URL(embed.external.uri)
} catch {}
if (url) {
const {success} = parseTenorGif(url)
if (success) {
return (
<Outer style={style}>
<GifItem
thumbnail={embed.external.thumb}
alt={embed.external.title}
/>
</Outer>
)
}
}
} else if (AppBskyEmbedVideo.isView(embed)) {
return (
<Outer style={style}>
<VideoItem thumbnail={embed.thumbnail} alt={embed.alt} />
</Outer>
)
}
return null
}
export function Outer({
children,
style,
}: {
children?: React.ReactNode
style?: StyleProp<ViewStyle>
}) {
return <View style={[a.flex_row, a.gap_xs, style]}>{children}</View>
}
export function ImageItem({
thumbnail,
alt,
children,
}: {
thumbnail: string
alt?: string
children?: React.ReactNode
}) {
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]}
contentFit="cover"
accessible={true}
accessibilityIgnoresInvertColors
accessibilityHint={alt}
accessibilityLabel=""
/>
{children}
</View>
)
}
export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
return (
<ImageItem thumbnail={thumbnail} alt={alt}>
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon size={24} />
</View>
<View style={styles.altContainer}>
<Text style={styles.alt}>
<Trans>GIF</Trans>
</Text>
</View>
</ImageItem>
)
}
export function VideoItem({
thumbnail,
alt,
}: {
thumbnail?: string
alt?: string
}) {
if (!thumbnail) {
return (
<View
style={[
{backgroundColor: 'black'},
a.flex_1,
{aspectRatio: 1, maxWidth: 100},
a.justify_center,
a.align_center,
]}>
<PlayButtonIcon size={24} />
</View>
)
}
return (
<ImageItem thumbnail={thumbnail} alt={alt}>
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon size={24} />
</View>
</ImageItem>
)
}
const styles = StyleSheet.create({
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
right: 5,
bottom: 5,
zIndex: 2,
},
alt: {
color: 'white',
fontSize: 7,
fontWeight: 'bold',
},
})
+10 -1
View File
@@ -13,6 +13,15 @@ import {
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export type AppModerationCause =
| ModerationCause
| {
type: 'reply-hidden'
source: {type: 'user'; did: string}
priority: 6
downgraded?: boolean
}
export type CommonProps = {
size?: 'sm' | 'lg'
}
@@ -40,7 +49,7 @@ export function Row({
}
export type LabelProps = {
cause: ModerationCause
cause: AppModerationCause
disableDetailsDialog?: boolean
noBg?: boolean
} & CommonProps
@@ -377,7 +377,7 @@ function Inner({
hide: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {_, i18n} = useLingui()
const {currentAccount} = useSession()
const moderation = React.useMemo(
() => moderateProfile(profile, moderationOpts),
@@ -393,8 +393,8 @@ function Inner({
profile.viewer?.blocking ||
profile.viewer?.blockedBy ||
profile.viewer?.blockingByList
const following = formatCount(profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0)
const following = formatCount(i18n, profile.followsCount || 0)
const followers = formatCount(i18n, profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower',
other: 'followers',
+5 -2
View File
@@ -8,7 +8,10 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
export {useDialogControl as usePromptControl} from '#/components/Dialog'
export {
type DialogControlProps as PromptControlProps,
useDialogControl as usePromptControl,
} from '#/components/Dialog'
const Context = React.createContext<{
titleId: string
@@ -23,7 +26,7 @@ export function Outer({
control,
testID,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
control: Dialog.DialogControlProps
testID?: string
}>) {
const {gtMobile} = useBreakpoints()
@@ -9,14 +9,15 @@ import {
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
import {isNative, isWeb} from 'platform/detection'
import {useListMembersQuery} from 'state/queries/list-members'
import {useAllListMembersQuery} from 'state/queries/list-members'
import {useSession} from 'state/session'
import {List, ListRef} from 'view/com/util/List'
import {SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf'
import {ListMaybePlaceholder} from '#/components/Lists'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
@@ -39,17 +40,20 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
ref,
) {
const t = useTheme()
const [initialHeaderHeight] = React.useState(headerHeight)
const bottomBarOffset = useBottomBarOffset(20)
const bottomBarOffset = useBottomBarOffset(200)
const initialNumToRender = useInitialNumToRender()
const {currentAccount} = useSession()
const {data, refetch, isError} = useListMembersQuery(listUri, 50)
const {data, refetch, isError} = useAllListMembersQuery(listUri)
const [isPTRing, setIsPTRing] = React.useState(false)
// The server returns these sorted by descending creation date, so we want to invert
const profiles = data?.pages
.flatMap(p => p.items.map(i => i.subject))
.filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler)
const profiles = data
?.filter(
p => !isBlockedOrBlocking(p.subject) && !p.subject.associated?.labeler,
)
.map(p => p.subject)
.reverse()
const isOwn = new AtUri(listUri).host === currentAccount?.did
@@ -99,7 +103,11 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
if (!data) {
return (
<View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}>
<View
style={[
a.h_full_vh,
{marginTop: headerHeight, marginBottom: bottomBarOffset},
]}>
<ListMaybePlaceholder
isLoading={true}
isError={isError}
@@ -118,10 +126,13 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
ref={scrollElRef}
headerOffset={headerHeight}
ListFooterComponent={
<View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
<ListFooter
style={{paddingBottom: bottomBarOffset, borderTopWidth: 0}}
/>
}
showsVerticalScrollIndicator={false}
desktopFixedHeight
initialNumToRender={initialNumToRender}
refreshing={isPTRing}
onRefresh={async () => {
setIsPTRing(true)
+18 -14
View File
@@ -59,20 +59,24 @@ export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
<QrCodeInner link={link} />
</View>
<View style={[a.flex_row, a.align_center, {gap: 5}]}>
<Text
style={[
a.font_bold,
a.text_center,
{color: 'white', fontSize: 18},
]}>
<Trans>on</Trans>
</Text>
<Logo width={26} fill="white" />
<View style={[{marginTop: 5, marginLeft: 2.5}]}>
<Logotype width={68} fill="white" />
</View>
</View>
<Text
style={[
a.flex,
a.flex_row,
a.align_center,
a.font_bold,
{color: 'white', fontSize: 18, gap: 6},
]}>
<Trans>
on
<View style={[a.flex_row, a.align_center, {gap: 6}]}>
<Logo width={25} fill="white" />
<View style={[{marginTop: 3.5}]}>
<Logotype width={72} fill="white" />
</View>
</View>
</Trans>
</Text>
</View>
</LinearGradientBackground>
</ViewShot>
@@ -7,6 +7,7 @@ import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
@@ -42,6 +43,7 @@ export function WizardEditListDialog({
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const initialNumToRender = useInitialNumToRender()
const listRef = useRef<BottomSheetFlatListMethods>(null)
@@ -148,6 +150,7 @@ export function WizardEditListDialog({
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag"
removeClippedSubviews={true}
initialNumToRender={initialNumToRender}
/>
</Dialog.Outer>
)
@@ -12,7 +12,7 @@ import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {DISCOVER_FEED_URI} from 'lib/constants'
import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {useSession} from 'state/session'
@@ -130,7 +130,8 @@ export function WizardProfileCard({
const isMe = profile.did === currentAccount?.did
const included = isMe || state.profiles.some(p => p.did === profile.did)
const disabled = isMe || (!included && state.profiles.length >= 49)
const disabled =
isMe || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName)
+129 -168
View File
@@ -1,39 +1,34 @@
import React from 'react'
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedGetPostThread,
AppBskyFeedPost,
AppBskyGraphDefs,
AtUri,
BskyAgent,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {createThreadgate} from '#/lib/api'
import {until} from '#/lib/async/until'
import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import {
ThreadgateSetting,
threadgateViewToSettings,
ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate'
import {useAgent} from '#/state/session'
import * as Toast from 'view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {
PostInteractionSettingsDialog,
usePrefetchPostInteractionSettings,
} from '#/components/dialogs/PostInteractionSettingsDialog'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {TextLink} from '../view/com/util/Link'
import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps {
@@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const t = useTheme()
const infoDialogControl = useDialogControl()
const editDialogControl = useDialogControl()
const agent = useAgent()
const queryClient = useQueryClient()
const settings = React.useMemo(
() => threadgateViewToSettings(post.threadgate),
[post],
)
const isRootPost = !('reply' in post.record)
/*
* `WhoCanReply` is only used for root posts atm, in case this changes
* unexpectedly, we should check to make sure it's for sure the root URI.
*/
const rootUri =
AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
? post.record.reply.root.uri
: post.uri
const settings = React.useMemo(() => {
return threadgateViewToAllowUISetting(post.threadgate)
}, [post.threadgate])
if (!isRootPost) {
return null
}
if (!settings.length && !isThreadAuthor) {
return null
}
const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
postUri: post.uri,
rootPostUri: rootUri,
})
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
const description = isEverybody
const anyoneCanReply =
settings.length === 1 && settings[0].type === 'everybody'
const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
const description = anyoneCanReply
? _(msg`Everybody can reply`)
: isNobody
: noOneCanReply
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
const onPressEdit = () => {
const onPressOpen = () => {
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
@@ -82,52 +80,23 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
}
}
const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
return
}
try {
if (newSettings.length) {
await createThreadgate(agent, post.uri, newSettings)
} else {
await agent.api.com.atproto.repo.deleteRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: new AtUri(post.uri).rkey,
})
}
await whenAppViewReady(agent, post.uri, res => {
const thread = res.data.thread
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
const fetchedSettings = threadgateViewToSettings(
thread.post.threadgate,
)
return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
}
return false
})
Toast.show(_(msg`Thread settings updated`))
queryClient.invalidateQueries({
queryKey: [POST_THREAD_RQKEY_ROOT],
})
} catch (err) {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
logger.error('Failed to edit threadgate', {message: err})
}
}
return (
<>
<Button
label={
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
}
onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
onPress={onPressOpen}
{...(isThreadAuthor
? Platform.select({
web: {
onHoverIn: prefetchPostInteractionSettings,
},
native: {
onPressIn: prefetchPostInteractionSettings,
},
})
: {})}
hitSlop={HITSLOP_10}>
{({hovered}) => (
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
@@ -145,22 +114,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
]}>
{description}
</Text>
{isThreadAuthor && (
<PencilLine width={12} fill={t.palette.primary_500} />
)}
</View>
)}
</Button>
<WhoCanReplyDialog
control={infoDialogControl}
post={post}
settings={settings}
/>
{isThreadAuthor && (
<ThreadgateEditorDialog
{isThreadAuthor ? (
<PostInteractionSettingsDialog
postUri={post.uri}
rootPostUri={rootUri}
control={editDialogControl}
threadgate={settings}
onConfirm={onEditConfirm}
initialThreadgateView={post.threadgate}
/>
) : (
<WhoCanReplyDialog
control={infoDialogControl}
post={post}
settings={settings}
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
/>
)}
</>
@@ -174,7 +148,7 @@ function Icon({
}: {
color: string
width?: number
settings: ThreadgateSetting[]
settings: ThreadgateAllowUISetting[]
}) {
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
@@ -186,79 +160,84 @@ function WhoCanReplyDialog({
control,
post,
settings,
embeddingDisabled,
}: {
control: Dialog.DialogControlProps
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<WhoCanReplyDialogInner post={post} settings={settings} />
</Dialog.Outer>
)
}
function WhoCanReplyDialogInner({
post,
settings,
}: {
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
settings: ThreadgateAllowUISetting[]
embeddingDisabled: boolean
}) {
const {_} = useLingui()
return (
<Dialog.ScrollableInner
label={_(msg`Who can reply dialog`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl]}>
<Trans>Who can reply?</Trans>
</Text>
<Rules post={post} settings={settings} />
</View>
</Dialog.ScrollableInner>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Dialog: adjust who can interact with this post`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
<Trans>Who can interact with this post?</Trans>
</Text>
<Rules
post={post}
settings={settings}
embeddingDisabled={embeddingDisabled}
/>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Rules({
post,
settings,
embeddingDisabled,
}: {
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
settings: ThreadgateAllowUISetting[]
embeddingDisabled: boolean
}) {
const t = useTheme()
return (
<Text
style={[
a.text_md,
a.leading_tight,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
{!settings.length ? (
<Trans>Everybody can reply</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this thread are disabled</Trans>
) : (
<Trans>
Only{' '}
{settings.map((rule, i) => (
<>
<Rule
key={`rule-${i}`}
rule={rule}
post={post}
lists={post.threadgate!.lists}
/>
<Separator key={`sep-${i}`} i={i} length={settings.length} />
</>
))}{' '}
can reply
</Trans>
<>
<Text
style={[
a.text_sm,
a.leading_snug,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
{settings[0].type === 'everybody' ? (
<Trans>Everybody can reply to this post.</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this post are disabled.</Trans>
) : (
<Trans>
Only{' '}
{settings.map((rule, i) => (
<React.Fragment key={`rule-${i}`}>
<Rule rule={rule} post={post} lists={post.threadgate!.lists} />
<Separator i={i} length={settings.length} />
</React.Fragment>
))}{' '}
can reply.
</Trans>
)}{' '}
</Text>
{embeddingDisabled && (
<Text
style={[
a.text_sm,
a.leading_snug,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
<Trans>No one but the author can quote this post.</Trans>
</Text>
)}
</Text>
</>
)
}
@@ -267,11 +246,10 @@ function Rule({
post,
lists,
}: {
rule: ThreadgateSetting
rule: ThreadgateAllowUISetting
post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) {
const t = useTheme()
if (rule.type === 'mention') {
return <Trans>mentioned users</Trans>
}
@@ -279,12 +257,12 @@ function Rule({
return (
<Trans>
users followed by{' '}
<TextLink
type="sm"
href={makeProfileLink(post.author)}
text={`@${post.author.handle}`}
style={{color: t.palette.primary_500}}
/>
<InlineLinkText
label={`@${post.author.handle}`}
to={makeProfileLink(post.author)}
style={[a.text_sm, a.leading_snug]}>
@{post.author.handle}
</InlineLinkText>
</Trans>
)
}
@@ -294,12 +272,12 @@ function Rule({
const listUrip = new AtUri(list.uri)
return (
<Trans>
<TextLink
type="sm"
href={makeListLink(listUrip.hostname, listUrip.rkey)}
text={list.name}
style={{color: t.palette.primary_500}}
/>{' '}
<InlineLinkText
label={list.name}
to={makeListLink(listUrip.hostname, listUrip.rkey)}
style={[a.text_sm, a.leading_snug]}>
{list.name}
</InlineLinkText>{' '}
members
</Trans>
)
@@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) {
}
return <>, </>
}
async function whenAppViewReady(
agent: BskyAgent,
uri: string,
fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
) {
await until(
5, // 5 tries
1e3, // 1s delay between tries
fn,
() =>
agent.app.bsky.feed.getPostThread({
uri,
depth: 0,
}),
)
}
+3 -3
View File
@@ -43,7 +43,7 @@ function EmbedDialogInner({
timestamp,
}: Omit<EmbedDialogProps, 'control'>) {
const t = useTheme()
const {_} = useLingui()
const {_, i18n} = useLingui()
const ref = useRef<TextInput>(null)
const [copied, setCopied] = useState(false)
@@ -86,9 +86,9 @@ function EmbedDialogInner({
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
postAuthor.handle,
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(timestamp),
niceDate(i18n, timestamp),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [postUri, postCid, record, timestamp, postAuthor])
}, [i18n, postUri, postCid, record, timestamp, postAuthor])
return (
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
@@ -0,0 +1,538 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import isEqual from 'lodash.isequal'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {useMyListsQuery} from '#/state/queries/my-lists'
import {
createPostgateQueryKey,
getPostgateRecord,
usePostgateQuery,
useWritePostgateMutation,
} from '#/state/queries/postgate'
import {
createPostgateRecord,
embeddingRules,
} from '#/state/queries/postgate/util'
import {
createThreadgateViewQueryKey,
getThreadgateView,
ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
useSetThreadgateAllowMutation,
useThreadgateViewQuery,
} from '#/state/queries/threadgate'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export type PostInteractionSettingsFormProps = {
onSave: () => void
isSaving?: boolean
postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
threadgateAllowUISettings: ThreadgateAllowUISetting[]
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
replySettingsDisabled?: boolean
}
export function PostInteractionSettingsControlledDialog({
control,
...rest
}: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
<PostInteractionSettingsForm {...rest} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
export type PostInteractionSettingsDialogProps = {
control: Dialog.DialogControlProps
/**
* URI of the post to edit the interaction settings for. Could be a root post
* or could be a reply.
*/
postUri: string
/**
* The URI of the root post in the thread. Used to determine if the viewer
* owns the threadgate record and can therefore edit it.
*/
rootPostUri: string
/**
* Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we
* happen to have one before opening the settings dialog.
*/
initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
}
export function PostInteractionSettingsDialog(
props: PostInteractionSettingsDialogProps,
) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<PostInteractionSettingsDialogControlledInner {...props} />
</Dialog.Outer>
)
}
export function PostInteractionSettingsDialogControlledInner(
props: PostInteractionSettingsDialogProps,
) {
const {_} = useLingui()
const {currentAccount} = useSession()
const [isSaving, setIsSaving] = React.useState(false)
const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
useThreadgateViewQuery({postUri: props.rootPostUri})
const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({
postUri: props.postUri,
})
const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
const [editedPostgate, setEditedPostgate] =
React.useState<AppBskyFeedPostgate.Record>()
const [editedAllowUISettings, setEditedAllowUISettings] =
React.useState<ThreadgateAllowUISetting[]>()
const isLoading = isLoadingThreadgate || isLoadingPostgate
const threadgateView = threadgateViewLoaded || props.initialThreadgateView
const isThreadgateOwnedByViewer = React.useMemo(() => {
return currentAccount?.did === new AtUri(props.rootPostUri).host
}, [props.rootPostUri, currentAccount?.did])
const postgateValue = React.useMemo(() => {
return (
editedPostgate || postgate || createPostgateRecord({post: props.postUri})
)
}, [postgate, editedPostgate, props.postUri])
const allowUIValue = React.useMemo(() => {
return (
editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
)
}, [threadgateView, editedAllowUISettings])
const onSave = React.useCallback(async () => {
if (!editedPostgate && !editedAllowUISettings) {
props.control.close()
return
}
setIsSaving(true)
try {
const requests = []
if (editedPostgate) {
requests.push(
writePostgateRecord({
postUri: props.postUri,
postgate: editedPostgate,
}),
)
}
if (editedAllowUISettings && isThreadgateOwnedByViewer) {
requests.push(
setThreadgateAllow({
postUri: props.rootPostUri,
allow: editedAllowUISettings,
}),
)
}
await Promise.all(requests)
props.control.close()
} catch (e: any) {
logger.error(`Failed to save post interaction settings`, {
context: 'PostInteractionSettingsDialogControlledInner',
safeMessage: e.message,
})
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
} finally {
setIsSaving(false)
}
}, [
_,
props.postUri,
props.rootPostUri,
props.control,
editedPostgate,
editedAllowUISettings,
setIsSaving,
writePostgateRecord,
setThreadgateAllow,
isThreadgateOwnedByViewer,
])
return (
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
{isLoading ? (
<Loader size="xl" />
) : (
<PostInteractionSettingsForm
replySettingsDisabled={!isThreadgateOwnedByViewer}
isSaving={isSaving}
onSave={onSave}
postgate={postgateValue}
onChangePostgate={setEditedPostgate}
threadgateAllowUISettings={allowUIValue}
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
/>
)}
</Dialog.ScrollableInner>
)
}
export function PostInteractionSettingsForm({
onSave,
isSaving,
postgate,
onChangePostgate,
threadgateAllowUISettings,
onChangeThreadgateAllowUISettings,
replySettingsDisabled,
}: PostInteractionSettingsFormProps) {
const t = useTheme()
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = React.useState(
!(
postgate.embeddingRules &&
postgate.embeddingRules.find(
v => v.$type === embeddingRules.disableRule.$type,
)
),
)
const onPressAudience = (setting: ThreadgateAllowUISetting) => {
// remove boolean values
let newSelected: ThreadgateAllowUISetting[] =
threadgateAllowUISettings.filter(
v => v.type !== 'nobody' && v.type !== 'everybody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
newSelected.push(setting)
} else {
newSelected.splice(i, 1)
}
onChangeThreadgateAllowUISettings(newSelected)
}
const onChangeQuotesEnabled = React.useCallback(
(enabled: boolean) => {
setQuotesEnabled(enabled)
onChangePostgate(
createPostgateRecord({
...postgate,
embeddingRules: enabled ? [] : [embeddingRules.disableRule],
}),
)
},
[setQuotesEnabled, postgate, onChangePostgate],
)
const noOneCanReply = !!threadgateAllowUISettings.find(
v => v.type === 'nobody',
)
return (
<View>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Post interaction settings</Trans>
</Text>
<View style={[a.gap_lg]}>
<Text style={[a.text_md]}>
<Trans>Customize who can interact with this post.</Trans>
</Text>
<Divider />
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Quote settings</Trans>
</Text>
<Toggle.Item
name="quoteposts"
type="checkbox"
label={
quotesEnabled
? _(msg`Click to disable quote posts of this post.`)
: _(msg`Click to enable quote posts of this post.`)
}
value={quotesEnabled}
onChange={onChangeQuotesEnabled}
style={[, a.justify_between, a.pt_xs]}>
<Text style={[t.atoms.text_contrast_medium]}>
{quotesEnabled ? (
<Trans>Quote posts enabled</Trans>
) : (
<Trans>Quote posts disabled</Trans>
)}
</Text>
<Toggle.Switch />
</Toggle.Item>
</View>
<Divider />
{replySettingsDisabled && (
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
a.flex_row,
a.align_center,
a.gap_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo fill={t.atoms.text_contrast_low.color} />
<Text
style={[
a.flex_1,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Reply settings are chosen by the author of the thread
</Trans>
</Text>
</View>
)}
<View
style={[
a.gap_sm,
{
opacity: replySettingsDisabled ? 0.3 : 1,
},
]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Reply settings</Trans>
</Text>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Allow replies from:</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={
!!threadgateAllowUISettings.find(v => v.type === 'everybody')
}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'everybody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={noOneCanReply}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
</View>
{!noOneCanReply && (
<>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'mention',
)
}
onPress={() => onPressAudience({type: 'mention'})}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'following',
)
}
onPress={() => onPressAudience({type: 'following'})}
disabled={replySettingsDisabled}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'list' && v.list === list.uri,
)
}
onPress={() =>
onPressAudience({type: 'list', list: list.uri})
}
disabled={replySettingsDisabled}
/>
))
: // No loading states to avoid jumps for the common case (no lists)
null}
</View>
</>
)}
</View>
</View>
</View>
<Button
label={_(msg`Save`)}
onPress={onSave}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid"
style={a.mt_xl}>
<ButtonText>{_(msg`Save`)}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} position="right" />}
</Button>
</View>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
disabled,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
disabled?: boolean
}) {
const t = useTheme()
return (
<Button
disabled={disabled}
onPress={onPress}
label={label}
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{height: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
)
}
export function usePrefetchPostInteractionSettings({
postUri,
rootPostUri,
}: {
postUri: string
rootPostUri: string
}) {
const queryClient = useQueryClient()
const agent = useAgent()
return React.useCallback(async () => {
try {
await Promise.all([
queryClient.prefetchQuery({
queryKey: createPostgateQueryKey(postUri),
queryFn: () => getPostgateRecord({agent, postUri}),
staleTime: STALE.SECONDS.THIRTY,
}),
queryClient.prefetchQuery({
queryKey: createThreadgateViewQueryKey(rootPostUri),
queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
staleTime: STALE.SECONDS.THIRTY,
}),
])
} catch (e: any) {
logger.error(`Failed to prefetch post interaction settings`, {
safeMessage: e.message,
})
}
}, [queryClient, agent, postUri, rootPostUri])
}
-217
View File
@@ -1,217 +0,0 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import isEqual from 'lodash.isequal'
import {useMyListsQuery} from '#/state/queries/my-lists'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Text} from '#/components/Typography'
interface ThreadgateEditorDialogProps {
control: Dialog.DialogControlProps
threadgate: ThreadgateSetting[]
onChange?: (v: ThreadgateSetting[]) => void
onConfirm?: (v: ThreadgateSetting[]) => void
}
export function ThreadgateEditorDialog({
control,
threadgate,
onChange,
onConfirm,
}: ThreadgateEditorDialogProps) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<DialogContent
seedThreadgate={threadgate}
onChange={onChange}
onConfirm={onConfirm}
/>
</Dialog.Outer>
)
}
function DialogContent({
seedThreadgate,
onChange,
onConfirm,
}: {
seedThreadgate: ThreadgateSetting[]
onChange?: (v: ThreadgateSetting[]) => void
onConfirm?: (v: ThreadgateSetting[]) => void
}) {
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [draft, setDraft] = React.useState(seedThreadgate)
const [prevSeedThreadgate, setPrevSeedThreadgate] =
React.useState(seedThreadgate)
if (seedThreadgate !== prevSeedThreadgate) {
// New data flowed from above (e.g. due to update coming through).
setPrevSeedThreadgate(seedThreadgate)
setDraft(seedThreadgate) // Reset draft.
}
function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
setDraft(nextThreadgate)
onChange?.(nextThreadgate)
}
const onPressEverybody = () => {
updateThreadgate([])
}
const onPressNobody = () => {
updateThreadgate([{type: 'nobody'}])
}
const onPressAudience = (setting: ThreadgateSetting) => {
// remove nobody
let newSelected: ThreadgateSetting[] = draft.filter(
v => v.type !== 'nobody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
newSelected.push(setting)
} else {
newSelected.splice(i, 1)
}
updateThreadgate(newSelected)
}
const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`)
return (
<Dialog.ScrollableInner
label={_(msg`Choose who can reply`)}
style={[{maxWidth: 500}, a.w_full]}>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Choose who can reply</Trans>
</Text>
<Text style={a.mt_xs}>
<Trans>Either choose "Everybody" or "Nobody"</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={draft.length === 0}
onPress={onPressEverybody}
style={{flex: 1}}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={!!draft.find(v => v.type === 'nobody')}
onPress={onPressNobody}
style={{flex: 1}}
/>
</View>
<Text style={a.mt_md}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={!!draft.find(v => v.type === 'mention')}
onPress={() => onPressAudience({type: 'mention'})}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={!!draft.find(v => v.type === 'following')}
onPress={() => onPressAudience({type: 'following'})}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!draft.find(v => v.type === 'list' && v.list === list.uri)
}
onPress={() =>
onPressAudience({type: 'list', list: list.uri})
}
/>
))
: // No loading states to avoid jumps for the common case (no lists)
null}
</View>
</View>
<Button
label={doneLabel}
onPress={() => {
control.close()
onConfirm?.(draft)
}}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid"
style={a.mt_xl}>
<ButtonText>{doneLabel}</ButtonText>
</Button>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<Button
onPress={onPress}
label={label}
accessibilityHint="Select this option"
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{height: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
)
}
+6 -5
View File
@@ -11,6 +11,7 @@ import {
ChatBskyConvoDefs,
RichText as RichTextAPI,
} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -153,14 +154,14 @@ let MessageItemMetadata = ({
)
const relativeTimestamp = useCallback(
(timestamp: string) => {
(i18n: I18n, timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const time = new Intl.DateTimeFormat(undefined, {
const time = i18n.date(date, {
hour: 'numeric',
minute: 'numeric',
}).format(date)
})
const diff = now.getTime() - date.getTime()
@@ -182,13 +183,13 @@ let MessageItemMetadata = ({
return _(msg`Yesterday, ${time}`)
}
return new Intl.DateTimeFormat(undefined, {
return i18n.date(date, {
hour: 'numeric',
minute: 'numeric',
day: 'numeric',
month: 'numeric',
year: 'numeric',
}).format(date)
})
},
[_],
)
+6 -2
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api'
import {PostEmbeds} from '#/view/com/util/post-embeds'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, useTheme} from '#/alf'
let MessageItemEmbed = ({
@@ -14,7 +14,11 @@ let MessageItemEmbed = ({
return (
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds embed={embed} allowNestedQuotes />
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
)
}
@@ -1,12 +1,12 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {useLingui} from '@lingui/react'
import {android, atoms as a, useTheme, web} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import {Text} from '#/components/Typography'
import {localizeDate} from './utils'
// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
// iOS: open a dialog with an inline date picker
@@ -25,6 +25,7 @@ export function DateFieldButton({
isInvalid?: boolean
accessibilityHint?: string
}) {
const {i18n} = useLingui()
const t = useTheme()
const {
@@ -91,7 +92,7 @@ export function DateFieldButton({
t.atoms.text,
{lineHeight: a.text_md.fontSize * 1.1875},
]}>
{localizeDate(value)}
{i18n.date(value, {timeZone: 'UTC'})}
</Text>
</Pressable>
</View>
-11
View File
@@ -1,16 +1,5 @@
import {getLocales} from 'expo-localization'
const LOCALE = getLocales()[0]
// we need the date in the form yyyy-MM-dd to pass to the input
export function toSimpleDateString(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return _date.toISOString().split('T')[0]
}
export function localizeDate(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat(LOCALE.languageTag, {
timeZone: 'UTC',
}).format(_date)
}
+2 -2
View File
@@ -5,10 +5,10 @@ export function useInteractionState() {
const onIn = React.useCallback(() => {
setState(true)
}, [setState])
}, [])
const onOut = React.useCallback(() => {
setState(false)
}, [setState])
}, [])
return React.useMemo(
() => ({
+1 -1
View File
@@ -2,7 +2,7 @@ import {useEffect, useRef, useState} from 'react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
export function useThrottledValue<T>(value: T, time?: number) {
export function useThrottledValue<T>(value: T, time: number) {
const pendingValueRef = useRef(value)
const [throttledValue, setThrottledValue] = useState(value)
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Crop_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Eye_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
})
+1
View File
@@ -19,6 +19,7 @@ export const sizes = {
md: 20,
lg: 24,
xl: 28,
'2xl': 32,
}
export function useCommonSVGProps(props: Props) {
+89
View File
@@ -0,0 +1,89 @@
import React from 'react'
import {ModerationUI} from '@atproto/api'
import {
ModerationCauseDescription,
useModerationCauseDescription,
} from '#/lib/moderation/useModerationCauseDescription'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
type Context = {
isContentVisible: boolean
setIsContentVisible: (show: boolean) => void
info: ModerationCauseDescription
showInfoDialog: () => void
meta: {
isNoPwi: boolean
allowOverride: boolean
}
}
const Context = React.createContext<Context>({} as Context)
export const useHider = () => React.useContext(Context)
export function Outer({
modui,
isContentVisibleInitialState,
allowOverride,
children,
}: React.PropsWithChildren<{
isContentVisibleInitialState?: boolean
allowOverride?: boolean
modui: ModerationUI | undefined
}>) {
const control = useModerationDetailsDialogControl()
const blur = modui?.blurs[0]
const [isContentVisible, setIsContentVisible] = React.useState(
isContentVisibleInitialState || !blur,
)
const info = useModerationCauseDescription(blur)
const meta = {
isNoPwi: Boolean(
modui?.blurs.find(
cause =>
cause.type === 'label' &&
cause.labelDef.identifier === '!no-unauthenticated',
),
),
allowOverride: allowOverride ?? !modui?.noOverride,
}
const showInfoDialog = () => {
control.open()
}
const onSetContentVisible = (show: boolean) => {
if (!meta.allowOverride) return
setIsContentVisible(show)
}
const ctx = {
isContentVisible,
setIsContentVisible: onSetContentVisible,
showInfoDialog,
info,
meta,
}
return (
<Context.Provider value={ctx}>
{children}
<ModerationDetailsDialog control={control} modcause={blur} />
</Context.Provider>
)
}
export function Content({children}: {children: React.ReactNode}) {
const ctx = useHider()
return ctx.isContentVisible ? children : null
}
export function Mask({children}: {children: React.ReactNode}) {
const ctx = useHider()
return ctx.isContentVisible ? null : children
}
+5 -6
View File
@@ -14,19 +14,18 @@ import {
} from '#/components/moderation/LabelsOnMeDialog'
export function LabelsOnMe({
details,
type,
labels,
size,
style,
}: {
details: {did: string} | {uri: string; cid: string}
type: 'account' | 'content'
labels: ComAtprotoLabelDefs.Label[] | undefined
size?: ButtonSize
style?: StyleProp<ViewStyle>
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const isAccount = 'did' in details
const control = useLabelsOnMeDialogControl()
if (!labels || !currentAccount) {
@@ -39,7 +38,7 @@ export function LabelsOnMe({
return (
<View style={[a.flex_row, style]}>
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
<LabelsOnMeDialog control={control} labels={labels} type={type} />
<Button
variant="solid"
@@ -51,7 +50,7 @@ export function LabelsOnMe({
}}>
<ButtonIcon position="left" icon={CircleInfo} />
<ButtonText style={[a.leading_snug]}>
{isAccount ? (
{type === 'account' ? (
<Plural
value={labels.length}
one="# label has been placed on this account"
@@ -82,6 +81,6 @@ export function LabelsOnMyPost({
return null
}
return (
<LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} />
<LabelsOnMe type="content" labels={post.labels} size="tiny" style={style} />
)
}
+6 -15
View File
@@ -5,6 +5,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {useLabelSubject} from '#/lib/moderation'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -18,21 +19,13 @@ import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {Divider} from '../Divider'
import {Loader} from '../Loader'
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
type Subject =
| {
uri: string
cid: string
}
| {
did: string
}
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
export interface LabelsOnMeDialogProps {
control: Dialog.DialogOuterProps['control']
subject: Subject
labels: ComAtprotoLabelDefs.Label[]
type: 'account' | 'content'
}
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
@@ -51,8 +44,8 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
const [appealingLabel, setAppealingLabel] = React.useState<
ComAtprotoLabelDefs.Label | undefined
>(undefined)
const {subject, labels} = props
const isAccount = 'did' in subject
const {labels} = props
const isAccount = props.type === 'account'
const containsSelfLabel = React.useMemo(
() => labels.some(l => l.src === currentAccount?.did),
[currentAccount?.did, labels],
@@ -68,7 +61,6 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
{appealingLabel ? (
<AppealForm
label={appealingLabel}
subject={subject}
control={props.control}
onPressBack={() => setAppealingLabel(undefined)}
/>
@@ -188,12 +180,10 @@ function Label({
function AppealForm({
label,
subject,
control,
onPressBack,
}: {
label: ComAtprotoLabelDefs.Label
subject: Subject
control: Dialog.DialogOuterProps['control']
onPressBack: () => void
}) {
@@ -201,6 +191,7 @@ function AppealForm({
const {labeler, strings} = useLabelInfo(label)
const {gtMobile} = useBreakpoints()
const [details, setDetails] = React.useState('')
const {subject} = useLabelSubject({label})
const isAccountReport = 'did' in subject
const agent = useAgent()
const sourceName = labeler
@@ -8,17 +8,19 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
import {makeProfileLink} from '#/lib/routes/links'
import {listUriToHref} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {InlineLinkText} from '#/components/Link'
import {AppModerationCause} from '#/components/Pills'
import {Text} from '#/components/Typography'
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
export interface ModerationDetailsDialogProps {
control: Dialog.DialogOuterProps['control']
modcause: ModerationCause
modcause?: ModerationCause | AppModerationCause
}
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
@@ -39,6 +41,7 @@ function ModerationDetailsDialogInner({
const t = useTheme()
const {_} = useLingui()
const desc = useModerationCauseDescription(modcause)
const {currentAccount} = useSession()
let name
let description
@@ -105,6 +108,14 @@ function ModerationDetailsDialogInner({
} else if (modcause.type === 'hidden') {
name = _(msg`Post Hidden by You`)
description = _(msg`You have hidden this post.`)
} else if (modcause.type === 'reply-hidden') {
const isYou = currentAccount?.did === modcause.source.did
name = isYou
? _(msg`Reply Hidden by You`)
: _(msg`Reply Hidden by Thread Author`)
description = isYou
? _(msg`You hid this reply.`)
: _(msg`The author of this thread has hidden this reply.`)
} else if (modcause.type === 'label') {
name = desc.name
description = desc.description
@@ -119,12 +130,12 @@ function ModerationDetailsDialogInner({
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
{name}
</Text>
<Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}>
<Text style={[t.atoms.text, a.text_md, a.leading_snug]}>
{description}
</Text>
{modcause.type === 'label' && (
<>
{modcause?.type === 'label' && (
<View style={[a.pt_lg]}>
<Divider />
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
{modcause.source.type === 'user' ? (
@@ -143,7 +154,7 @@ function ModerationDetailsDialogInner({
</Trans>
)}
</Text>
</>
</View>
)}
{isNative && <View style={{height: 40}} />}
+12 -2
View File
@@ -1,6 +1,6 @@
import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {ModerationCause, ModerationUI} from '@atproto/api'
import {getModerationCauseKey} from '#/lib/moderation'
import * as Pills from '#/components/Pills'
@@ -9,13 +9,15 @@ export function PostAlerts({
modui,
size = 'sm',
style,
additionalCauses,
}: {
modui: ModerationUI
size?: Pills.CommonProps['size']
includeMute?: boolean
style?: StyleProp<ViewStyle>
additionalCauses?: ModerationCause[] | Pills.AppModerationCause[]
}) {
if (!modui.alert && !modui.inform) {
if (!modui.alert && !modui.inform && !additionalCauses?.length) {
return null
}
@@ -37,6 +39,14 @@ export function PostAlerts({
noBg={size === 'sm'}
/>
))}
{additionalCauses?.map(cause => (
<Pills.Label
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
noBg={size === 'sm'}
/>
))}
</Pills.Row>
)
}
+25
View File
@@ -0,0 +1,25 @@
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'
export function PlayButtonIcon({size = 44}: {size?: number}) {
const t = useTheme()
return (
<View
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{
backgroundColor: t.palette.primary_500,
width: size + 16,
height: size + 16,
},
]}>
<PlayIcon height={size} width={size} style={{color: 'white'}} />
</View>
)
}
+41 -20
View File
@@ -81,7 +81,15 @@ export class FeedViewPostsSlice {
isParentBlocked,
isParentNotFound,
})
if (!reply || reason) {
if (!reply) {
if (post.record.reply) {
// This reply wasn't properly hydrated by the AppView.
this.isOrphan = true
this.items[0].isParentNotFound = true
}
return
}
if (reason) {
return
}
if (
@@ -263,7 +271,12 @@ export class FeedTuner {
}
} else {
if (!dryRun) {
this.seenUris.add(item.post.uri)
// Reposting a reply elevates it to top-level, so its parent/root won't be displayed.
// Disable in-thread dedupe for this case since we don't want to miss them later.
const disableDedupe = slice.isReply && slice.isRepost
if (!disableDedupe) {
this.seenUris.add(item.post.uri)
}
}
}
}
@@ -366,11 +379,7 @@ export class FeedTuner {
): FeedViewPostsSlice[] => {
for (let i = 0; i < slices.length; i++) {
const slice = slices[i]
if (
slice.isReply &&
!slice.isRepost &&
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
) {
if (slice.isReply && !shouldDisplayReplyInFollowing(slice, userDid)) {
slices.splice(i, 1)
i--
}
@@ -392,27 +401,20 @@ export class FeedTuner {
slices: FeedViewPostsSlice[],
_dryRun: boolean,
): FeedViewPostsSlice[] => {
const candidateSlices = slices.slice()
// early return if no languages have been specified
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
return slices
}
for (let i = 0; i < slices.length; i++) {
let hasPreferredLang = false
for (const item of slices[i].items) {
const candidateSlices = slices.filter(slice => {
for (const item of slice.items) {
if (isPostInLanguage(item.post, preferredLangsCode2)) {
hasPreferredLang = true
break
return true
}
}
// if item does not fit preferred language, remove it
if (!hasPreferredLang) {
candidateSlices.splice(i, 1)
}
}
return false
})
// if the language filter cleared out the entire page, return the original set
// so that something always shows
@@ -441,9 +443,13 @@ function areSameAuthor(authors: AuthorContext): boolean {
}
function shouldDisplayReplyInFollowing(
authors: AuthorContext,
slice: FeedViewPostsSlice,
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.
@@ -457,6 +463,21 @@ 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 &&
+104 -61
View File
@@ -1,17 +1,26 @@
import {
AppBskyEmbedDefs,
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedThreadgate,
AppBskyEmbedVideo,
AppBskyFeedPostgate,
AtUri,
BlobRef,
BskyAgent,
ComAtprotoLabelDefs,
RichText,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {logger} from '#/logger'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {writePostgateRecord} from '#/state/queries/postgate'
import {
createThreadgateRecord,
ThreadgateAllowUISetting,
threadgateAllowUISettingToAllowRecordValue,
writeThreadgateRecord,
} from '#/state/queries/threadgate'
import {isNetworkError} from 'lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
import {isNative} from 'platform/detection'
@@ -38,13 +47,16 @@ interface PostOpts {
cid: string
}
video?: {
uri: string
cid: string
blobRef: BlobRef
altText: string
captions: {lang: string; file: File}[]
aspectRatio?: AppBskyEmbedDefs.AspectRatio
}
extLink?: ExternalEmbedDraft
images?: ImageModel[]
labels?: string[]
threadgate?: ThreadgateSetting[]
threadgate: ThreadgateAllowUISetting[]
postgate: AppBskyFeedPostgate.Record
onStateChange?: (state: string) => void
langs?: string[]
}
@@ -54,18 +66,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
| AppBskyEmbedImages.Main
| AppBskyEmbedExternal.Main
| AppBskyEmbedRecord.Main
| AppBskyEmbedVideo.Main
| AppBskyEmbedRecordWithMedia.Main
| undefined
let reply
let rt = new RichText(
{text: opts.rawText.trimEnd()},
{
cleanNewlines: true,
},
)
let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true})
opts.onStateChange?.('Processing...')
await rt.detectFacets(agent)
rt = shortenLinks(rt)
rt = stripInvalidMentions(rt)
@@ -122,6 +132,41 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
}
}
// add video embed if present
if (opts.video) {
const captions = await Promise.all(
opts.video.captions
.filter(caption => caption.lang !== '')
.map(async caption => {
const {data} = await agent.uploadBlob(caption.file, {
encoding: 'text/vtt',
})
return {lang: caption.lang, file: data.blob}
}),
)
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.recordWithMedia',
record: embed,
media: {
$type: 'app.bsky.embed.video',
video: opts.video.blobRef,
alt: opts.video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
} as AppBskyEmbedVideo.Main,
} as AppBskyEmbedRecordWithMedia.Main
} else {
embed = {
$type: 'app.bsky.embed.video',
video: opts.video.blobRef,
alt: opts.video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
} as AppBskyEmbedVideo.Main
}
}
// add external embed if present
if (opts.extLink && !opts.images?.length) {
if (opts.extLink.embed) {
@@ -232,7 +277,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
labels,
})
} catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`)
logger.error(`Failed to create post`, {
safeMessage: e.message,
})
if (isNetworkError(e)) {
throw new Error(
'Post failed to upload. Please check your Internet connection and try again.',
@@ -242,56 +289,52 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
}
}
try {
// TODO: this needs to be batch-created with the post!
if (opts.threadgate?.length) {
await createThreadgate(agent, res.uri, opts.threadgate)
if (opts.threadgate.some(tg => tg.type !== 'everybody')) {
try {
// TODO: this needs to be batch-created with the post!
await writeThreadgateRecord({
agent,
postUri: res.uri,
threadgate: createThreadgateRecord({
post: res.uri,
allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate),
}),
})
} catch (e: any) {
logger.error(`Failed to create threadgate`, {
context: 'composer',
safeMessage: e.message,
})
throw new Error(
'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
)
}
}
if (
opts.postgate.embeddingRules?.length ||
opts.postgate.detachedEmbeddingUris?.length
) {
try {
// TODO: this needs to be batch-created with the post!
await writePostgateRecord({
agent,
postUri: res.uri,
postgate: {
...opts.postgate,
post: res.uri,
},
})
} catch (e: any) {
logger.error(`Failed to create postgate`, {
context: 'composer',
safeMessage: e.message,
})
throw new Error(
'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
)
}
} catch (e: any) {
console.error(`Failed to create threadgate: ${e.toString()}`)
throw new Error(
'Post reply-controls failed to be set. Your post was created but anyone can reply to it.',
)
}
return res
}
export async function createThreadgate(
agent: BskyAgent,
postUri: string,
threadgate: ThreadgateSetting[],
) {
let allow: (
| AppBskyFeedThreadgate.MentionRule
| AppBskyFeedThreadgate.FollowingRule
| AppBskyFeedThreadgate.ListRule
)[] = []
if (!threadgate.find(v => v.type === 'nobody')) {
for (const rule of threadgate) {
if (rule.type === 'mention') {
allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'})
} else if (rule.type === 'following') {
allow.push({$type: 'app.bsky.feed.threadgate#followingRule'})
} else if (rule.type === 'list') {
allow.push({
$type: 'app.bsky.feed.threadgate#listRule',
list: rule.list,
})
}
}
}
const postUrip = new AtUri(postUri)
await agent.api.com.atproto.repo.putRecord({
repo: agent.accountDid,
collection: 'app.bsky.feed.threadgate',
rkey: postUrip.rkey,
record: {
$type: 'app.bsky.feed.threadgate',
post: postUri,
allow,
createdAt: new Date().toISOString(),
},
})
}
+20
View File
@@ -0,0 +1,20 @@
export function cancelable<A, T>(
f: (args: A) => Promise<T>,
signal: AbortSignal,
) {
return (args: A) => {
return new Promise<T>((resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new AbortError())
})
f(args).then(resolve, reject)
})
}
}
export class AbortError extends Error {
constructor() {
super('Aborted')
this.name = 'AbortError'
}
}
+11 -1
View File
@@ -12,6 +12,7 @@ export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG
export const EMBED_SERVICE = 'https://embed.bsky.app'
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
export const STARTER_PACK_MAX_SIZE = 150
// HACK
// Yes, this is exactly what it looks like. It's a hard-coded constant
@@ -20,7 +21,7 @@ export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
// code and update this number with each release until we can get the
// server route done.
// -prf
export const JOINED_THIS_WEEK = 21797 // as of Jul5 2024
export const JOINED_THIS_WEEK = 50676 // as of Aug 17, 2024
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({
@@ -135,3 +136,12 @@ export const GIF_FEATURED = (params: string) =>
`${GIF_SERVICE}/tenor/v2/featured?${params}`
export const MAX_LABELERS = 20
export const SUPPORTED_MIME_TYPES = [
'video/mp4',
'video/mpeg',
'video/webm',
'video/quicktime',
] as const
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
+179
View File
@@ -0,0 +1,179 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {
Easing,
LayoutAnimationConfig,
useReducedMotion,
withTiming,
} from 'react-native-reanimated'
import {i18n} from '@lingui/core'
import {decideShouldRoll} from 'lib/custom-animations/util'
import {s} from 'lib/styles'
import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
const animationConfig = {
duration: 400,
easing: Easing.out(Easing.cubic),
}
function EnteringUp() {
'worklet'
const animations = {
opacity: withTiming(1, animationConfig),
transform: [{translateY: withTiming(0, animationConfig)}],
}
const initialValues = {
opacity: 0,
transform: [{translateY: 18}],
}
return {
animations,
initialValues,
}
}
function EnteringDown() {
'worklet'
const animations = {
opacity: withTiming(1, animationConfig),
transform: [{translateY: withTiming(0, animationConfig)}],
}
const initialValues = {
opacity: 0,
transform: [{translateY: -18}],
}
return {
animations,
initialValues,
}
}
function ExitingUp() {
'worklet'
const animations = {
opacity: withTiming(0, animationConfig),
transform: [
{
translateY: withTiming(-18, animationConfig),
},
],
}
const initialValues = {
opacity: 1,
transform: [{translateY: 0}],
}
return {
animations,
initialValues,
}
}
function ExitingDown() {
'worklet'
const animations = {
opacity: withTiming(0, animationConfig),
transform: [{translateY: withTiming(18, animationConfig)}],
}
const initialValues = {
opacity: 1,
transform: [{translateY: 0}],
}
return {
animations,
initialValues,
}
}
export function CountWheel({
likeCount,
big,
isLiked,
isToggle,
}: {
likeCount: number
big?: boolean
isLiked: boolean
isToggle: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && isToggle
const shouldRoll = decideShouldRoll(isLiked, likeCount)
// Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
// animation
// The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
// be unnecessary
const [key, setKey] = React.useState(0)
const [prevCount, setPrevCount] = React.useState(likeCount)
const prevIsLiked = React.useRef(isLiked)
const formattedCount = formatCount(i18n, likeCount)
const formattedPrevCount = formatCount(i18n, prevCount)
React.useEffect(() => {
if (isLiked === prevIsLiked.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
setKey(prev => prev + 1)
setPrevCount(newPrevCount)
prevIsLiked.current = isLiked
}, [isLiked, likeCount])
const enteringAnimation =
shouldAnimate && shouldRoll
? isLiked
? EnteringUp
: EnteringDown
: undefined
const exitingAnimation =
shouldAnimate && shouldRoll
? isLiked
? ExitingUp
: ExitingDown
: undefined
return (
<LayoutAnimationConfig skipEntering skipExiting>
{likeCount > 0 ? (
<View style={[a.justify_center]}>
<Animated.View entering={enteringAnimation} key={key}>
<Text
testID="likeCount"
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</Animated.View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
<Animated.View
entering={exitingAnimation}
// Add 2 to the key so there are never duplicates
key={key + 2}
style={[a.absolute, {width: 50, opacity: 0}]}
aria-disabled={true}>
<Text
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</Animated.View>
) : null}
</View>
) : null}
</LayoutAnimationConfig>
)
}
@@ -0,0 +1,122 @@
import React from 'react'
import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {i18n} from '@lingui/core'
import {decideShouldRoll} from 'lib/custom-animations/util'
import {s} from 'lib/styles'
import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
const animationConfig = {
duration: 400,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' as FillMode,
}
const enteringUpKeyframe = [
{opacity: 0, transform: 'translateY(18px)'},
{opacity: 1, transform: 'translateY(0)'},
]
const enteringDownKeyframe = [
{opacity: 0, transform: 'translateY(-18px)'},
{opacity: 1, transform: 'translateY(0)'},
]
const exitingUpKeyframe = [
{opacity: 1, transform: 'translateY(0)'},
{opacity: 0, transform: 'translateY(-18px)'},
]
const exitingDownKeyframe = [
{opacity: 1, transform: 'translateY(0)'},
{opacity: 0, transform: 'translateY(18px)'},
]
export function CountWheel({
likeCount,
big,
isLiked,
isToggle,
}: {
likeCount: number
big?: boolean
isLiked: boolean
isToggle: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && isToggle
const shouldRoll = decideShouldRoll(isLiked, likeCount)
const countView = React.useRef<HTMLDivElement>(null)
const prevCountView = React.useRef<HTMLDivElement>(null)
const [prevCount, setPrevCount] = React.useState(likeCount)
const prevIsLiked = React.useRef(isLiked)
const formattedCount = formatCount(i18n, likeCount)
const formattedPrevCount = formatCount(i18n, prevCount)
React.useEffect(() => {
if (isLiked === prevIsLiked.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
if (shouldAnimate && shouldRoll) {
countView.current?.animate?.(
isLiked ? enteringUpKeyframe : enteringDownKeyframe,
animationConfig,
)
prevCountView.current?.animate?.(
isLiked ? exitingUpKeyframe : exitingDownKeyframe,
animationConfig,
)
setPrevCount(newPrevCount)
}
prevIsLiked.current = isLiked
}, [isLiked, likeCount, shouldAnimate, shouldRoll])
if (likeCount < 1) {
return null
}
return (
<View>
<View
// @ts-expect-error is div
ref={countView}>
<Text
testID="likeCount"
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
<View
style={{position: 'absolute', opacity: 0}}
aria-disabled={true}
// @ts-expect-error is div
ref={prevCountView}>
<Text
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</View>
) : null}
</View>
)
}
+137
View File
@@ -0,0 +1,137 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {
Keyframe,
LayoutAnimationConfig,
useReducedMotion,
} from 'react-native-reanimated'
import {s} from 'lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
const keyframe = new Keyframe({
0: {
transform: [{scale: 1}],
},
10: {
transform: [{scale: 0.7}],
},
40: {
transform: [{scale: 1.2}],
},
100: {
transform: [{scale: 1}],
},
})
const circle1Keyframe = new Keyframe({
0: {
opacity: 0,
transform: [{scale: 0}],
},
10: {
opacity: 0.4,
},
40: {
transform: [{scale: 1.5}],
},
95: {
opacity: 0.4,
},
100: {
opacity: 0,
transform: [{scale: 1.5}],
},
})
const circle2Keyframe = new Keyframe({
0: {
opacity: 0,
transform: [{scale: 0}],
},
10: {
opacity: 1,
},
40: {
transform: [{scale: 0}],
},
95: {
opacity: 1,
},
100: {
opacity: 0,
transform: [{scale: 1.5}],
},
})
export function AnimatedLikeIcon({
isLiked,
big,
isToggle,
}: {
isLiked: boolean
big?: boolean
isToggle: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion() && isToggle
return (
<View>
<LayoutAnimationConfig skipEntering>
{isLiked ? (
<Animated.View
entering={shouldAnimate ? keyframe.duration(300) : undefined}>
<HeartIconFilled style={s.likeColor} width={size} />
</Animated.View>
) : (
<HeartIconOutline
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
width={size}
/>
)}
{isLiked ? (
<>
<Animated.View
entering={
shouldAnimate ? circle1Keyframe.duration(300) : undefined
}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
}}
/>
<Animated.View
entering={
shouldAnimate ? circle2Keyframe.duration(300) : undefined
}
style={{
position: 'absolute',
backgroundColor: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
}}
/>
</>
) : null}
</LayoutAnimationConfig>
</View>
)
}
+119
View File
@@ -0,0 +1,119 @@
import React from 'react'
import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {s} from 'lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
const animationConfig = {
duration: 400,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' as FillMode,
}
const keyframe = [
{transform: 'scale(1)'},
{transform: 'scale(0.7)'},
{transform: 'scale(1.2)'},
{transform: 'scale(1)'},
]
const circle1Keyframe = [
{opacity: 0, transform: 'scale(0)'},
{opacity: 0.4},
{transform: 'scale(1.5)'},
{opacity: 0.4},
{opacity: 0, transform: 'scale(1.5)'},
]
const circle2Keyframe = [
{opacity: 0, transform: 'scale(0)'},
{opacity: 1},
{transform: 'scale(0)'},
{opacity: 1},
{opacity: 0, transform: 'scale(1.5)'},
]
export function AnimatedLikeIcon({
isLiked,
big,
isToggle,
}: {
isLiked: boolean
big?: boolean
isToggle: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion() && isToggle
const prevIsLiked = React.useRef(isLiked)
const likeIconRef = React.useRef<HTMLDivElement>(null)
const circle1Ref = React.useRef<HTMLDivElement>(null)
const circle2Ref = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (prevIsLiked.current === isLiked) {
return
}
if (shouldAnimate && isLiked) {
likeIconRef.current?.animate?.(keyframe, animationConfig)
circle1Ref.current?.animate?.(circle1Keyframe, animationConfig)
circle2Ref.current?.animate?.(circle2Keyframe, animationConfig)
}
prevIsLiked.current = isLiked
}, [shouldAnimate, isLiked])
return (
<View>
{isLiked ? (
// @ts-expect-error is div
<View ref={likeIconRef}>
<HeartIconFilled style={s.likeColor} width={size} />
</View>
) : (
<HeartIconOutline
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
width={size}
/>
)}
<View
// @ts-expect-error is div
ref={circle1Ref}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
opacity: 0,
}}
/>
<View
// @ts-expect-error is div
ref={circle2Ref}
style={{
position: 'absolute',
backgroundColor: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
opacity: 0,
}}
/>
</View>
)
}
+21
View File
@@ -0,0 +1,21 @@
// It should roll when:
// - We're going from 1 to 0 (roll backwards)
// - The count is anywhere between 1 and 999
// - The count is going up and is a multiple of 100
// - The count is going down and is 1 less than a multiple of 100
export function decideShouldRoll(isSet: boolean, count: number) {
let shouldRoll = false
if (!isSet && count === 1) {
shouldRoll = true
} else if (count > 1 && count < 1000) {
shouldRoll = true
} else if (count > 0) {
const mod = count % 100
if (isSet && mod === 0) {
shouldRoll = true
} else if (!isSet && mod === 99) {
shouldRoll = true
}
}
return shouldRoll
}
+1 -2
View File
@@ -65,7 +65,6 @@ export function useGenerateStarterPackMutation({
}) {
const {_} = useLingui()
const agent = useAgent()
const starterPackString = _(msg`Starter Pack`)
return useMutation<{uri: string; cid: string}, Error, void>({
mutationFn: async () => {
@@ -106,7 +105,7 @@ export function useGenerateStarterPackMutation({
25,
true,
)
const starterPackName = `${displayName}'s ${starterPackString}`
const starterPackName = _(msg`${displayName}'s Starter Pack`)
const list = await createStarterPackList({
name: starterPackName,
+136 -25
View File
@@ -1,102 +1,213 @@
import {describe, expect, it} from '@jest/globals'
import {MessageDescriptor} from '@lingui/core'
import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
import {dateDiff} from '../useTimeAgo'
const lingui: any = (obj: MessageDescriptor) => obj.message
const base = new Date('2024-06-17T00:00:00Z')
describe('dateDiff', () => {
it(`works with numbers`, () => {
expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d')
const earlier = subDays(base, 3)
expect(dateDiff(earlier, Number(base))).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
})
it(`works with strings`, () => {
expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d')
const earlier = subDays(base, 3)
expect(dateDiff(earlier, base.toString())).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
})
it(`works with dates`, () => {
expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d')
const earlier = subDays(base, 3)
expect(dateDiff(earlier, base)).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
})
it(`equal values return now`, () => {
expect(dateDiff(base, base, {lingui})).toEqual('now')
expect(dateDiff(base, base)).toEqual({
value: 0,
unit: 'now',
earlier: base,
later: base,
})
})
it(`future dates return now`, () => {
expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now')
const earlier = addDays(base, 3)
expect(dateDiff(earlier, base)).toEqual({
value: 0,
unit: 'now',
earlier,
later: base,
})
})
it(`values < 5 seconds ago return now`, () => {
const then = subSeconds(base, 4)
expect(dateDiff(then, base, {lingui})).toEqual('now')
expect(dateDiff(then, base)).toEqual({
value: 0,
unit: 'now',
earlier: then,
later: base,
})
})
it(`values >= 5 seconds ago return seconds`, () => {
const then = subSeconds(base, 5)
expect(dateDiff(then, base, {lingui})).toEqual('5s')
expect(dateDiff(then, base)).toEqual({
value: 5,
unit: 'second',
earlier: then,
later: base,
})
})
it(`values < 1 min return seconds`, () => {
const then = subSeconds(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59s')
expect(dateDiff(then, base)).toEqual({
value: 59,
unit: 'second',
earlier: then,
later: base,
})
})
it(`values >= 1 min return minutes`, () => {
const then = subSeconds(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1m')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'minute',
earlier: then,
later: base,
})
})
it(`minutes round down`, () => {
const then = subSeconds(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1m')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'minute',
earlier: then,
later: base,
})
})
it(`values < 1 hour return minutes`, () => {
const then = subMinutes(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59m')
expect(dateDiff(then, base)).toEqual({
value: 59,
unit: 'minute',
earlier: then,
later: base,
})
})
it(`values >= 1 hour return hours`, () => {
const then = subMinutes(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1h')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'hour',
earlier: then,
later: base,
})
})
it(`hours round down`, () => {
const then = subMinutes(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1h')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'hour',
earlier: then,
later: base,
})
})
it(`values < 1 day return hours`, () => {
const then = subHours(base, 23)
expect(dateDiff(then, base, {lingui})).toEqual('23h')
expect(dateDiff(then, base)).toEqual({
value: 23,
unit: 'hour',
earlier: then,
later: base,
})
})
it(`values >= 1 day return days`, () => {
const then = subHours(base, 24)
expect(dateDiff(then, base, {lingui})).toEqual('1d')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'day',
earlier: then,
later: base,
})
})
it(`days round down`, () => {
const then = subHours(base, 47)
expect(dateDiff(then, base, {lingui})).toEqual('1d')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'day',
earlier: then,
later: base,
})
})
it(`values < 30 days return days`, () => {
const then = subDays(base, 29)
expect(dateDiff(then, base, {lingui})).toEqual('29d')
expect(dateDiff(then, base)).toEqual({
value: 29,
unit: 'day',
earlier: then,
later: base,
})
})
it(`values >= 30 days return months`, () => {
const then = subDays(base, 30)
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'month',
earlier: then,
later: base,
})
})
it(`months round down`, () => {
const then = subDays(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'month',
earlier: then,
later: base,
})
})
it(`values are rounded by increments of 30`, () => {
const then = subDays(base, 61)
expect(dateDiff(then, base, {lingui})).toEqual('2mo')
expect(dateDiff(then, base)).toEqual({
value: 2,
unit: 'month',
earlier: then,
later: base,
})
})
it(`values < 360 days return months`, () => {
const then = subDays(base, 359)
expect(dateDiff(then, base, {lingui})).toEqual('11mo')
expect(dateDiff(then, base)).toEqual({
value: 11,
unit: 'month',
earlier: then,
later: base,
})
})
it(`values >= 360 days return the earlier value`, () => {
const then = subDays(base, 360)
expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString())
expect(dateDiff(then, base)).toEqual({
value: 12,
unit: 'month',
earlier: then,
later: base,
})
})
})
+23
View File
@@ -0,0 +1,23 @@
import {StackActions, useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {router} from '#/routes'
export function useGoBack(onGoBack?: () => unknown) {
const navigation = useNavigation<NavigationProp>()
return () => {
onGoBack?.()
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('HomeTab')
// Checking the state for routes ensures that web doesn't encounter errors while going back
if (navigation.getState()?.routes) {
navigation.dispatch(StackActions.push(...router.matchPath('/')))
} else {
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
}
}
}
}
+20 -7
View File
@@ -1,11 +1,24 @@
import React from 'react'
import {Dimensions} from 'react-native'
import {useWindowDimensions} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
const MIN_POST_HEIGHT = 100
export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
return React.useMemo(() => {
const screenHeight = Dimensions.get('window').height
return Math.ceil(screenHeight / minItemHeight) + 1
}, [minItemHeight])
export function useInitialNumToRender({
minItemHeight = MIN_POST_HEIGHT,
screenHeightOffset = 0,
}: {minItemHeight?: number; screenHeightOffset?: number} = {}) {
const {height: screenHeight} = useWindowDimensions()
const {top: topInset} = useSafeAreaInsets()
const bottomBarHeight = useBottomBarOffset()
const finalHeight =
screenHeight - screenHeightOffset - topInset - bottomBarHeight
const minItems = Math.floor(finalHeight / minItemHeight)
if (minItems < 1) {
return 1
}
return minItems
}
+18 -27
View File
@@ -2,21 +2,24 @@ import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
import {useMinimalShellMode} from '#/state/shell/minimal-mode'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useGate} from '../statsig/statsig'
// Keep these separated so that we only pay for useAnimatedStyle that gets used.
export function useMinimalShellHeaderTransform() {
const mode = useMinimalShellMode()
const {headerMode} = useMinimalShellMode()
const {headerHeight} = useShellLayout()
const headerTransform = useAnimatedStyle(() => {
return {
pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2),
pointerEvents: headerMode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - headerMode.value, 2),
transform: [
{
translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
translateY: interpolate(
headerMode.value,
[0, 1],
[0, -headerHeight.value],
),
},
],
}
@@ -26,21 +29,20 @@ export function useMinimalShellHeaderTransform() {
}
export function useMinimalShellFooterTransform() {
const mode = useMinimalShellMode()
const {footerMode} = useMinimalShellMode()
const {footerHeight} = useShellLayout()
const gate = useGate()
const isFixedBottomBar = gate('fixed_bottom_bar')
const footerTransform = useAnimatedStyle(() => {
if (isFixedBottomBar) {
return {}
}
return {
pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2),
pointerEvents: footerMode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - footerMode.value, 2),
transform: [
{
translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
translateY: interpolate(
footerMode.value,
[0, 1],
[0, footerHeight.value],
),
},
],
}
@@ -50,24 +52,13 @@ export function useMinimalShellFooterTransform() {
}
export function useMinimalShellFabTransform() {
const mode = useMinimalShellMode()
const gate = useGate()
const isFixedBottomBar = gate('fixed_bottom_bar')
const {footerMode} = useMinimalShellMode()
const fabTransform = useAnimatedStyle(() => {
if (isFixedBottomBar) {
return {
transform: [
{
translateY: -44,
},
],
}
}
return {
transform: [
{
translateY: interpolate(mode.value, [0, 1], [-44, 0]),
translateY: interpolate(footerMode.value, [0, 1], [-44, 0]),
},
],
}
+153 -61
View File
@@ -1,25 +1,16 @@
import {useCallback} from 'react'
import {msg, plural} from '@lingui/macro'
import {I18nContext, useLingui} from '@lingui/react'
import {I18n} from '@lingui/core'
import {defineMessage, msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
export type TimeAgoOptions = {
lingui: I18nContext['_']
format?: 'long' | 'short'
}
export type DateDiffFormat = 'long' | 'short'
export function useGetTimeAgo() {
const {_} = useLingui()
return useCallback(
(
earlier: number | string | Date,
later: number | string | Date,
options?: Omit<TimeAgoOptions, 'lingui'>,
) => {
return dateDiff(earlier, later, {lingui: _, format: options?.format})
},
[_],
)
type DateDiff = {
value: number
unit: 'now' | 'second' | 'minute' | 'hour' | 'day' | 'month'
earlier: Date
later: Date
}
const NOW = 5
@@ -28,59 +19,160 @@ const HOUR = MINUTE * 60
const DAY = HOUR * 24
const MONTH_30 = DAY * 30
export function useGetTimeAgo() {
const {i18n} = useLingui()
return useCallback(
(
earlier: number | string | Date,
later: number | string | Date,
options?: {format: DateDiffFormat},
) => {
const diff = dateDiff(earlier, later)
return formatDateDiff({diff, i18n, format: options?.format})
},
[i18n],
)
}
/**
* Returns the difference between `earlier` and `later` dates, formatted as a
* natural language string.
* Returns the difference between `earlier` and `later` dates, based on
* opinionated rules.
*
* - All month are considered exactly 30 days.
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
* - All values round down
*/
export function dateDiff(
earlier: number | string | Date,
later: number | string | Date,
): DateDiff {
let diff = {
value: 0,
unit: 'now' as DateDiff['unit'],
}
const e = new Date(earlier)
const l = new Date(later)
const diffSeconds = differenceInSeconds(l, e)
if (diffSeconds < NOW) {
diff = {
value: 0,
unit: 'now' as DateDiff['unit'],
}
} else if (diffSeconds < MINUTE) {
diff = {
value: diffSeconds,
unit: 'second' as DateDiff['unit'],
}
} else if (diffSeconds < HOUR) {
const value = Math.floor(diffSeconds / MINUTE)
diff = {
value,
unit: 'minute' as DateDiff['unit'],
}
} else if (diffSeconds < DAY) {
const value = Math.floor(diffSeconds / HOUR)
diff = {
value,
unit: 'hour' as DateDiff['unit'],
}
} else if (diffSeconds < MONTH_30) {
const value = Math.floor(diffSeconds / DAY)
diff = {
value,
unit: 'day' as DateDiff['unit'],
}
} else {
const value = Math.floor(diffSeconds / MONTH_30)
diff = {
value,
unit: 'month' as DateDiff['unit'],
}
}
return {
...diff,
earlier: e,
later: l,
}
}
/**
* Accepts a `DateDiff` and teturns the difference between `earlier` and
* `later` dates, formatted as a natural language string.
*
* - All month are considered exactly 30 days.
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
* - Differences >= 360 days are returned as the "M/D/YYYY" string
* - All values round down
*/
export function dateDiff(
earlier: number | string | Date,
later: number | string | Date,
options: TimeAgoOptions,
): string {
const _ = options.lingui
const format = options?.format || 'short'
export function formatDateDiff({
diff,
format = 'short',
i18n,
}: {
diff: DateDiff
format?: DateDiffFormat
i18n: I18n
}): string {
const long = format === 'long'
const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
if (diffSeconds < NOW) {
return _(msg`now`)
} else if (diffSeconds < MINUTE) {
return `${diffSeconds}${
long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's'
}`
} else if (diffSeconds < HOUR) {
const diff = Math.floor(diffSeconds / MINUTE)
return `${diff}${
long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm'
}`
} else if (diffSeconds < DAY) {
const diff = Math.floor(diffSeconds / HOUR)
return `${diff}${
long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h'
}`
} else if (diffSeconds < MONTH_30) {
const diff = Math.floor(diffSeconds / DAY)
return `${diff}${
long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd'
}`
} else {
const diff = Math.floor(diffSeconds / MONTH_30)
if (diff < 12) {
return `${diff}${
long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo'
}`
} else {
const str = new Date(earlier).toLocaleDateString()
if (long) {
return _(msg`on ${str}`)
switch (diff.unit) {
case 'now': {
return i18n._(msg`now`)
}
case 'second': {
return long
? i18n._(plural(diff.value, {one: '# second', other: '# seconds'}))
: i18n._(
defineMessage({
message: `${diff.value}s`,
comment: `How many seconds have passed, displayed in a narrow form`,
}),
)
}
case 'minute': {
return long
? i18n._(plural(diff.value, {one: '# minute', other: '# minutes'}))
: i18n._(
defineMessage({
message: `${diff.value}m`,
comment: `How many minutes have passed, displayed in a narrow form`,
}),
)
}
case 'hour': {
return long
? i18n._(plural(diff.value, {one: '# hour', other: '# hours'}))
: i18n._(
defineMessage({
message: `${diff.value}h`,
comment: `How many hours have passed, displayed in a narrow form`,
}),
)
}
case 'day': {
return long
? i18n._(plural(diff.value, {one: '# day', other: '# days'}))
: i18n._(
defineMessage({
message: `${diff.value}d`,
comment: `How many days have passed, displayed in a narrow form`,
}),
)
}
case 'month': {
if (diff.value < 12) {
return long
? i18n._(plural(diff.value, {one: '# month', other: '# months'}))
: i18n._(
defineMessage({
message: `${diff.value}mo`,
comment: `How many months have passed, displayed in a narrow form`,
}),
)
}
return str
return i18n.date(new Date(diff.earlier))
}
}
}
+8
View File
@@ -107,6 +107,11 @@ export async function extractBskyMeta(
return meta
}
export class EmbeddingDisabledError extends Error {
constructor() {
super('Embedding is disabled for this record')
}
}
export async function getPostAsQuote(
getPost: ReturnType<typeof useGetPost>,
url: string,
@@ -115,6 +120,9 @@ export async function getPostAsQuote(
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
const post = await getPost({uri: uri})
if (post.viewer?.embeddingDisabled) {
throw new EmbeddingDisabledError()
}
return {
uri: post.uri,
cid: post.cid,
+12 -8
View File
@@ -1,30 +1,34 @@
import {getVideoMetaData, Video} from 'react-native-compressor'
export type CompressedVideo = {
uri: string
size: number
}
import {CompressedVideo} from './types'
export async function compressVideo(
file: string,
opts?: {
getCancellationId?: (id: string) => void
signal?: AbortSignal
onProgress?: (progress: number) => void
},
): Promise<CompressedVideo> {
const {onProgress, getCancellationId} = opts || {}
const {onProgress, signal} = opts || {}
const compressed = await Video.compress(
file,
{
getCancellationId,
compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps
maxSize: 1920,
getCancellationId: id => {
if (signal) {
signal.addEventListener('abort', () => {
Video.cancelCompression(id)
})
}
},
},
onProgress,
)
const info = await getVideoMetaData(compressed)
return {uri: compressed, size: info.size}
return {uri: compressed, size: info.size, mimeType: `video/mp4`}
}
+37 -10
View File
@@ -1,21 +1,19 @@
import {VideoTooLargeError} from 'lib/media/video/errors'
import {CompressedVideo} from './types'
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
export type CompressedVideo = {
uri: string
size: number
}
// doesn't actually compress, but throws if >100MB
export async function compressVideo(
file: string,
_callbacks?: {
onProgress: (progress: number) => void
_opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
},
): Promise<CompressedVideo> {
const blob = await fetch(file).then(res => res.blob())
const video = URL.createObjectURL(blob)
const {mimeType, base64} = parseDataUrl(file)
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
if (blob.size > MAX_VIDEO_SIZE) {
throw new VideoTooLargeError()
@@ -23,6 +21,35 @@ export async function compressVideo(
return {
size: blob.size,
uri: video,
uri,
bytes: await blob.arrayBuffer(),
mimeType,
}
}
function parseDataUrl(dataUrl: string) {
const [mimeType, base64] = dataUrl.slice('data:'.length).split(';base64,')
if (!mimeType || !base64) {
throw new Error('Invalid data URL')
}
return {mimeType, base64}
}
function base64ToBlob(base64: string, mimeType: string) {
const byteCharacters = atob(base64)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
return new Blob(byteArrays, {type: mimeType})
}
+7
View File
@@ -4,3 +4,10 @@ export class VideoTooLargeError extends Error {
this.name = 'VideoTooLargeError'
}
}
export class ServerError extends Error {
constructor(message: string) {
super(message)
this.name = 'ServerError'
}
}
+6 -35
View File
@@ -1,36 +1,7 @@
/**
* TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION.
* @temporary
* PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented.
* Not joking, this is temporary.
*/
export interface JobStatus {
jobId: string
did: string
cid: string
state: JobState
progress?: number
errorHuman?: string
errorMachine?: string
}
export enum JobState {
JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED',
JOB_STATE_CREATED = 'JOB_STATE_CREATED',
JOB_STATE_ENCODING = 'JOB_STATE_ENCODING',
JOB_STATE_ENCODED = 'JOB_STATE_ENCODED',
JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING',
JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED',
JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING',
JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED',
JOB_STATE_FAILED = 'JOB_STATE_FAILED',
JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED',
}
export interface UploadVideoResponse {
job_id: string
did: string
cid: string
state: JobState
export type CompressedVideo = {
uri: string
mimeType: string
size: number
// web only, can fall back to uri if missing
bytes?: ArrayBuffer
}
+2 -1
View File
@@ -1,4 +1,5 @@
import {moderatePost, BSKY_LABELER_DID} from '@atproto/api'
/* eslint-disable-next-line no-restricted-imports */
import {BSKY_LABELER_DID, moderatePost} from '@atproto/api'
type ModeratePost = typeof moderatePost
type Options = Parameters<ModeratePost>[1]
+41 -5
View File
@@ -1,17 +1,22 @@
import React from 'react'
import {
ModerationCause,
ModerationUI,
InterpretedLabelValueDefinition,
LABELS,
AppBskyLabelerDefs,
BskyAgent,
ComAtprotoLabelDefs,
InterpretedLabelValueDefinition,
LABELS,
ModerationCause,
ModerationOpts,
ModerationUI,
} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {AppModerationCause} from '#/components/Pills'
export function getModerationCauseKey(cause: ModerationCause): string {
export function getModerationCauseKey(
cause: ModerationCause | AppModerationCause,
): string {
const source =
cause.source.type === 'labeler'
? cause.source.did
@@ -79,3 +84,34 @@ export function isLabelerSubscribed(
}
return modOpts.prefs.labelers.find(l => l.did === labeler)
}
export type Subject =
| {
uri: string
cid: string
}
| {
did: string
}
export function useLabelSubject({label}: {label: ComAtprotoLabelDefs.Label}): {
subject: Subject
} {
return React.useMemo(() => {
const {cid, uri} = label
if (cid) {
return {
subject: {
uri,
cid,
},
}
} else {
return {
subject: {
did: uri,
},
}
}
}, [label])
}
+3 -3
View File
@@ -1,9 +1,9 @@
import {
ComAtprotoLabelDefs,
AppBskyLabelerDefs,
LABELS,
interpretLabelValueDefinition,
ComAtprotoLabelDefs,
InterpretedLabelValueDefinition,
interpretLabelValueDefinition,
LABELS,
} from '@atproto/api'
import {useLingui} from '@lingui/react'
import * as bcp47Match from 'bcp-47-match'
@@ -8,11 +8,13 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useLabelDefinitions} from '#/state/preferences'
import {useSession} from '#/state/session'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Props as SVGIconProps} from '#/components/icons/common'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {AppModerationCause} from '#/components/Pills'
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
import {getDefinition, getLabelStrings} from './useLabelInfo'
@@ -27,8 +29,9 @@ export interface ModerationCauseDescription {
}
export function useModerationCauseDescription(
cause: ModerationCause | undefined,
cause: ModerationCause | AppModerationCause | undefined,
): ModerationCauseDescription {
const {currentAccount} = useSession()
const {_, i18n} = useLingui()
const {labelDefs, labelers} = useLabelDefinitions()
const globalLabelStrings = useGlobalLabelStrings()
@@ -111,6 +114,18 @@ export function useModerationCauseDescription(
description: _(msg`You have hidden this post`),
}
}
if (cause.type === 'reply-hidden') {
const isMe = currentAccount?.did === cause.source.did
return {
icon: EyeSlash,
name: isMe
? _(msg`Reply Hidden by You`)
: _(msg`Reply Hidden by Thread Author`),
description: isMe
? _(msg`You hid this reply.`)
: _(msg`The author of this thread has hidden this reply.`),
}
}
if (cause.type === 'label') {
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
@@ -150,5 +165,13 @@ export function useModerationCauseDescription(
name: '',
description: ``,
}
}, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale])
}, [
labelDefs,
labelers,
globalLabelStrings,
cause,
_,
i18n.locale,
currentAccount?.did,
])
}
+5
View File
@@ -62,6 +62,11 @@ export function useReportOptions(): ReportOptions {
other,
],
post: [
{
reason: ComAtprotoModerationDefs.REASONMISLEADING,
title: _(msg`Misleading Post`),
description: _(msg`Impersonation, misinformation, or false claims`),
},
{
reason: ComAtprotoModerationDefs.REASONSPAM,
title: _(msg`Spam`),
+1
View File
@@ -20,6 +20,7 @@ export type CommonNavigatorParams = {
PostThread: {name: string; rkey: string}
PostLikedBy: {name: string; rkey: string}
PostRepostedBy: {name: string; rkey: string}
PostQuotes: {name: string; rkey: string}
ProfileFeed: {name: string; rkey: string}
ProfileFeedLikedBy: {name: string; rkey: string}
ProfileLabelerLikedBy: {name: string}
-6
View File
@@ -216,12 +216,6 @@ export type LogEvents = {
'profile:header:suggestedFollowsCard:press': {}
'debug:followingPrefs': {
followingShowRepliesFromPref: 'all' | 'following' | 'off'
followingRepliesMinLikePref: number
}
'debug:followingDisplayed': {}
'test:all:always': {}
'test:all:sometimes': {}
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
+4 -4
View File
@@ -2,9 +2,9 @@ export type Gate =
// Keep this alphabetic please.
| 'debug_show_feedcontext'
| 'fixed_bottom_bar'
| 'new_user_guided_tour'
| 'onboarding_minimum_interests'
| 'show_follow_back_label_v2'
| 'suggested_feeds_interstitial'
| 'video_debug'
| 'videos'
| 'show_follow_suggestions_in_profile'
| 'video_debug' // not recommended
| 'video_upload' // upload videos
| 'video_view_on_posts' // see posted videos
+4 -4
View File
@@ -226,11 +226,11 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
let secondsActive = 0
if (lastActive != null) {
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
lastActive = null
logEvent('state:background:sampled', {
secondsActive,
})
}
lastActive = null
logEvent('state:background:sampled', {
secondsActive,
})
}
})
+18
View File
@@ -1,3 +1,6 @@
import {useCallback, useMemo} from 'react'
import Graphemer from 'graphemer'
export function enforceLen(
str: string,
len: number,
@@ -23,6 +26,21 @@ export function enforceLen(
return str
}
export function useEnforceMaxGraphemeCount() {
const splitter = useMemo(() => new Graphemer(), [])
return useCallback(
(text: string, maxCount: number) => {
if (splitter.countGraphemes(text) > maxCount) {
return splitter.splitGraphemes(text).slice(0, maxCount).join('')
} else {
return text
}
},
[splitter],
)
}
// https://stackoverflow.com/a/52171480
export function toHashCode(str: string, seed = 0): number {
let h1 = 0xdeadbeef ^ seed,
+8 -9
View File
@@ -1,13 +1,12 @@
export function niceDate(date: number | string | Date) {
import {I18n} from '@lingui/core'
export function niceDate(i18n: I18n, date: number | string | Date) {
const d = new Date(date)
return `${d.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})} at ${d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}`
return i18n.date(d, {
dateStyle: 'long',
timeStyle: 'short',
})
}
export function getAge(birthDate: Date): number {
+18
View File
@@ -339,3 +339,21 @@ export function shortLinkToHref(url: string): string {
return url
}
}
export function getHostnameFromUrl(url: string | URL): string | null {
let urlp
try {
urlp = new URL(url)
} catch (e) {
return null
}
return urlp.hostname
}
export function getServiceAuthAudFromUrl(url: string | URL): string | null {
const hostname = getHostnameFromUrl(url)
if (!hostname) {
return null
}
return `did:web:${hostname}`
}
+64 -16
View File
@@ -37,82 +37,130 @@ export async function dynamicActivate(locale: AppLanguage) {
switch (locale) {
case AppLanguage.ca: {
i18n.loadAndActivate({locale, messages: messagesCa})
await import('@formatjs/intl-pluralrules/locale-data/ca')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ca'),
import('@formatjs/intl-numberformat/locale-data/ca'),
])
break
}
case AppLanguage.de: {
i18n.loadAndActivate({locale, messages: messagesDe})
await import('@formatjs/intl-pluralrules/locale-data/de')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/de'),
import('@formatjs/intl-numberformat/locale-data/de'),
])
break
}
case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
await import('@formatjs/intl-pluralrules/locale-data/es')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/es'),
import('@formatjs/intl-numberformat/locale-data/es'),
])
break
}
case AppLanguage.fi: {
i18n.loadAndActivate({locale, messages: messagesFi})
await import('@formatjs/intl-pluralrules/locale-data/fi')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/fi'),
import('@formatjs/intl-numberformat/locale-data/fi'),
])
break
}
case AppLanguage.fr: {
i18n.loadAndActivate({locale, messages: messagesFr})
await import('@formatjs/intl-pluralrules/locale-data/fr')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/fr'),
import('@formatjs/intl-numberformat/locale-data/fr'),
])
break
}
case AppLanguage.ga: {
i18n.loadAndActivate({locale, messages: messagesGa})
await import('@formatjs/intl-pluralrules/locale-data/ga')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ga'),
import('@formatjs/intl-numberformat/locale-data/ga'),
])
break
}
case AppLanguage.hi: {
i18n.loadAndActivate({locale, messages: messagesHi})
await import('@formatjs/intl-pluralrules/locale-data/hi')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/hi'),
import('@formatjs/intl-numberformat/locale-data/hi'),
])
break
}
case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesId})
await import('@formatjs/intl-pluralrules/locale-data/id')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/id'),
import('@formatjs/intl-numberformat/locale-data/id'),
])
break
}
case AppLanguage.it: {
i18n.loadAndActivate({locale, messages: messagesIt})
await import('@formatjs/intl-pluralrules/locale-data/it')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/it'),
import('@formatjs/intl-numberformat/locale-data/it'),
])
break
}
case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa})
await import('@formatjs/intl-pluralrules/locale-data/ja')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ja'),
import('@formatjs/intl-numberformat/locale-data/ja'),
])
break
}
case AppLanguage.ko: {
i18n.loadAndActivate({locale, messages: messagesKo})
await import('@formatjs/intl-pluralrules/locale-data/ko')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ko'),
import('@formatjs/intl-numberformat/locale-data/ko'),
])
break
}
case AppLanguage.pt_BR: {
i18n.loadAndActivate({locale, messages: messagesPt_BR})
await import('@formatjs/intl-pluralrules/locale-data/pt')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/pt'),
import('@formatjs/intl-numberformat/locale-data/pt'),
])
break
}
case AppLanguage.tr: {
i18n.loadAndActivate({locale, messages: messagesTr})
await import('@formatjs/intl-pluralrules/locale-data/tr')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/tr'),
import('@formatjs/intl-numberformat/locale-data/tr'),
])
break
}
case AppLanguage.uk: {
i18n.loadAndActivate({locale, messages: messagesUk})
await import('@formatjs/intl-pluralrules/locale-data/uk')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/uk'),
import('@formatjs/intl-numberformat/locale-data/uk'),
])
break
}
case AppLanguage.zh_CN: {
i18n.loadAndActivate({locale, messages: messagesZh_CN})
await import('@formatjs/intl-pluralrules/locale-data/zh')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
])
break
}
case AppLanguage.zh_TW: {
i18n.loadAndActivate({locale, messages: messagesZh_TW})
await import('@formatjs/intl-pluralrules/locale-data/zh')
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
])
break
}
default: {
+94 -94
View File
@@ -68,7 +68,7 @@ export const LANGUAGES: Language[] = [
{code3: 'alt', code2: '', name: 'Southern Altai'},
{code3: 'amh', code2: 'am', name: 'Amharic'},
{code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)'},
{code3: 'anp ', code2: 'Angika', name: 'Angika'},
{code3: 'anp', code2: '', name: 'Angika'},
{code3: 'apa', code2: '', name: 'Apache languages'},
{code3: 'ara', code2: 'ar', name: 'Arabic'},
{
@@ -233,7 +233,7 @@ export const LANGUAGES: Language[] = [
{code3: 'gre', code2: 'el', name: 'Greek, Modern (1453-)'},
{code3: 'grn', code2: 'gn', name: 'Guarani'},
{code3: 'gsw', code2: '', name: 'Swiss German; Alemannic; Alsatian'},
{code3: 'gujgu', code2: 'Gujarati', name: 'goudjrati'},
{code3: 'guj', code2: 'gu', name: 'Gujarati'},
{code3: 'gwi', code2: '', name: "Gwich'in"},
{code3: 'hai', code2: '', name: 'Haida'},
{code3: 'hat', code2: 'ht', name: 'Haitian; Haitian Creole'},
@@ -339,8 +339,8 @@ export const LANGUAGES: Language[] = [
{code3: 'lun', code2: '', name: 'Lunda'},
{
code3: 'luo',
code2: ' Luo (Kenya and Tanzania)',
name: 'luo (Kenya et Tanzanie)',
code2: '',
name: 'Luo (Kenya and Tanzania)',
},
{code3: 'lus', code2: '', name: 'Lushai'},
{code3: 'mac', code2: 'mk', name: 'Macedonian'},
@@ -430,162 +430,162 @@ export const LANGUAGES: Language[] = [
{code3: 'oto', code2: '', name: 'Otomian languages'},
{code3: 'paa', code2: '', name: 'Papuan languages'},
{code3: 'pag', code2: '', name: 'Pangasinan'},
{code3: 'pal', code2: ' ', name: 'Pahlavi'},
{code3: 'pam', code2: ' ', name: 'Pampanga; Kapampangan'},
{code3: 'pan', code2: 'paPanjabi; Punjabi', name: 'pendjabi'},
{code3: 'pap', code2: ' ', name: 'Papiamento'},
{code3: 'pau', code2: ' ', name: 'Palauan'},
{code3: 'peo', code2: ' ', name: 'Persian, Old (ca.600-400 B.C.)'},
{code3: 'pal', code2: '', name: 'Pahlavi'},
{code3: 'pam', code2: '', name: 'Pampanga; Kapampangan'},
{code3: 'pan', code2: 'pa', name: 'Panjabi; Punjabi'},
{code3: 'pap', code2: '', name: 'Papiamento'},
{code3: 'pau', code2: '', name: 'Palauan'},
{code3: 'peo', code2: '', name: 'Persian, Old (ca.600-400 B.C.)'},
{code3: 'per', code2: 'fa', name: 'Persian'},
{code3: 'phi', code2: ' ', name: 'Philippine languages'},
{code3: 'phn', code2: ' ', name: 'Phoenician'},
{code3: 'phi', code2: '', name: 'Philippine languages'},
{code3: 'phn', code2: '', name: 'Phoenician'},
{code3: 'pli', code2: 'pi', name: 'Pali'},
{code3: 'pol', code2: 'pl', name: 'Polish'},
{code3: 'pon', code2: ' ', name: 'Pohnpeian'},
{code3: 'pon', code2: '', name: 'Pohnpeian'},
{code3: 'por', code2: 'pt', name: 'Portuguese'},
{code3: 'pra', code2: ' ', name: 'Prakrit languages'},
{code3: 'pra', code2: '', name: 'Prakrit languages'},
{
code3: 'pro',
code2: ' ',
code2: '',
name: 'Provençal, Old (to 1500);Occitan, Old (to 1500)',
},
{code3: 'pus', code2: 'ps', name: 'Pushto; Pashto'},
{code3: 'que', code2: 'qu', name: 'Quechua'},
{code3: 'raj', code2: ' ', name: 'Rajasthani'},
{code3: 'rap', code2: ' ', name: 'Rapanui'},
{code3: 'rar', code2: ' ', name: 'Rarotongan; Cook Islands Maori'},
{code3: 'roa', code2: ' ', name: 'Romance languages'},
{code3: 'raj', code2: '', name: 'Rajasthani'},
{code3: 'rap', code2: '', name: 'Rapanui'},
{code3: 'rar', code2: '', name: 'Rarotongan; Cook Islands Maori'},
{code3: 'roa', code2: '', name: 'Romance languages'},
{code3: 'roh', code2: 'rm', name: 'Romansh'},
{code3: 'rom', code2: ' ', name: 'Romany'},
{code3: 'rom', code2: '', name: 'Romany'},
{code3: 'rum', code2: 'ro', name: 'Romanian; Moldavian; Moldovan'},
{code3: 'ron', code2: 'ro', name: 'Romanian; Moldavian; Moldovan'},
{code3: 'run', code2: 'rn', name: 'Rundi'},
{code3: 'rup', code2: ' ', name: 'Aromanian; Arumanian; Macedo-Romanian'},
{code3: 'rup', code2: '', name: 'Aromanian; Arumanian; Macedo-Romanian'},
{code3: 'rus', code2: 'ru', name: 'Russian'},
{code3: 'sad', code2: ' ', name: 'Sandawe'},
{code3: 'sad', code2: '', name: 'Sandawe'},
{code3: 'sag', code2: 'sg', name: 'Sango'},
{code3: 'sah', code2: ' ', name: 'Yakut'},
{code3: 'sai', code2: ' ', name: 'South American Indian languages'},
{code3: 'sal', code2: ' ', name: 'Salishan languages'},
{code3: 'sam', code2: ' ', name: 'Samaritan Aramaic'},
{code3: 'sah', code2: '', name: 'Yakut'},
{code3: 'sai', code2: '', name: 'South American Indian languages'},
{code3: 'sal', code2: '', name: 'Salishan languages'},
{code3: 'sam', code2: '', name: 'Samaritan Aramaic'},
{code3: 'san', code2: 'sa', name: 'Sanskrit'},
{code3: 'sas', code2: ' ', name: 'Sasak'},
{code3: 'sat', code2: ' ', name: 'Santali'},
{code3: 'scn', code2: ' ', name: 'Sicilian'},
{code3: 'sco', code2: ' ', name: 'Scots'},
{code3: 'sel', code2: ' ', name: 'Selkup'},
{code3: 'sem', code2: ' ', name: 'Semitic languages'},
{code3: 'sga', code2: ' ', name: 'Irish, Old (to 900)'},
{code3: 'sgn', code2: ' ', name: 'Sign Languages'},
{code3: 'shn', code2: ' ', name: 'Shan'},
{code3: 'sid', code2: ' ', name: 'Sidamo'},
{code3: 'sas', code2: '', name: 'Sasak'},
{code3: 'sat', code2: '', name: 'Santali'},
{code3: 'scn', code2: '', name: 'Sicilian'},
{code3: 'sco', code2: '', name: 'Scots'},
{code3: 'sel', code2: '', name: 'Selkup'},
{code3: 'sem', code2: '', name: 'Semitic languages'},
{code3: 'sga', code2: '', name: 'Irish, Old (to 900)'},
{code3: 'sgn', code2: '', name: 'Sign Languages'},
{code3: 'shn', code2: '', name: 'Shan'},
{code3: 'sid', code2: '', name: 'Sidamo'},
{code3: 'sin', code2: 'si', name: 'Sinhala; Sinhalese'},
{code3: 'sio', code2: ' ', name: 'Siouan languages'},
{code3: 'sit', code2: ' ', name: 'Sino-Tibetan languages'},
{code3: 'sla', code2: ' ', name: 'Slavic languages'},
{code3: 'sio', code2: '', name: 'Siouan languages'},
{code3: 'sit', code2: '', name: 'Sino-Tibetan languages'},
{code3: 'sla', code2: '', name: 'Slavic languages'},
{code3: 'slo', code2: 'sk', name: 'Slovak'},
{code3: 'slk', code2: 'sk', name: 'Slovak'},
{code3: 'slv', code2: 'sl', name: 'Slovenian'},
{code3: 'sma', code2: ' ', name: 'Southern Sami'},
{code3: 'sma', code2: '', name: 'Southern Sami'},
{code3: 'sme', code2: 'se', name: 'Northern Sami'},
{code3: 'smi', code2: ' ', name: 'Sami languages'},
{code3: 'smj', code2: ' ', name: 'Lule Sami'},
{code3: 'smn', code2: ' ', name: 'Inari Sami'},
{code3: 'smi', code2: '', name: 'Sami languages'},
{code3: 'smj', code2: '', name: 'Lule Sami'},
{code3: 'smn', code2: '', name: 'Inari Sami'},
{code3: 'smo', code2: 'sm', name: 'Samoan'},
{code3: 'sms', code2: ' ', name: 'Skolt Sami'},
{code3: 'sms', code2: '', name: 'Skolt Sami'},
{code3: 'sna', code2: 'sn', name: 'Shona'},
{code3: 'snd', code2: 'sd', name: 'Sindhi'},
{code3: 'snk', code2: ' ', name: 'Soninke'},
{code3: 'sog', code2: ' ', name: 'Sogdian'},
{code3: 'snk', code2: '', name: 'Soninke'},
{code3: 'sog', code2: '', name: 'Sogdian'},
{code3: 'som', code2: 'so', name: 'Somali'},
{code3: 'son', code2: ' ', name: 'Songhai languages'},
{code3: 'son', code2: '', name: 'Songhai languages'},
{code3: 'sot', code2: 'st', name: 'Sotho, Southern'},
{code3: 'spa', code2: 'es', name: 'Spanish'},
{code3: 'sqi', code2: 'sq', name: 'Albanian'},
{code3: 'srd', code2: 'sc', name: 'Sardinian'},
{code3: 'srn', code2: ' ', name: 'Sranan Tongo'},
{code3: 'srn', code2: '', name: 'Sranan Tongo'},
{code3: 'srp', code2: 'sr', name: 'Serbian'},
{code3: 'srr', code2: ' ', name: 'Serer'},
{code3: 'ssa', code2: ' ', name: 'Nilo-Saharan languages'},
{code3: 'srr', code2: '', name: 'Serer'},
{code3: 'ssa', code2: '', name: 'Nilo-Saharan languages'},
{code3: 'ssw', code2: 'ss', name: 'Swati'},
{code3: 'suk', code2: ' ', name: 'Sukuma'},
{code3: 'suk', code2: '', name: 'Sukuma'},
{code3: 'sun', code2: 'su', name: 'Sundanese'},
{code3: 'sus', code2: ' ', name: 'Susu'},
{code3: 'sux', code2: ' ', name: 'Sumerian'},
{code3: 'sus', code2: '', name: 'Susu'},
{code3: 'sux', code2: '', name: 'Sumerian'},
{code3: 'swa', code2: 'sw', name: 'Swahili'},
{code3: 'swe', code2: 'sv', name: 'Swedish'},
{code3: 'syc', code2: ' ', name: 'Classical Syriac'},
{code3: 'syr', code2: ' ', name: 'Syriac'},
{code3: 'syc', code2: '', name: 'Classical Syriac'},
{code3: 'syr', code2: '', name: 'Syriac'},
{code3: 'tah', code2: 'ty', name: 'Tahitian'},
{code3: 'tai', code2: ' ', name: 'Tai languages'},
{code3: 'tai', code2: '', name: 'Tai languages'},
{code3: 'tam', code2: 'ta', name: 'Tamil'},
{code3: 'tat', code2: 'tt', name: 'Tatar'},
{code3: 'tel', code2: 'te', name: 'Telugu'},
{code3: 'tem', code2: ' ', name: 'Timne'},
{code3: 'ter', code2: ' ', name: 'Tereno'},
{code3: 'tet', code2: ' ', name: 'Tetum'},
{code3: 'tem', code2: '', name: 'Timne'},
{code3: 'ter', code2: '', name: 'Tereno'},
{code3: 'tet', code2: '', name: 'Tetum'},
{code3: 'tgk', code2: 'tg', name: 'Tajik'},
{code3: 'tgl', code2: 'tl', name: 'Tagalog'},
{code3: 'tha', code2: 'th', name: 'Thai'},
{code3: 'tib', code2: 'bo', name: 'Tibetan'},
{code3: 'tig', code2: ' ', name: 'Tigre'},
{code3: 'tig', code2: '', name: 'Tigre'},
{code3: 'tir', code2: 'ti', name: 'Tigrinya'},
{code3: 'tiv', code2: ' ', name: 'Tiv'},
{code3: 'tkl', code2: ' ', name: 'Tokelau'},
{code3: 'tlh', code2: ' ', name: 'Klingon; tlhIngan-Hol'},
{code3: 'tli', code2: ' ', name: 'Tlingit'},
{code3: 'tmh', code2: ' ', name: 'Tamashek'},
{code3: 'tog', code2: ' ', name: 'Tonga (Nyasa)'},
{code3: 'tiv', code2: '', name: 'Tiv'},
{code3: 'tkl', code2: '', name: 'Tokelau'},
{code3: 'tlh', code2: '', name: 'Klingon; tlhIngan-Hol'},
{code3: 'tli', code2: '', name: 'Tlingit'},
{code3: 'tmh', code2: '', name: 'Tamashek'},
{code3: 'tog', code2: '', name: 'Tonga (Nyasa)'},
{code3: 'ton', code2: 'to', name: 'Tonga (Tonga Islands)'},
{code3: 'tpi', code2: ' ', name: 'Tok Pisin'},
{code3: 'tsi', code2: ' ', name: 'Tsimshian'},
{code3: 'tpi', code2: '', name: 'Tok Pisin'},
{code3: 'tsi', code2: '', name: 'Tsimshian'},
{code3: 'tsn', code2: 'tn', name: 'Tswana'},
{code3: 'tso', code2: 'ts', name: 'Tsonga'},
{code3: 'tuk', code2: 'tk', name: 'Turkmen'},
{code3: 'tum', code2: ' ', name: 'Tumbuka'},
{code3: 'tup', code2: ' ', name: 'Tupi languages'},
{code3: 'tum', code2: '', name: 'Tumbuka'},
{code3: 'tup', code2: '', name: 'Tupi languages'},
{code3: 'tur', code2: 'tr', name: 'Turkish'},
{code3: 'tut', code2: ' ', name: 'Altaic languages'},
{code3: 'tvl', code2: ' ', name: 'Tuvalu'},
{code3: 'tut', code2: '', name: 'Altaic languages'},
{code3: 'tvl', code2: '', name: 'Tuvalu'},
{code3: 'twi', code2: 'tw', name: 'Twi'},
{code3: 'tyv', code2: ' ', name: 'Tuvinian'},
{code3: 'udm', code2: ' ', name: 'Udmurt'},
{code3: 'uga', code2: ' ', name: 'Ugaritic'},
{code3: 'tyv', code2: '', name: 'Tuvinian'},
{code3: 'udm', code2: '', name: 'Udmurt'},
{code3: 'uga', code2: '', name: 'Ugaritic'},
{code3: 'uig', code2: 'ug', name: 'Uighur; Uyghur'},
{code3: 'ukr', code2: 'uk', name: 'Ukrainian'},
{code3: 'umb', code2: ' ', name: 'Umbundu'},
{code3: 'und', code2: ' ', name: 'Undetermined'},
{code3: 'umb', code2: '', name: 'Umbundu'},
{code3: 'und', code2: '', name: 'Undetermined'},
{code3: 'urd', code2: 'ur', name: 'Urdu'},
{code3: 'uzb', code2: 'uz', name: 'Uzbek'},
{code3: 'vai', code2: ' ', name: 'Vai'},
{code3: 'vai', code2: '', name: 'Vai'},
{code3: 'ven', code2: 've', name: 'Venda'},
{code3: 'vie', code2: 'vi', name: 'Vietnamese'},
{code3: 'vol', code2: 'vo', name: 'Volapük'},
{code3: 'vot', code2: ' ', name: 'Votic'},
{code3: 'wak', code2: ' ', name: 'Wakashan languages'},
{code3: 'wal', code2: ' ', name: 'Wolaitta; Wolaytta'},
{code3: 'war', code2: ' ', name: 'Waray'},
{code3: 'was', code2: ' ', name: 'Washo'},
{code3: 'vot', code2: '', name: 'Votic'},
{code3: 'wak', code2: '', name: 'Wakashan languages'},
{code3: 'wal', code2: '', name: 'Wolaitta; Wolaytta'},
{code3: 'war', code2: '', name: 'Waray'},
{code3: 'was', code2: '', name: 'Washo'},
{code3: 'wel', code2: 'cy', name: 'Welsh'},
{code3: 'wen', code2: ' ', name: 'Sorbian languages'},
{code3: 'wen', code2: '', name: 'Sorbian languages'},
{code3: 'wln', code2: 'wa', name: 'Walloon'},
{code3: 'wol', code2: 'wo', name: 'Wolof'},
{code3: 'xal', code2: ' ', name: 'Kalmyk; Oirat'},
{code3: 'xal', code2: '', name: 'Kalmyk; Oirat'},
{code3: 'xho', code2: 'xh', name: 'Xhosa'},
{code3: 'yao', code2: ' ', name: 'Yao'},
{code3: 'yap', code2: ' ', name: 'Yapese'},
{code3: 'yao', code2: '', name: 'Yao'},
{code3: 'yap', code2: '', name: 'Yapese'},
{code3: 'yid', code2: 'yi', name: 'Yiddish'},
{code3: 'yor', code2: 'yo', name: 'Yoruba'},
{code3: 'ypk', code2: ' ', name: 'Yupik languages'},
{code3: 'zap', code2: ' ', name: 'Zapotec'},
{code3: 'zbl', code2: ' ', name: 'Blissymbols; Blissymbolics; Bliss'},
{code3: 'zen', code2: ' ', name: 'Zenaga'},
{code3: 'zgh', code2: ' ', name: 'Standard Moroccan Tamazight'},
{code3: 'ypk', code2: '', name: 'Yupik languages'},
{code3: 'zap', code2: '', name: 'Zapotec'},
{code3: 'zbl', code2: '', name: 'Blissymbols; Blissymbolics; Bliss'},
{code3: 'zen', code2: '', name: 'Zenaga'},
{code3: 'zgh', code2: '', name: 'Standard Moroccan Tamazight'},
{code3: 'zha', code2: 'za', name: 'Zhuang; Chuang'},
{code3: 'zho', code2: 'zh', name: 'Chinese'},
{code3: 'znd', code2: ' ', name: 'Zande languages'},
{code3: 'znd', code2: '', name: 'Zande languages'},
{code3: 'zul', code2: 'zu', name: 'Zulu'},
{code3: 'zun', code2: ' ', name: 'Zuni'},
{code3: 'zun', code2: '', name: 'Zuni'},
{
code3: 'zza',
code2: '',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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