Squashed commit of the following:
commitaf5262682eAuthor: Minseo Lee <itoupluk427@gmail.com> Date: Thu Aug 8 21:12:23 2024 +0900 Added trans (#4890) commita864f69849Author: dan <dan.abramov@gmail.com> Date: Thu Aug 8 06:20:24 2024 +0100 Keep interstitial fresh on refresh (#4888) commit00fea10782Author: dan <dan.abramov@gmail.com> Date: Thu Aug 8 05:56:22 2024 +0100 Include popcluster in suggestion ranking (#4887) commitb3092413ddAuthor: Hailey <me@haileyok.com> Date: Wed Aug 7 17:13:29 2024 -0700 Add logging of selected feed preference when displaying the following feed (#4789) commit1b02f81cb8Author: Hailey <me@haileyok.com> Date: Wed Aug 7 14:45:06 2024 -0700 [Video] Visibility detection view (#4741) Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> commitfff2c079c2Author: Samuel Newman <mozzius@protonmail.com> Date: Wed Aug 7 18:47:51 2024 +0100 [Videos] Video player - PR #2 - better web support (#4732) * attempt some sort of "usurping" system * polling-based active video approach * split into inner component again * click to steal active video * disable findAndActivateVideo on native * new intersectionobserver approach - wip * fix types * disable perf optimisation to allow overflow * make active player indicator subtler, clean up video utils * partially fix double-playing * start working on controls * fullscreen API * get buttons working somewhat * rm source from where it shouldn't be * use video elem as source of truth * fix keyboard nav + mute state * new icons, add fullscreen + time + fix play * unmount when far offscreen + round 2dp * listen globally to clicks rather than blur event * move controls to new file * reduce quality when not active * add hover state to buttons * stop propagation of videoplayer click * move around autoplay effects * increase background contrast * add subtitles button * add stopPropagation to root of video player * clean up VideoWebControls * fix chrome * change quality based on focused state * use autoLevelCapping instead of nextLevel * get subtitle track from stream * always use hlsjs * rework hls into a ref * render player earlier, allowing preload * add error boundary * clean up component structure and organisation * rework fullscreen API * disable fullscreen on iPhone * don't play when ready on pause * debounce buffering * simplify giant list of event listeners * update pref * reduce prop drilling * minimise rerenders in `ActiveViewContext` * restore prop drilling --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> Co-authored-by: Hailey <me@haileyok.com> commitb701e8c68cAuthor: Samuel Newman <mozzius@protonmail.com> Date: Wed Aug 7 16:56:12 2024 +0100 [Video] Authed video upload (#4885) * add service auth call * update API package --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> commit753a233408Author: Hailey <me@haileyok.com> Date: Tue Aug 6 11:21:59 2024 -0700 Tweak feed manip to show cases of A -> B without further children (#4883) commit5845e08eeeAuthor: dan <dan.abramov@gmail.com> Date: Tue Aug 6 17:12:27 2024 +0100 Show own replies before follows' replies in threads (#4882) commitb291a1ed8aAuthor: dan <dan.abramov@gmail.com> Date: Tue Aug 6 16:42:42 2024 +0100 Show more replies in Following (different heuristic) (#4880) commit686d5ebb53Author: dan <dan.abramov@gmail.com> Date: Tue Aug 6 01:30:52 2024 +0100 [Persisted] Make broadcast subscriptions granular by key (#4874) * Add fast path for guaranteed noop updates * Change persisted.onUpdate() API to take a key * Implement granular broadcast listeners commit966f6c511fAuthor: dan <dan.abramov@gmail.com> Date: Tue Aug 6 01:03:27 2024 +0100 [Persisted] Fix the race condition causing clobbered writes between tabs (#4873) * Broadcast the update in the same tick The motivation for the original code is unclear. I was not able to reproduce the described behavior and have not seen it mentioned on the web. I'll assume that this was a misunderstanding. * Remove defensive programming The only places in this code that we can expect to throw are schema.parse(), JSON.parse(), JSON.stringify(), and localStorage.getItem/setItem/removeItem. Let's push try/catch'es where we expect them to be necessary. * Don't write or clobber defaults Writing defaults to local storage is unnecessary. We would write them as a part of next update anyway. So I'm removing that to reduce the number of moving pieces. However, we do need to be wary of _state being set to defaults. Because _state gets mutated on write. We don't want to mutate the defaults object. To avoid having to think about this, let's copy on write. We don't write to this object very often. * Refactor: extract tryParse * Refactor: move string parsing into tryParse * Extract tryStringify, split logging by platform Shared data parsing/stringification errors are always logged. Storage errors are only logged on native because we trust the web APIs to work. * Add a layer of caching to readFromStorage to web We're going to be doing a read on every write so let's add a fast path that avoids parsing and validating. * Fix the race condition causing clobbered writes between tabs commit5bf7f3769dAuthor: dan <dan.abramov@gmail.com> Date: Tue Aug 6 00:30:58 2024 +0100 [Persisted] Fork web and native, make it synchronous on the web (#4872) * Delete logic for legacy storage * Delete superfluous tests At this point these tests aren't testing anything useful, let's just get rid of them. * Inline store.ts methods into persisted/index.ts * Fork persisted/index.ts into index.web.ts * Remove non-essential code and comments from both forks * Remove async/await from web fork of persisted/index.ts * Remove unused return * Enforce that forked types match commit74b0318d89Author: dan <dan.abramov@gmail.com> Date: Mon Aug 5 20:51:41 2024 +0100 Show replies in context of their threads (#4871) * Don't reconstruct threads from separate posts * Remove post-level dedupe for now * Change repost dedupe condition to look just at length * Delete unused isThread * Delete another isThread field It is now meaningless because there's nothing special about author threads. * Narrow down slice item shape so it does not need reply * Consolidate slice validation criteria in one place * Show replies in context * Make fallback marker work * Remove misleading and now-unused property It was called rootUri but it was actually the leaf URI. Regardless, it's not used anymore. * Add by-thread dedupe to non-author feeds * Add post-level dedupe * Always count from the start This is easier to think about. * Only tuner state need to be untouched on dry run * Account for threads in reply filtering * Remove repost deduping This is already being taken care of by item-level deduping. It's also now wrong and removing too much (since it wasn't filtering for reposts directly). * Calculate rootUri correctly * Apply Following settings to all lists * Don't dedupe intentional reposts by thread * Show reply parent when ambiguous * Explicitly remove orphaned replies from following/lists * Fix thread dedupe to work across pages * Mark grandparent-blocked as orphaned * Guard tuner state change by dryRun * Remove dead code * Don't dedupe feedgen threads * Revert "Apply Following settings to all lists" This reverts commit aff86be6d37b60cc5d0ac38f22c31a4808342cf4. Let's not do this yet and have a bit more discussion. This is a chunky change already. * Reason belongs to a slice, not item * Logically feedContext belongs to the slice * Update comment to reflect latest behavior commit18b423396bAuthor: Hailey <me@haileyok.com> Date: Mon Aug 5 12:21:34 2024 -0700 Add `PlatformInfo` module (#4877) commitfb278384c6Author: bnewbold <bnewbold@robocracy.org> Date: Fri Aug 2 15:57:50 2024 -0700 bskyweb: optional basic auth password middleware (#4759) commit6298e6897fAuthor: Samuel Newman <mozzius@protonmail.com> Date: Sat Aug 3 00:33:45 2024 +0200 tweak list header (#4870) Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> commitc3d8beee6dAuthor: Eric Bailey <git@esb.lol> Date: Fri Aug 2 13:05:33 2024 -0500 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 commit293ac6fab2Author: dan <dan.abramov@gmail.com> Date: Fri Aug 2 17:13:31 2024 +0100 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 commit7f292abf51Author: dan <dan.abramov@gmail.com> Date: Thu Aug 1 22:05:40 2024 +0100 Always limit Following replies to the people you follow (#4868) * Limit feed replies to people you follow * Remove dead code commitf056cb646eAuthor: Hailey <me@haileyok.com> Date: Thu Aug 1 10:32:36 2024 -0700 Fix missing header on Likes/Reposted By, add missing perf optimizations (#4867) * fix liked by list * fix lists * tweaks to style * change string commitc78e9e3147Author: Samuel Newman <mozzius@protonmail.com> Date: Thu Aug 1 19:14:32 2024 +0200 Move theme controls to its own screen (#4866) commit388c157c36Author: dan <dan.abramov@gmail.com> Date: Thu Aug 1 17:49:43 2024 +0100 Display second-to-last rather than second post in a slice (#4864) commitb0e130a4d8Author: Eric Bailey <git@esb.lol> Date: Thu Aug 1 10:29:27 2024 -0500 Update muted words dialog with `expiresAt` and `actorTarget` (#4801) * WIP not working dropdown * Update MutedWords dialog * Add i18n formatDistance * Comments * Handle text wrapping * Update label copy Co-authored-by: Hailey <me@haileyok.com> * Fix alignment * Improve translation output * Revert toggle changes * Better types for useFormatDistance * Tweaks * Integrate new sdk version into TagMenu * Use ampersand Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Bump SDK --------- Co-authored-by: Hailey <me@haileyok.com> Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> commitd2e88cc623Author: dan <dan.abramov@gmail.com> Date: Thu Aug 1 02:27:25 2024 +0100 Fetch enough pages to fill a page's worth of items (#4863) * Fetch enough pages to fill a page's worth of items * Add failsafe in case of appview bug commit70ffd387e3Author: Hailey <me@haileyok.com> Date: Wed Jul 31 11:16:14 2024 -0700 Only show "followed you back" when appropriate (#4849) * only show followed back when we should * try/catch * log * Update FeedItem.tsx * tweak commit576cef88b5Author: dan <dan.abramov@gmail.com> Date: Wed Jul 31 19:10:24 2024 +0100 [Web] Retrigger onEndReached if needed when content height changes (#4859) * Extract EdgeVisibility * Key Visibility by container height instead of item count commitc75bb65befAuthor: dan <dan.abramov@gmail.com> Date: Wed Jul 31 13:00:22 2024 +0100 Remove unused NoopFeedTuner (#4856) commitc3e77b56ffAuthor: GSMT <samaritanojr006@gmail.com> Date: Wed Jul 31 00:19:23 2024 +0200 useDedupe callback (#4855) commit8ddb28d3c5Author: Hailey <me@haileyok.com> Date: Tue Jul 30 08:25:31 2024 -0700 [Video] Uploads (#4754) * state for video uploads * get upload working * add a debug log * add post progress * progress * fetch data * add some progress info, web uploads * post on finished uploading (wip) * add a note * add some todos * clear video * merge some stuff * convert to `createUploadTask` * patch expo modules core * working native upload progress * platform fork * upload progress for web * cleanup * cleanup * more tweaks * simplify * fix type errors --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> Merge remote-tracking branch 'upstream/main' into Improve-notification-localization
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 369 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 443 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 403 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 183 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 326 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="M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 184 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 276 B |
@@ -67,6 +67,13 @@ func run(args []string) {
|
|||||||
Required: false,
|
Required: false,
|
||||||
EnvVars: []string{"DEBUG"},
|
EnvVars: []string{"DEBUG"},
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "basic-auth-password",
|
||||||
|
Usage: "optional password to restrict access to web interface",
|
||||||
|
Required: false,
|
||||||
|
Value: "",
|
||||||
|
EnvVars: []string{"BASIC_AUTH_PASSWORD"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -48,6 +49,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
appviewHost := cctx.String("appview-host")
|
appviewHost := cctx.String("appview-host")
|
||||||
ogcardHost := cctx.String("ogcard-host")
|
ogcardHost := cctx.String("ogcard-host")
|
||||||
linkHost := cctx.String("link-host")
|
linkHost := cctx.String("link-host")
|
||||||
|
basicAuthPassword := cctx.String("basic-auth-password")
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
@@ -140,6 +142,18 @@ func serve(cctx *cli.Context) error {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// optional password gating of entire web interface
|
||||||
|
if basicAuthPassword != "" {
|
||||||
|
e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
|
||||||
|
// Be careful to use constant time comparison to prevent timing attacks
|
||||||
|
if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(password), []byte(basicAuthPassword)) == 1 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// redirect trailing slash to non-trailing slash.
|
// redirect trailing slash to non-trailing slash.
|
||||||
// all of our current endpoints have no trailing slash.
|
// all of our current endpoints have no trailing slash.
|
||||||
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
|
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
|
||||||
@@ -211,6 +225,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
e.GET("/settings/threads", server.WebGeneric)
|
e.GET("/settings/threads", server.WebGeneric)
|
||||||
e.GET("/settings/external-embeds", server.WebGeneric)
|
e.GET("/settings/external-embeds", server.WebGeneric)
|
||||||
e.GET("/settings/accessibility", server.WebGeneric)
|
e.GET("/settings/accessibility", server.WebGeneric)
|
||||||
|
e.GET("/settings/appearance", server.WebGeneric)
|
||||||
e.GET("/sys/debug", server.WebGeneric)
|
e.GET("/sys/debug", server.WebGeneric)
|
||||||
e.GET("/sys/debug-mod", server.WebGeneric)
|
e.GET("/sys/debug-mod", server.WebGeneric)
|
||||||
e.GET("/sys/log", server.WebGeneric)
|
e.GET("/sys/log", server.WebGeneric)
|
||||||
|
|||||||
@@ -95,3 +95,16 @@ jest.mock('expo-application', () => ({
|
|||||||
nativeApplicationVersion: '1.0.0',
|
nativeApplicationVersion: '1.0.0',
|
||||||
nativeBuildVersion: '1',
|
nativeBuildVersion: '1',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
jest.mock('expo-modules-core', () => ({
|
||||||
|
requireNativeModule: jest.fn().mockImplementation(moduleName => {
|
||||||
|
if (moduleName === 'ExpoPlatformInfo') {
|
||||||
|
return {
|
||||||
|
getIsReducedMotionEnabled: () => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
|
||||||
|
return () => null
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package expo.modules.blueskyswissarmy.platforminfo
|
||||||
|
|
||||||
|
import android.provider.Settings
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class ExpoPlatformInfoModule : Module() {
|
||||||
|
override fun definition() =
|
||||||
|
ModuleDefinition {
|
||||||
|
Name("ExpoPlatformInfo")
|
||||||
|
|
||||||
|
// See https://github.com/software-mansion/react-native-reanimated/blob/7df5fd57d608fe25724608835461cd925ff5151d/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeProxy/NativeProxyCommon.java#L242
|
||||||
|
Function("getIsReducedMotionEnabled") {
|
||||||
|
val resolver = appContext.reactContext?.contentResolver ?: return@Function false
|
||||||
|
val scale = Settings.Global.getString(resolver, Settings.Global.TRANSITION_ANIMATION_SCALE) ?: return@Function false
|
||||||
|
|
||||||
|
try {
|
||||||
|
return@Function scale.toFloat() == 0f
|
||||||
|
} catch (_: Error) {
|
||||||
|
return@Function false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package expo.modules.blueskyswissarmy.visibilityview
|
||||||
|
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class ExpoBlueskyVisibilityViewModule : Module() {
|
||||||
|
override fun definition() =
|
||||||
|
ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyVisibilityView")
|
||||||
|
|
||||||
|
AsyncFunction("updateActiveViewAsync") {
|
||||||
|
VisibilityViewManager.updateActiveView()
|
||||||
|
}
|
||||||
|
|
||||||
|
View(VisibilityView::class) {
|
||||||
|
Events(arrayOf("onChangeStatus"))
|
||||||
|
|
||||||
|
Prop("enabled") { view: VisibilityView, prop: Boolean ->
|
||||||
|
view.isViewEnabled = prop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package expo.modules.blueskyswissarmy.visibilityview
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Rect
|
||||||
|
import expo.modules.kotlin.AppContext
|
||||||
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
|
import expo.modules.kotlin.views.ExpoView
|
||||||
|
|
||||||
|
class VisibilityView(
|
||||||
|
context: Context,
|
||||||
|
appContext: AppContext,
|
||||||
|
) : ExpoView(context, appContext) {
|
||||||
|
var isViewEnabled: Boolean = false
|
||||||
|
|
||||||
|
private val onChangeStatus by EventDispatcher()
|
||||||
|
|
||||||
|
private var isCurrentlyActive = false
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
VisibilityViewManager.addView(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
VisibilityViewManager.removeView(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setIsCurrentlyActive(isActive: Boolean) {
|
||||||
|
if (isCurrentlyActive == isActive) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isCurrentlyActive = isActive
|
||||||
|
this.onChangeStatus(
|
||||||
|
mapOf(
|
||||||
|
"isActive" to isActive,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPositionOnScreen(): Rect? {
|
||||||
|
if (!this.isShown) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val screenPosition = intArrayOf(0, 0)
|
||||||
|
this.getLocationInWindow(screenPosition)
|
||||||
|
return Rect(
|
||||||
|
screenPosition[0],
|
||||||
|
screenPosition[1],
|
||||||
|
screenPosition[0] + this.width,
|
||||||
|
screenPosition[1] + this.height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isViewableEnough(): Boolean {
|
||||||
|
val positionOnScreen = this.getPositionOnScreen() ?: return false
|
||||||
|
val visibleArea = positionOnScreen.width() * positionOnScreen.height()
|
||||||
|
val totalArea = this.width * this.height
|
||||||
|
return visibleArea >= 0.5 * totalArea
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package expo.modules.blueskyswissarmy.visibilityview
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
|
|
||||||
|
class VisibilityViewManager {
|
||||||
|
companion object {
|
||||||
|
private val views = HashMap<Int, VisibilityView>()
|
||||||
|
private var currentlyActiveView: VisibilityView? = null
|
||||||
|
private var prevCount = 0
|
||||||
|
|
||||||
|
fun addView(view: VisibilityView) {
|
||||||
|
this.views[view.id] = view
|
||||||
|
|
||||||
|
if (this.prevCount == 0) {
|
||||||
|
this.updateActiveView()
|
||||||
|
}
|
||||||
|
this.prevCount = this.views.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeView(view: VisibilityView) {
|
||||||
|
this.views.remove(view.id)
|
||||||
|
this.prevCount = this.views.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateActiveView() {
|
||||||
|
var activeView: VisibilityView? = null
|
||||||
|
val count = this.views.count()
|
||||||
|
|
||||||
|
if (count == 1) {
|
||||||
|
val view = this.views.values.first()
|
||||||
|
if (view.isViewableEnough()) {
|
||||||
|
activeView = view
|
||||||
|
}
|
||||||
|
} else if (count > 1) {
|
||||||
|
val views = this.views.values
|
||||||
|
var mostVisibleView: VisibilityView? = null
|
||||||
|
var mostVisiblePosition: Rect? = null
|
||||||
|
|
||||||
|
views.forEach { view ->
|
||||||
|
if (!view.isViewableEnough()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val position = view.getPositionOnScreen() ?: return@forEach
|
||||||
|
val topY = position.centerY() - (position.height() / 2)
|
||||||
|
|
||||||
|
if (topY >= 150) {
|
||||||
|
if (mostVisiblePosition == null) {
|
||||||
|
mostVisiblePosition = position
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.centerY() <= mostVisiblePosition!!.centerY()) {
|
||||||
|
mostVisibleView = view
|
||||||
|
mostVisiblePosition = position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeView = mostVisibleView
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeView == this.currentlyActiveView) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearActiveView()
|
||||||
|
if (activeView != null) {
|
||||||
|
this.setActiveView(activeView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearActiveView() {
|
||||||
|
this.currentlyActiveView?.setIsCurrentlyActive(false)
|
||||||
|
this.currentlyActiveView = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setActiveView(view: VisibilityView) {
|
||||||
|
view.setIsCurrentlyActive(true)
|
||||||
|
this.currentlyActiveView = view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
{
|
{
|
||||||
"platforms": ["ios", "tvos", "android", "web"],
|
"platforms": ["ios", "tvos", "android", "web"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"]
|
"modules": [
|
||||||
|
"ExpoBlueskySharedPrefsModule",
|
||||||
|
"ExpoBlueskyReferrerModule",
|
||||||
|
"ExpoBlueskyVisibilityViewModule",
|
||||||
|
"ExpoPlatformInfoModule"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"modules": [
|
"modules": [
|
||||||
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
|
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
|
||||||
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule"
|
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
|
||||||
|
"expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
|
||||||
|
"expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import * as PlatformInfo from './src/PlatformInfo'
|
||||||
import * as Referrer from './src/Referrer'
|
import * as Referrer from './src/Referrer'
|
||||||
import * as SharedPrefs from './src/SharedPrefs'
|
import * as SharedPrefs from './src/SharedPrefs'
|
||||||
|
import VisibilityView from './src/VisibilityView'
|
||||||
|
|
||||||
export {Referrer, SharedPrefs}
|
export {PlatformInfo, Referrer, SharedPrefs, VisibilityView}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
public class ExpoPlatformInfoModule: Module {
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("ExpoPlatformInfo")
|
||||||
|
|
||||||
|
Function("getIsReducedMotionEnabled") {
|
||||||
|
return UIAccessibility.isReduceMotionEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
public class ExpoBlueskyVisibilityViewModule: Module {
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyVisibilityView")
|
||||||
|
|
||||||
|
AsyncFunction("updateActiveViewAsync") {
|
||||||
|
VisibilityViewManager.shared.updateActiveView()
|
||||||
|
}
|
||||||
|
|
||||||
|
View(VisibilityView.self) {
|
||||||
|
Events([
|
||||||
|
"onChangeStatus"
|
||||||
|
])
|
||||||
|
|
||||||
|
Prop("enabled") { (view: VisibilityView, prop: Bool) in
|
||||||
|
view.enabled = prop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class VisibilityViewManager {
|
||||||
|
static let shared = VisibilityViewManager()
|
||||||
|
|
||||||
|
private let views = NSHashTable<VisibilityView>(options: .weakMemory)
|
||||||
|
private var currentlyActiveView: VisibilityView?
|
||||||
|
private var screenHeight: CGFloat = UIScreen.main.bounds.height
|
||||||
|
private var prevCount = 0
|
||||||
|
|
||||||
|
func addView(_ view: VisibilityView) {
|
||||||
|
self.views.add(view)
|
||||||
|
|
||||||
|
if self.prevCount == 0 {
|
||||||
|
self.updateActiveView()
|
||||||
|
}
|
||||||
|
self.prevCount = self.views.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeView(_ view: VisibilityView) {
|
||||||
|
self.views.remove(view)
|
||||||
|
self.prevCount = self.views.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateActiveView() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
var activeView: VisibilityView?
|
||||||
|
|
||||||
|
if self.views.count == 1 {
|
||||||
|
let view = self.views.allObjects[0]
|
||||||
|
if view.isViewableEnough() {
|
||||||
|
activeView = view
|
||||||
|
}
|
||||||
|
} else if self.views.count > 1 {
|
||||||
|
let views = self.views.allObjects
|
||||||
|
var mostVisibleView: VisibilityView?
|
||||||
|
var mostVisiblePosition: CGRect?
|
||||||
|
|
||||||
|
views.forEach { view in
|
||||||
|
if !view.isViewableEnough() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let position = view.getPositionOnScreen() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if position.minY >= 150 {
|
||||||
|
if mostVisiblePosition == nil {
|
||||||
|
mostVisiblePosition = position
|
||||||
|
}
|
||||||
|
|
||||||
|
if let unwrapped = mostVisiblePosition,
|
||||||
|
position.minY <= unwrapped.minY {
|
||||||
|
mostVisibleView = view
|
||||||
|
mostVisiblePosition = position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeView = mostVisibleView
|
||||||
|
}
|
||||||
|
|
||||||
|
if activeView == self.currentlyActiveView {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.clearActiveView()
|
||||||
|
if let view = activeView {
|
||||||
|
self.setActiveView(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearActiveView() {
|
||||||
|
if let currentlyActiveView = self.currentlyActiveView {
|
||||||
|
currentlyActiveView.setIsCurrentlyActive(isActive: false)
|
||||||
|
self.currentlyActiveView = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setActiveView(_ view: VisibilityView) {
|
||||||
|
view.setIsCurrentlyActive(isActive: true)
|
||||||
|
self.currentlyActiveView = view
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
class VisibilityView: ExpoView {
|
||||||
|
var enabled = false {
|
||||||
|
didSet {
|
||||||
|
if enabled {
|
||||||
|
VisibilityViewManager.shared.removeView(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let onChangeStatus = EventDispatcher()
|
||||||
|
private var isCurrentlyActiveView = false
|
||||||
|
|
||||||
|
required init(appContext: AppContext? = nil) {
|
||||||
|
super.init(appContext: appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func willMove(toWindow newWindow: UIWindow?) {
|
||||||
|
super.willMove(toWindow: newWindow)
|
||||||
|
|
||||||
|
if !self.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if newWindow == nil {
|
||||||
|
VisibilityViewManager.shared.removeView(self)
|
||||||
|
} else {
|
||||||
|
VisibilityViewManager.shared.addView(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setIsCurrentlyActive(isActive: Bool) {
|
||||||
|
if isCurrentlyActiveView == isActive {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.isCurrentlyActiveView = isActive
|
||||||
|
self.onChangeStatus([
|
||||||
|
"isActive": isActive
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚨 DANGER 🚨
|
||||||
|
// These functions need to be called from the main thread. Xcode will warn you if you call one of them
|
||||||
|
// off the main thread, so pay attention!
|
||||||
|
extension UIView {
|
||||||
|
func getPositionOnScreen() -> CGRect? {
|
||||||
|
if let window = self.window {
|
||||||
|
return self.convert(self.bounds, to: window)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isViewableEnough() -> Bool {
|
||||||
|
guard let window = self.window else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewFrameOnScreen = self.convert(self.bounds, to: window)
|
||||||
|
let screenBounds = window.bounds
|
||||||
|
let intersection = viewFrameOnScreen.intersection(screenBounds)
|
||||||
|
|
||||||
|
let viewHeight = viewFrameOnScreen.height
|
||||||
|
let intersectionHeight = intersection.height
|
||||||
|
|
||||||
|
return intersectionHeight >= 0.5 * viewHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import {requireNativeModule} from 'expo-modules-core'
|
||||||
|
|
||||||
|
const NativeModule = requireNativeModule('ExpoPlatformInfo')
|
||||||
|
|
||||||
|
export function getIsReducedMotionEnabled(): boolean {
|
||||||
|
return NativeModule.getIsReducedMotionEnabled()
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {NotImplementedError} from '../NotImplemented'
|
||||||
|
|
||||||
|
export function getIsReducedMotionEnabled(): boolean {
|
||||||
|
throw new NotImplementedError()
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function getIsReducedMotionEnabled(): boolean {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, ViewStyle} from 'react-native'
|
||||||
|
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||||
|
|
||||||
|
import {VisibilityViewProps} from './types'
|
||||||
|
const NativeView: React.ComponentType<{
|
||||||
|
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
enabled: Boolean
|
||||||
|
style: StyleProp<ViewStyle>
|
||||||
|
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
|
||||||
|
|
||||||
|
const NativeModule = requireNativeModule('ExpoBlueskyVisibilityView')
|
||||||
|
|
||||||
|
export async function updateActiveViewAsync() {
|
||||||
|
await NativeModule.updateActiveViewAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VisibilityView({
|
||||||
|
children,
|
||||||
|
onChangeStatus: onChangeStatusOuter,
|
||||||
|
enabled,
|
||||||
|
}: VisibilityViewProps) {
|
||||||
|
const onChangeStatus = React.useCallback(
|
||||||
|
(e: {nativeEvent: {isActive: boolean}}) => {
|
||||||
|
onChangeStatusOuter(e.nativeEvent.isActive)
|
||||||
|
},
|
||||||
|
[onChangeStatusOuter],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NativeView
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
enabled={enabled}
|
||||||
|
style={{flex: 1}}>
|
||||||
|
{children}
|
||||||
|
</NativeView>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {NotImplementedError} from '../NotImplemented'
|
||||||
|
import {VisibilityViewProps} from './types'
|
||||||
|
|
||||||
|
export async function updateActiveViewAsync() {
|
||||||
|
throw new NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VisibilityView({children}: VisibilityViewProps) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react'
|
||||||
|
export interface VisibilityViewProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
onChangeStatus: (isActive: boolean) => void
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.12.25",
|
"@atproto/api": "0.12.29",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
|
|||||||
@@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644
|
|||||||
|
|
||||||
Map<String, Object> constants = new HashMap<>(3);
|
Map<String, Object> constants = new HashMap<>(3);
|
||||||
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
|
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
|
||||||
|
diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js
|
||||||
|
index 109d3fe..c7fce9e 100644
|
||||||
|
--- a/node_modules/expo-modules-core/build/uuid/uuid.js
|
||||||
|
+++ b/node_modules/expo-modules-core/build/uuid/uuid.js
|
||||||
|
@@ -1,5 +1,7 @@
|
||||||
|
import bytesToUuid from './lib/bytesToUuid';
|
||||||
|
import { Uuidv5Namespace } from './uuid.types';
|
||||||
|
+import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled';
|
||||||
|
+ensureNativeModulesAreInstalled();
|
||||||
|
const nativeUuidv4 = globalThis?.expo?.uuidv4;
|
||||||
|
const nativeUuidv5 = globalThis?.expo?.uuidv5;
|
||||||
|
function uuidv4() {
|
||||||
|
|||||||
@@ -207,31 +207,3 @@ index 88b3fdf..2488ebc 100644
|
|||||||
|
|
||||||
const { layout, entering, exiting, sharedTransitionTag } = this.props;
|
const { layout, entering, exiting, sharedTransitionTag } = this.props;
|
||||||
if (
|
if (
|
||||||
diff --git a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
|
|
||||||
index ac9be5d..86d4605 100644
|
|
||||||
--- a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
|
|
||||||
+++ b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
|
|
||||||
@@ -47,4 +47,5 @@ export { LayoutAnimationConfig } from './component/LayoutAnimationConfig';
|
|
||||||
export { PerformanceMonitor } from './component/PerformanceMonitor';
|
|
||||||
export { startMapper, stopMapper } from './mappers';
|
|
||||||
export { startScreenTransition, finishScreenTransition, ScreenTransition } from './screenTransition';
|
|
||||||
+export { isReducedMotion } from './PlatformChecker';
|
|
||||||
//# sourceMappingURL=index.js.map
|
|
||||||
diff --git a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
|
|
||||||
index f01dc57..161ef22 100644
|
|
||||||
--- a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
|
|
||||||
+++ b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
|
|
||||||
@@ -36,3 +36,4 @@ export type { FlatListPropsWithLayout } from './component/FlatList';
|
|
||||||
export { startMapper, stopMapper } from './mappers';
|
|
||||||
export { startScreenTransition, finishScreenTransition, ScreenTransition, } from './screenTransition';
|
|
||||||
export type { AnimatedScreenTransition, GoBackGesture, ScreenTransitionConfig, } from './screenTransition';
|
|
||||||
+export { isReducedMotion } from './PlatformChecker';
|
|
||||||
diff --git a/node_modules/react-native-reanimated/src/reanimated2/index.ts b/node_modules/react-native-reanimated/src/reanimated2/index.ts
|
|
||||||
index 5885fa1..a3c693f 100644
|
|
||||||
--- a/node_modules/react-native-reanimated/src/reanimated2/index.ts
|
|
||||||
+++ b/node_modules/react-native-reanimated/src/reanimated2/index.ts
|
|
||||||
@@ -284,3 +284,4 @@ export type {
|
|
||||||
GoBackGesture,
|
|
||||||
ScreenTransitionConfig,
|
|
||||||
} from './screenTransition';
|
|
||||||
+export { isReducedMotion } from './PlatformChecker';
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import HashtagScreen from '#/screens/Hashtag'
|
|||||||
import {ModerationScreen} from '#/screens/Moderation'
|
import {ModerationScreen} from '#/screens/Moderation'
|
||||||
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||||
|
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||||
import {
|
import {
|
||||||
StarterPackScreen,
|
StarterPackScreen,
|
||||||
StarterPackScreenShort,
|
StarterPackScreenShort,
|
||||||
@@ -310,6 +311,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
|||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="AppearanceSettings"
|
||||||
|
getComponent={() => AppearanceSettingsScreen}
|
||||||
|
options={{
|
||||||
|
title: title(msg`Appearance Settings`),
|
||||||
|
requireAuth: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Hashtag"
|
name="Hashtag"
|
||||||
getComponent={() => HashtagScreen}
|
getComponent={() => HashtagScreen}
|
||||||
|
|||||||
@@ -92,14 +92,16 @@ function getRank(seenPost: SeenPost): string {
|
|||||||
tier = 'a'
|
tier = 'a'
|
||||||
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
||||||
tier = 'b'
|
tier = 'b'
|
||||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
} else if (seenPost.feedContext === 'popcluster') {
|
||||||
tier = 'c'
|
tier = 'c'
|
||||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||||
tier = 'd'
|
tier = 'd'
|
||||||
} else if (seenPost.feedContext === 'nettop') {
|
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||||
tier = 'e'
|
tier = 'e'
|
||||||
} else {
|
} else if (seenPost.feedContext === 'nettop') {
|
||||||
tier = 'f'
|
tier = 'f'
|
||||||
|
} else {
|
||||||
|
tier = 'g'
|
||||||
}
|
}
|
||||||
let score = Math.round(
|
let score = Math.round(
|
||||||
Math.log(
|
Math.log(
|
||||||
|
|||||||
@@ -122,8 +122,16 @@ export function ListHeaderDesktop({
|
|||||||
if (!gtTablet) return null
|
if (!gtTablet) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
|
<View
|
||||||
<Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.py_sm,
|
||||||
|
a.px_xl,
|
||||||
|
a.gap_xs,
|
||||||
|
a.justify_center,
|
||||||
|
{minHeight: 50},
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold]}>{title}</Text>
|
||||||
{subtitle ? (
|
{subtitle ? (
|
||||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
|
||||||
import * as Dialog from '#/components/Dialog'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
|
||||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
|
||||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
|
||||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
|
||||||
import {Divider} from '#/components/Divider'
|
|
||||||
import {Link} from '#/components/Link'
|
|
||||||
import {makeSearchLink} from '#/lib/routes/links'
|
import {makeSearchLink} from '#/lib/routes/links'
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
|
useRemoveMutedWordsMutation,
|
||||||
useUpsertMutedWordsMutation,
|
useUpsertMutedWordsMutation,
|
||||||
useRemoveMutedWordMutation,
|
|
||||||
} from '#/state/queries/preferences'
|
} from '#/state/queries/preferences'
|
||||||
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||||
|
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||||
|
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function useTagMenuControl() {
|
export function useTagMenuControl() {
|
||||||
return Dialog.useDialogControl()
|
return Dialog.useDialogControl()
|
||||||
@@ -52,10 +52,10 @@ export function TagMenu({
|
|||||||
reset: resetUpsert,
|
reset: resetUpsert,
|
||||||
} = useUpsertMutedWordsMutation()
|
} = useUpsertMutedWordsMutation()
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeMutedWord,
|
mutateAsync: removeMutedWords,
|
||||||
variables: optimisticRemove,
|
variables: optimisticRemove,
|
||||||
reset: resetRemove,
|
reset: resetRemove,
|
||||||
} = useRemoveMutedWordMutation()
|
} = useRemoveMutedWordsMutation()
|
||||||
const displayTag = '#' + tag
|
const displayTag = '#' + tag
|
||||||
|
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
@@ -65,9 +65,20 @@ export function TagMenu({
|
|||||||
optimisticUpsert?.find(
|
optimisticUpsert?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
)) &&
|
)) &&
|
||||||
!(optimisticRemove?.value === tag),
|
!optimisticRemove?.find(m => m?.value === tag),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mute word records that exactly match the tag in question.
|
||||||
|
*/
|
||||||
|
const removeableMuteWords = React.useMemo(() => {
|
||||||
|
return (
|
||||||
|
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||||
|
return word.value === tag
|
||||||
|
}) || []
|
||||||
|
)
|
||||||
|
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
@@ -212,13 +223,16 @@ export function TagMenu({
|
|||||||
control.close(() => {
|
control.close(() => {
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
resetUpsert()
|
resetUpsert()
|
||||||
removeMutedWord({
|
removeMutedWords(removeableMuteWords)
|
||||||
value: tag,
|
|
||||||
targets: ['tag'],
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
resetRemove()
|
resetRemove()
|
||||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
upsertMutedWord([
|
||||||
|
{
|
||||||
|
value: tag,
|
||||||
|
targets: ['tag'],
|
||||||
|
actorTarget: 'all',
|
||||||
|
},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import {msg} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
|
||||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
|
||||||
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||||
|
import {enforceLen} from '#/lib/strings/helpers'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
|
useRemoveMutedWordsMutation,
|
||||||
useUpsertMutedWordsMutation,
|
useUpsertMutedWordsMutation,
|
||||||
useRemoveMutedWordMutation,
|
|
||||||
} from '#/state/queries/preferences'
|
} from '#/state/queries/preferences'
|
||||||
import {enforceLen} from '#/lib/strings/helpers'
|
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||||
|
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
||||||
import {web} from '#/alf'
|
import {web} from '#/alf'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
@@ -47,8 +47,8 @@ export function TagMenu({
|
|||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
||||||
useUpsertMutedWordsMutation()
|
useUpsertMutedWordsMutation()
|
||||||
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
|
||||||
useRemoveMutedWordMutation()
|
useRemoveMutedWordsMutation()
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
(preferences?.moderationPrefs.mutedWords?.find(
|
(preferences?.moderationPrefs.mutedWords?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
@@ -56,10 +56,21 @@ export function TagMenu({
|
|||||||
optimisticUpsert?.find(
|
optimisticUpsert?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
)) &&
|
)) &&
|
||||||
!(optimisticRemove?.value === tag),
|
!optimisticRemove?.find(m => m?.value === tag),
|
||||||
)
|
)
|
||||||
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mute word records that exactly match the tag in question.
|
||||||
|
*/
|
||||||
|
const removeableMuteWords = React.useMemo(() => {
|
||||||
|
return (
|
||||||
|
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||||
|
return word.value === tag
|
||||||
|
}) || []
|
||||||
|
)
|
||||||
|
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||||
|
|
||||||
const dropdownItems = React.useMemo(() => {
|
const dropdownItems = React.useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -105,9 +116,11 @@ export function TagMenu({
|
|||||||
: _(msg`Mute ${truncatedTag}`),
|
: _(msg`Mute ${truncatedTag}`),
|
||||||
onPress() {
|
onPress() {
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
removeMutedWord({value: tag, targets: ['tag']})
|
removeMutedWords(removeableMuteWords)
|
||||||
} else {
|
} else {
|
||||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
upsertMutedWord([
|
||||||
|
{value: tag, targets: ['tag'], actorTarget: 'all'},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
testID: 'tagMenuMute',
|
testID: 'tagMenuMute',
|
||||||
@@ -129,7 +142,8 @@ export function TagMenu({
|
|||||||
tag,
|
tag,
|
||||||
truncatedTag,
|
truncatedTag,
|
||||||
upsertMutedWord,
|
upsertMutedWord,
|
||||||
removeMutedWord,
|
removeMutedWords,
|
||||||
|
removeableMuteWords,
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Keyboard, View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -24,6 +24,7 @@ import * as Dialog from '#/components/Dialog'
|
|||||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {useFormatDistance} from '#/components/hooks/dates'
|
||||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
@@ -32,6 +33,8 @@ import {Loader} from '#/components/Loader'
|
|||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
const ONE_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
export function MutedWordsDialog() {
|
export function MutedWordsDialog() {
|
||||||
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
||||||
return (
|
return (
|
||||||
@@ -53,16 +56,32 @@ function MutedWordsInner() {
|
|||||||
} = usePreferencesQuery()
|
} = usePreferencesQuery()
|
||||||
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
||||||
const [field, setField] = React.useState('')
|
const [field, setField] = React.useState('')
|
||||||
const [options, setOptions] = React.useState(['content'])
|
const [targets, setTargets] = React.useState(['content'])
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
|
const [durations, setDurations] = React.useState(['forever'])
|
||||||
|
const [excludeFollowing, setExcludeFollowing] = React.useState(false)
|
||||||
|
|
||||||
const submit = React.useCallback(async () => {
|
const submit = React.useCallback(async () => {
|
||||||
const sanitizedValue = sanitizeMutedWordValue(field)
|
const sanitizedValue = sanitizeMutedWordValue(field)
|
||||||
const targets = ['tag', options.includes('content') && 'content'].filter(
|
const surfaces = ['tag', targets.includes('content') && 'content'].filter(
|
||||||
Boolean,
|
Boolean,
|
||||||
) as AppBskyActorDefs.MutedWord['targets']
|
) as AppBskyActorDefs.MutedWord['targets']
|
||||||
|
const actorTarget = excludeFollowing ? 'exclude-following' : 'all'
|
||||||
|
|
||||||
if (!sanitizedValue || !targets.length) {
|
const now = Date.now()
|
||||||
|
const rawDuration = durations.at(0)
|
||||||
|
// undefined evaluates to 'forever'
|
||||||
|
let duration: string | undefined
|
||||||
|
|
||||||
|
if (rawDuration === '24_hours') {
|
||||||
|
duration = new Date(now + ONE_DAY).toISOString()
|
||||||
|
} else if (rawDuration === '7_days') {
|
||||||
|
duration = new Date(now + 7 * ONE_DAY).toISOString()
|
||||||
|
} else if (rawDuration === '30_days') {
|
||||||
|
duration = new Date(now + 30 * ONE_DAY).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sanitizedValue || !surfaces.length) {
|
||||||
setField('')
|
setField('')
|
||||||
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
||||||
return
|
return
|
||||||
@@ -70,28 +89,37 @@ function MutedWordsInner() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// send raw value and rely on SDK as sanitization source of truth
|
// send raw value and rely on SDK as sanitization source of truth
|
||||||
await addMutedWord([{value: field, targets}])
|
await addMutedWord([
|
||||||
|
{
|
||||||
|
value: field,
|
||||||
|
targets: surfaces,
|
||||||
|
actorTarget,
|
||||||
|
expiresAt: duration,
|
||||||
|
},
|
||||||
|
])
|
||||||
setField('')
|
setField('')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(`Failed to save muted word`, {message: e.message})
|
logger.error(`Failed to save muted word`, {message: e.message})
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
}
|
}
|
||||||
}, [_, field, options, addMutedWord, setField])
|
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||||
<View onTouchStart={Keyboard.dismiss}>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||||
<Trans>Add muted words and tags</Trans>
|
<Trans>Add muted words and tags</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
Posts can be muted based on their text, their tags, or both.
|
Posts can be muted based on their text, their tags, or both. We
|
||||||
|
recommend avoiding common words that appear in many posts, since it
|
||||||
|
can result in no posts being shown.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={[a.pb_lg]}>
|
<View style={[a.pb_sm]}>
|
||||||
<Dialog.Input
|
<Dialog.Input
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -107,30 +135,135 @@ function MutedWordsInner() {
|
|||||||
}}
|
}}
|
||||||
onSubmitEditing={submit}
|
onSubmitEditing={submit}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.pb_xl, a.gap_sm]}>
|
||||||
<Toggle.Group
|
<Toggle.Group
|
||||||
label={_(msg`Toggle between muted word options.`)}
|
label={_(msg`Select how long to mute this word for.`)}
|
||||||
type="radio"
|
type="radio"
|
||||||
values={options}
|
values={durations}
|
||||||
onChange={setOptions}>
|
onChange={setDurations}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Duration:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.pt_sm,
|
gtMobile && [a.flex_row, a.align_center, a.justify_start],
|
||||||
a.py_sm,
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
|
a.justify_start,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.gap_sm,
|
a.gap_sm,
|
||||||
a.flex_wrap,
|
|
||||||
]}>
|
]}>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word until you unmute it`)}
|
||||||
|
name="forever"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>Forever</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 24 hours`)}
|
||||||
|
name="24_hours"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>24 hours</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_start,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 7 days`)}
|
||||||
|
name="7_days"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>7 days</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 30 days`)}
|
||||||
|
name="30_days"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>30 days</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Toggle.Group>
|
||||||
|
|
||||||
|
<Toggle.Group
|
||||||
|
label={_(msg`Select what content this mute word should apply to.`)}
|
||||||
|
type="radio"
|
||||||
|
values={targets}
|
||||||
|
onChange={setTargets}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Mute in:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
|
||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
label={_(msg`Mute this word in post text and tags`)}
|
label={_(msg`Mute this word in post text and tags`)}
|
||||||
name="content"
|
name="content"
|
||||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
style={[a.flex_1]}>
|
||||||
<TargetToggle>
|
<TargetToggle>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Toggle.Radio />
|
<Toggle.Radio />
|
||||||
<Toggle.LabelText>
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
<Trans>Mute in text & tags</Trans>
|
<Trans>Text & tags</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
</View>
|
</View>
|
||||||
<PageText size="sm" />
|
<PageText size="sm" />
|
||||||
@@ -140,33 +273,63 @@ function MutedWordsInner() {
|
|||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
label={_(msg`Mute this word in tags only`)}
|
label={_(msg`Mute this word in tags only`)}
|
||||||
name="tag"
|
name="tag"
|
||||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
style={[a.flex_1]}>
|
||||||
<TargetToggle>
|
<TargetToggle>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Toggle.Radio />
|
<Toggle.Radio />
|
||||||
<Toggle.LabelText>
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
<Trans>Mute in tags only</Trans>
|
<Trans>Tags only</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
</View>
|
</View>
|
||||||
<Hashtag size="sm" />
|
<Hashtag size="sm" />
|
||||||
</TargetToggle>
|
</TargetToggle>
|
||||||
</Toggle.Item>
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
</Toggle.Group>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Options:</Trans>
|
||||||
|
</Text>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Do not apply this mute word to users you follow`)}
|
||||||
|
name="exclude_following"
|
||||||
|
style={[a.flex_row, a.justify_between]}
|
||||||
|
value={excludeFollowing}
|
||||||
|
onChange={setExcludeFollowing}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Checkbox />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>Exclude users you follow</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.pt_xs]}>
|
||||||
<Button
|
<Button
|
||||||
disabled={isPending || !field}
|
disabled={isPending || !field}
|
||||||
label={_(msg`Add mute word for configured settings`)}
|
label={_(msg`Add mute word for configured settings`)}
|
||||||
size="small"
|
size="medium"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
style={[!gtMobile && [a.w_full, a.flex_0]]}
|
style={[]}
|
||||||
onPress={submit}>
|
onPress={submit}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Add</Trans>
|
<Trans>Add</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
<ButtonIcon icon={isPending ? Loader : Plus} />
|
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</Toggle.Group>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<View
|
<View
|
||||||
@@ -191,20 +354,6 @@ function MutedWordsInner() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.pt_xs,
|
|
||||||
a.text_sm,
|
|
||||||
a.italic,
|
|
||||||
a.leading_snug,
|
|
||||||
t.atoms.text_contrast_medium,
|
|
||||||
]}>
|
|
||||||
<Trans>
|
|
||||||
We recommend avoiding common words that appear in many posts,
|
|
||||||
since it can result in no posts being shown.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -268,6 +417,9 @@ function MutedWordRow({
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
||||||
const control = Prompt.usePromptControl()
|
const control = Prompt.usePromptControl()
|
||||||
|
const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
|
||||||
|
const isExpired = expiryDate && expiryDate < new Date()
|
||||||
|
const formatDistance = useFormatDistance()
|
||||||
|
|
||||||
const remove = React.useCallback(async () => {
|
const remove = React.useCallback(async () => {
|
||||||
control.close()
|
control.close()
|
||||||
@@ -280,7 +432,7 @@ function MutedWordRow({
|
|||||||
control={control}
|
control={control}
|
||||||
title={_(msg`Are you sure?`)}
|
title={_(msg`Are you sure?`)}
|
||||||
description={_(
|
description={_(
|
||||||
msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
|
msg`This will delete "${word.value}" from your muted words. You can always add it back later.`,
|
||||||
)}
|
)}
|
||||||
onConfirm={remove}
|
onConfirm={remove}
|
||||||
confirmButtonCta={_(msg`Remove`)}
|
confirmButtonCta={_(msg`Remove`)}
|
||||||
@@ -289,54 +441,95 @@ function MutedWordRow({
|
|||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_between,
|
||||||
a.py_md,
|
a.py_md,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_between,
|
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.gap_md,
|
a.gap_md,
|
||||||
style,
|
style,
|
||||||
]}>
|
]}>
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.leading_snug,
|
a.leading_snug,
|
||||||
a.w_full,
|
|
||||||
a.font_bold,
|
a.font_bold,
|
||||||
t.atoms.text_contrast_high,
|
|
||||||
web({
|
web({
|
||||||
overflowWrap: 'break-word',
|
overflowWrap: 'break-word',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}),
|
}),
|
||||||
]}>
|
]}>
|
||||||
{word.value}
|
{word.targets.find(t => t === 'content') ? (
|
||||||
|
<Trans comment="Pattern: {wordValue} in text, tags">
|
||||||
|
{word.value}{' '}
|
||||||
|
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||||
|
in{' '}
|
||||||
|
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
text & tags
|
||||||
</Text>
|
</Text>
|
||||||
|
</Text>
|
||||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
|
</Trans>
|
||||||
{word.targets.map(target => (
|
) : (
|
||||||
<View
|
<Trans comment="Pattern: {wordValue} in tags">
|
||||||
key={target}
|
{word.value}{' '}
|
||||||
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
|
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||||
<Text
|
in{' '}
|
||||||
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
|
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
|
tags
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
|
||||||
|
{(expiryDate || word.actorTarget === 'exclude-following') && (
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{expiryDate && (
|
||||||
|
<>
|
||||||
|
{isExpired ? (
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Expires{' '}
|
||||||
|
{formatDistance(expiryDate, new Date(), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{word.actorTarget === 'exclude-following' && (
|
||||||
|
<>
|
||||||
|
{' • '}
|
||||||
|
<Trans>Excludes users you follow</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Remove mute word from your list`)}
|
label={_(msg`Remove mute word from your list`)}
|
||||||
size="tiny"
|
size="tiny"
|
||||||
shape="round"
|
shape="round"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onPress={() => control.open()}
|
onPress={() => control.open()}
|
||||||
style={[a.ml_sm]}>
|
style={[a.ml_sm]}>
|
||||||
<ButtonIcon icon={isPending ? Loader : X} />
|
<ButtonIcon icon={isPending ? Loader : X} />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export function Group({children, multiple, ...props}: GroupProps) {
|
|||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.border,
|
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
|
{borderWidth: 1},
|
||||||
]}>
|
]}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Hooks for date-fns localized formatters.
|
||||||
|
*
|
||||||
|
* Our app supports some languages that are not included in date-fns by
|
||||||
|
* default, in which case it will fall back to English.
|
||||||
|
*
|
||||||
|
* {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import {formatDistance, Locale} from 'date-fns'
|
||||||
|
import {
|
||||||
|
ca,
|
||||||
|
de,
|
||||||
|
es,
|
||||||
|
fi,
|
||||||
|
fr,
|
||||||
|
hi,
|
||||||
|
id,
|
||||||
|
it,
|
||||||
|
ja,
|
||||||
|
ko,
|
||||||
|
ptBR,
|
||||||
|
tr,
|
||||||
|
uk,
|
||||||
|
zhCN,
|
||||||
|
zhTW,
|
||||||
|
} from 'date-fns/locale'
|
||||||
|
|
||||||
|
import {AppLanguage} from '#/locale/languages'
|
||||||
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AppLanguage}
|
||||||
|
*/
|
||||||
|
const locales: Record<AppLanguage, Locale | undefined> = {
|
||||||
|
en: undefined,
|
||||||
|
ca,
|
||||||
|
de,
|
||||||
|
es,
|
||||||
|
fi,
|
||||||
|
fr,
|
||||||
|
ga: undefined,
|
||||||
|
hi,
|
||||||
|
id,
|
||||||
|
it,
|
||||||
|
ja,
|
||||||
|
ko,
|
||||||
|
['pt-BR']: ptBR,
|
||||||
|
tr,
|
||||||
|
uk,
|
||||||
|
['zh-CN']: zhCN,
|
||||||
|
['zh-TW']: zhTW,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a localized `formatDistance` function.
|
||||||
|
* {@link formatDistance}
|
||||||
|
*/
|
||||||
|
export function useFormatDistance() {
|
||||||
|
const {appLanguage} = useLanguagePrefs()
|
||||||
|
return React.useCallback<typeof formatDistance>(
|
||||||
|
(date, baseDate, options) => {
|
||||||
|
const locale = locales[appLanguage as AppLanguage]
|
||||||
|
return formatDistance(date, baseDate, {...options, locale: locale})
|
||||||
|
},
|
||||||
|
[appLanguage],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CC_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z',
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Moon_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z',
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Pause_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Pause_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Pause_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Pause_Filled_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z',
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Phone_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z',
|
||||||
|
})
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import {createSinglePathSVG} from './TEMPLATE'
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Play_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Play_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z',
|
||||||
|
})
|
||||||
|
|
||||||
export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z',
|
path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
|
|||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {CenteredView} from '#/view/com/util/Views'
|
import {CenteredView} from '#/view/com/util/Views'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import {
|
import {
|
||||||
ModerationDetailsDialog,
|
ModerationDetailsDialog,
|
||||||
@@ -105,6 +105,7 @@ export function ScreenHider({
|
|||||||
a.mb_md,
|
a.mb_md,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.text_center,
|
a.text_center,
|
||||||
|
a.leading_snug,
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
]}>
|
]}>
|
||||||
{isNoPwi ? (
|
{isNoPwi ? (
|
||||||
@@ -113,8 +114,15 @@ export function ScreenHider({
|
|||||||
</Trans>
|
</Trans>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Trans>This {screenDescription} has been flagged:</Trans>
|
<Trans>This {screenDescription} has been flagged:</Trans>{' '}
|
||||||
<Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_lg,
|
||||||
|
a.font_semibold,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text,
|
||||||
|
a.ml_xs,
|
||||||
|
]}>
|
||||||
{desc.name}.{' '}
|
{desc.name}.{' '}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableWithoutFeedback
|
<TouchableWithoutFeedback
|
||||||
@@ -127,16 +135,17 @@ export function ScreenHider({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.text_lg,
|
a.text_lg,
|
||||||
|
a.leading_snug,
|
||||||
{
|
{
|
||||||
color: t.palette.primary_500,
|
color: t.palette.primary_500,
|
||||||
// @ts-ignore web only -prf
|
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
},
|
||||||
|
web({
|
||||||
|
cursor: 'pointer',
|
||||||
|
}),
|
||||||
]}>
|
]}>
|
||||||
<Trans>Learn More</Trans>
|
<Trans>Learn More</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
|
|
||||||
<ModerationDetailsDialog control={control} modcause={blur} />
|
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||||
</>
|
</>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
@@ -6,50 +7,125 @@ import {
|
|||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {isPostInLanguage} from '../../locale/helpers'
|
import {isPostInLanguage} from '../../locale/helpers'
|
||||||
|
import {FALLBACK_MARKER_POST} from './feed/home'
|
||||||
import {ReasonFeedSource} from './feed/types'
|
import {ReasonFeedSource} from './feed/types'
|
||||||
|
|
||||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||||
|
|
||||||
export type FeedTunerFn = (
|
export type FeedTunerFn = (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
dryRun: boolean,
|
||||||
) => FeedViewPostsSlice[]
|
) => FeedViewPostsSlice[]
|
||||||
|
|
||||||
type FeedSliceItem = {
|
type FeedSliceItem = {
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
reply?: AppBskyFeedDefs.ReplyRef
|
record: AppBskyFeedPost.Record
|
||||||
|
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
|
isParentBlocked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem {
|
type AuthorContext = {
|
||||||
return {
|
author: AppBskyActorDefs.ProfileViewBasic
|
||||||
post: feedViewPost.post,
|
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
reply: feedViewPost.reply,
|
grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
}
|
rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FeedViewPostsSlice {
|
export class FeedViewPostsSlice {
|
||||||
_reactKey: string
|
_reactKey: string
|
||||||
_feedPost: FeedViewPost
|
_feedPost: FeedViewPost
|
||||||
items: FeedSliceItem[]
|
items: FeedSliceItem[]
|
||||||
|
isIncompleteThread: boolean
|
||||||
|
isFallbackMarker: boolean
|
||||||
|
isOrphan: boolean
|
||||||
|
rootUri: string
|
||||||
|
|
||||||
constructor(feedPost: FeedViewPost) {
|
constructor(feedPost: FeedViewPost) {
|
||||||
|
const {post, reply, reason} = feedPost
|
||||||
|
this.items = []
|
||||||
|
this.isIncompleteThread = false
|
||||||
|
this.isFallbackMarker = false
|
||||||
|
this.isOrphan = false
|
||||||
|
if (AppBskyFeedDefs.isPostView(reply?.root)) {
|
||||||
|
this.rootUri = reply.root.uri
|
||||||
|
} else {
|
||||||
|
this.rootUri = post.uri
|
||||||
|
}
|
||||||
this._feedPost = feedPost
|
this._feedPost = feedPost
|
||||||
this._reactKey = `slice-${feedPost.post.uri}-${
|
this._reactKey = `slice-${post.uri}-${
|
||||||
feedPost.reason?.indexedAt || feedPost.post.indexedAt
|
feedPost.reason?.indexedAt || post.indexedAt
|
||||||
}`
|
}`
|
||||||
this.items = [toSliceItem(feedPost)]
|
if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) {
|
||||||
|
this.isFallbackMarker = true
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
get uri() {
|
!AppBskyFeedPost.isRecord(post.record) ||
|
||||||
return this._feedPost.post.uri
|
!AppBskyFeedPost.validateRecord(post.record).success
|
||||||
|
) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
const parent = reply?.parent
|
||||||
get isThread() {
|
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
|
||||||
return (
|
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
this.items.length > 1 &&
|
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||||
this.items.every(
|
parentAuthor = parent.author
|
||||||
item => item.post.author.did === this.items[0].post.author.did,
|
}
|
||||||
)
|
this.items.push({
|
||||||
|
post,
|
||||||
|
record: post.record,
|
||||||
|
parentAuthor,
|
||||||
|
isParentBlocked,
|
||||||
|
})
|
||||||
|
if (!reply || reason) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!AppBskyFeedDefs.isPostView(parent) ||
|
||||||
|
!AppBskyFeedPost.isRecord(parent.record) ||
|
||||||
|
!AppBskyFeedPost.validateRecord(parent.record).success
|
||||||
|
) {
|
||||||
|
this.isOrphan = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const grandparentAuthor = reply.grandparentAuthor
|
||||||
|
const isGrandparentBlocked = Boolean(
|
||||||
|
grandparentAuthor?.viewer?.blockedBy ||
|
||||||
|
grandparentAuthor?.viewer?.blocking ||
|
||||||
|
grandparentAuthor?.viewer?.blockingByList,
|
||||||
)
|
)
|
||||||
|
this.items.unshift({
|
||||||
|
post: parent,
|
||||||
|
record: parent.record,
|
||||||
|
parentAuthor: grandparentAuthor,
|
||||||
|
isParentBlocked: isGrandparentBlocked,
|
||||||
|
})
|
||||||
|
if (isGrandparentBlocked) {
|
||||||
|
this.isOrphan = true
|
||||||
|
// Keep going, it might still have a root.
|
||||||
|
}
|
||||||
|
const root = reply.root
|
||||||
|
if (
|
||||||
|
!AppBskyFeedDefs.isPostView(root) ||
|
||||||
|
!AppBskyFeedPost.isRecord(root.record) ||
|
||||||
|
!AppBskyFeedPost.validateRecord(root.record).success
|
||||||
|
) {
|
||||||
|
this.isOrphan = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (root.uri === parent.uri) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.items.unshift({
|
||||||
|
post: root,
|
||||||
|
record: root.record,
|
||||||
|
isParentBlocked: false,
|
||||||
|
parentAuthor: undefined,
|
||||||
|
})
|
||||||
|
if (parent.record.reply?.parent.uri !== root.uri) {
|
||||||
|
this.isIncompleteThread = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get isQuotePost() {
|
get isQuotePost() {
|
||||||
@@ -82,10 +158,6 @@ export class FeedViewPostsSlice {
|
|||||||
return AppBskyFeedDefs.isReasonRepost(reason)
|
return AppBskyFeedDefs.isReasonRepost(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
get includesThreadRoot() {
|
|
||||||
return !this.items[0].reply
|
|
||||||
}
|
|
||||||
|
|
||||||
get likeCount() {
|
get likeCount() {
|
||||||
return this._feedPost.post.likeCount ?? 0
|
return this._feedPost.post.likeCount ?? 0
|
||||||
}
|
}
|
||||||
@@ -94,249 +166,192 @@ export class FeedViewPostsSlice {
|
|||||||
return !!this.items.find(item => item.post.uri === uri)
|
return !!this.items.find(item => item.post.uri === uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
isNextInThread(uri: string) {
|
getAuthors(): AuthorContext {
|
||||||
return this.items[this.items.length - 1].post.uri === uri
|
|
||||||
}
|
|
||||||
|
|
||||||
insert(item: FeedViewPost) {
|
|
||||||
const selfReplyUri = getSelfReplyUri(item)
|
|
||||||
const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri)
|
|
||||||
if (i !== -1) {
|
|
||||||
this.items.splice(i + 1, 0, item)
|
|
||||||
} else {
|
|
||||||
this.items.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flattenReplyParent() {
|
|
||||||
if (this.items[0].reply) {
|
|
||||||
const reply = this.items[0].reply
|
|
||||||
if (AppBskyFeedDefs.isPostView(reply.parent)) {
|
|
||||||
this.items.splice(0, 0, {post: reply.parent})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isFollowingAllAuthors(userDid: string) {
|
|
||||||
const feedPost = this._feedPost
|
const feedPost = this._feedPost
|
||||||
if (feedPost.post.author.did === userDid) {
|
let author: AppBskyActorDefs.ProfileViewBasic = feedPost.post.author
|
||||||
return true
|
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
|
let grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
|
let rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
|
if (feedPost.reply) {
|
||||||
|
if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) {
|
||||||
|
parentAuthor = feedPost.reply.parent.author
|
||||||
}
|
}
|
||||||
if (AppBskyFeedDefs.isPostView(feedPost.reply?.parent)) {
|
if (feedPost.reply.grandparentAuthor) {
|
||||||
const parent = feedPost.reply?.parent
|
grandparentAuthor = feedPost.reply.grandparentAuthor
|
||||||
if (parent?.author.did === userDid) {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return (
|
if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {
|
||||||
parent?.author.viewer?.following &&
|
rootAuthor = feedPost.reply.root.author
|
||||||
feedPost.post.author.viewer?.following
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
return {
|
||||||
|
author,
|
||||||
export class NoopFeedTuner {
|
parentAuthor,
|
||||||
reset() {}
|
grandparentAuthor,
|
||||||
tune(
|
rootAuthor,
|
||||||
feed: FeedViewPost[],
|
}
|
||||||
_opts?: {dryRun: boolean; maintainOrder: boolean},
|
|
||||||
): FeedViewPostsSlice[] {
|
|
||||||
return feed.map(item => new FeedViewPostsSlice(item))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FeedTuner {
|
export class FeedTuner {
|
||||||
seenKeys: Set<string> = new Set()
|
seenKeys: Set<string> = new Set()
|
||||||
seenUris: Set<string> = new Set()
|
seenUris: Set<string> = new Set()
|
||||||
|
seenRootUris: Set<string> = new Set()
|
||||||
|
|
||||||
constructor(public tunerFns: FeedTunerFn[]) {}
|
constructor(public tunerFns: FeedTunerFn[]) {}
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.seenKeys.clear()
|
|
||||||
this.seenUris.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
tune(
|
tune(
|
||||||
feed: FeedViewPost[],
|
feed: FeedViewPost[],
|
||||||
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
|
{dryRun}: {dryRun: boolean} = {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
maintainOrder: false,
|
|
||||||
},
|
},
|
||||||
): FeedViewPostsSlice[] {
|
): FeedViewPostsSlice[] {
|
||||||
let slices: FeedViewPostsSlice[] = []
|
let slices: FeedViewPostsSlice[] = feed
|
||||||
|
.map(item => new FeedViewPostsSlice(item))
|
||||||
// remove posts that are replies, but which don't have the parent
|
.filter(s => s.items.length > 0 || s.isFallbackMarker)
|
||||||
// hydrated. this means the parent was either deleted or blocked
|
|
||||||
feed = feed.filter(item => {
|
|
||||||
if (
|
|
||||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
|
||||||
item.post.record.reply &&
|
|
||||||
!item.reply
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (maintainOrder) {
|
|
||||||
slices = feed.map(item => new FeedViewPostsSlice(item))
|
|
||||||
} else {
|
|
||||||
// arrange the posts into thread slices
|
|
||||||
for (let i = feed.length - 1; i >= 0; i--) {
|
|
||||||
const item = feed[i]
|
|
||||||
|
|
||||||
const selfReplyUri = getSelfReplyUri(item)
|
|
||||||
if (selfReplyUri) {
|
|
||||||
const index = slices.findIndex(slice =>
|
|
||||||
slice.isNextInThread(selfReplyUri),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (index !== -1) {
|
|
||||||
const parent = slices[index]
|
|
||||||
|
|
||||||
parent.insert(item)
|
|
||||||
|
|
||||||
// If our slice isn't currently on the top, reinsert it to the top.
|
|
||||||
if (index !== 0) {
|
|
||||||
slices.splice(index, 1)
|
|
||||||
slices.unshift(parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.unshift(new FeedViewPostsSlice(item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// run the custom tuners
|
// run the custom tuners
|
||||||
for (const tunerFn of this.tunerFns) {
|
for (const tunerFn of this.tunerFns) {
|
||||||
slices = tunerFn(this, slices.slice())
|
slices = tunerFn(this, slices.slice(), dryRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove any items already "seen"
|
|
||||||
const soonToBeSeenUris: Set<string> = new Set()
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (!slices[i].isThread && this.seenUris.has(slices[i].uri)) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
} else {
|
|
||||||
for (const item of slices[i].items) {
|
|
||||||
soonToBeSeenUris.add(item.post.uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// turn non-threads with reply parents into threads
|
|
||||||
for (const slice of slices) {
|
|
||||||
if (!slice.isThread && !slice.reason && slice.items[0].reply) {
|
|
||||||
const reply = slice.items[0].reply
|
|
||||||
if (
|
|
||||||
AppBskyFeedDefs.isPostView(reply.parent) &&
|
|
||||||
!this.seenUris.has(reply.parent.uri) &&
|
|
||||||
!soonToBeSeenUris.has(reply.parent.uri)
|
|
||||||
) {
|
|
||||||
const uri = reply.parent.uri
|
|
||||||
slice.flattenReplyParent()
|
|
||||||
soonToBeSeenUris.add(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dryRun) {
|
|
||||||
slices = slices.filter(slice => {
|
slices = slices.filter(slice => {
|
||||||
if (this.seenKeys.has(slice._reactKey)) {
|
if (this.seenKeys.has(slice._reactKey)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for (const item of slice.items) {
|
// Some feeds, like Following, dedupe by thread, so you only see the most recent reply.
|
||||||
|
// However, we don't want per-thread dedupe for author feeds (where we need to show every post)
|
||||||
|
// or for feedgens (where we want to let the feed serve multiple replies if it chooses to).
|
||||||
|
// To avoid showing the same context (root and/or parent) more than once, we do last resort
|
||||||
|
// per-post deduplication. It hides already seen posts as long as this doesn't break the thread.
|
||||||
|
for (let i = 0; i < slice.items.length; i++) {
|
||||||
|
const item = slice.items[i]
|
||||||
|
if (this.seenUris.has(item.post.uri)) {
|
||||||
|
if (i === 0) {
|
||||||
|
// Omit contiguous seen leading items.
|
||||||
|
// For example, [A -> B -> C], [A -> D -> E], [A -> D -> F]
|
||||||
|
// would turn into [A -> B -> C], [D -> E], [F].
|
||||||
|
slice.items.splice(0, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
if (i === slice.items.length - 1) {
|
||||||
|
// If the last item in the slice was already seen, omit the whole slice.
|
||||||
|
// This means we'd miss its parents, but the user can "show more" to see them.
|
||||||
|
// For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C]
|
||||||
|
// would get collapsed into [A ... E -> F], with B/C/D considered seen.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!dryRun) {
|
||||||
this.seenUris.add(item.post.uri)
|
this.seenUris.add(item.post.uri)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dryRun) {
|
||||||
this.seenKeys.add(slice._reactKey)
|
this.seenKeys.add(slice._reactKey)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
static removeReplies(
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (slices[i].isReply) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slices
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (slices[i].isRepost) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slices
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (slices[i].isQuotePost) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slices
|
|
||||||
}
|
|
||||||
|
|
||||||
static dedupReposts(
|
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
): FeedViewPostsSlice[] {
|
_dryRun: boolean,
|
||||||
// remove duplicates caused by reposts
|
) {
|
||||||
for (let i = 0; i < slices.length; i++) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
const item1 = slices[i]
|
const slice = slices[i]
|
||||||
for (let j = i + 1; j < slices.length; j++) {
|
if (
|
||||||
const item2 = slices[j]
|
slice.isReply &&
|
||||||
if (item2.isThread) {
|
!slice.isRepost &&
|
||||||
// dont dedup items that are rendering in a thread as this can cause rendering errors
|
// This is not perfect but it's close as we can get to
|
||||||
continue
|
// detecting threads without having to peek ahead.
|
||||||
|
!areSameAuthor(slice.getAuthors())
|
||||||
|
) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
}
|
}
|
||||||
if (item1.containsUri(item2.items[0].post.uri)) {
|
}
|
||||||
slices.splice(j, 1)
|
return slices
|
||||||
j--
|
}
|
||||||
|
|
||||||
|
static removeReposts(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
if (slices[i].isRepost) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeQuotePosts(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
if (slices[i].isQuotePost) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeOrphans(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
if (slices[i].isOrphan) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices
|
||||||
|
}
|
||||||
|
|
||||||
|
static dedupThreads(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
dryRun: boolean,
|
||||||
|
): FeedViewPostsSlice[] {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
const rootUri = slices[i].rootUri
|
||||||
|
if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
} else {
|
||||||
|
if (!dryRun) {
|
||||||
|
tuner.seenRootUris.add(rootUri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
static thresholdRepliesOnly({
|
static followedRepliesOnly({userDid}: {userDid: string}) {
|
||||||
userDid,
|
|
||||||
minLikes,
|
|
||||||
followedOnly,
|
|
||||||
}: {
|
|
||||||
userDid: string
|
|
||||||
minLikes: number
|
|
||||||
followedOnly: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
// remove any replies without at least minLikes likes
|
for (let i = 0; i < slices.length; i++) {
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
const slice = slices[i]
|
const slice = slices[i]
|
||||||
if (slice.isReply) {
|
if (
|
||||||
if (slice.isThread && slice.includesThreadRoot) {
|
slice.isReply &&
|
||||||
continue
|
!slice.isRepost &&
|
||||||
}
|
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
|
||||||
if (slice.isRepost) {
|
) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (slice.likeCount < minLikes) {
|
|
||||||
slices.splice(i, 1)
|
slices.splice(i, 1)
|
||||||
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
|
i--
|
||||||
slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return slices
|
return slices
|
||||||
@@ -354,6 +369,7 @@ export class FeedTuner {
|
|||||||
return (
|
return (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
const candidateSlices = slices.slice()
|
const candidateSlices = slices.slice()
|
||||||
|
|
||||||
@@ -362,7 +378,7 @@ export class FeedTuner {
|
|||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
let hasPreferredLang = false
|
let hasPreferredLang = false
|
||||||
for (const item of slices[i].items) {
|
for (const item of slices[i].items) {
|
||||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||||
@@ -388,16 +404,66 @@ export class FeedTuner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelfReplyUri(item: FeedViewPost): string | undefined {
|
function areSameAuthor(authors: AuthorContext): boolean {
|
||||||
if (item.reply) {
|
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||||
if (
|
const authorDid = author.did
|
||||||
AppBskyFeedDefs.isPostView(item.reply.parent) &&
|
if (parentAuthor && parentAuthor.did !== authorDid) {
|
||||||
!AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies
|
return false
|
||||||
) {
|
|
||||||
return item.reply.parent.author.did === item.post.author.did
|
|
||||||
? item.reply.parent.uri
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
|
if (grandparentAuthor && grandparentAuthor.did !== authorDid) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return undefined
|
if (rootAuthor && rootAuthor.did !== authorDid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldDisplayReplyInFollowing(
|
||||||
|
authors: AuthorContext,
|
||||||
|
userDid: string,
|
||||||
|
): boolean {
|
||||||
|
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||||
|
if (!isSelfOrFollowing(author, userDid)) {
|
||||||
|
// Only show replies from self or people you follow.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(!parentAuthor || parentAuthor.did === author.did) &&
|
||||||
|
(!rootAuthor || rootAuthor.did === author.did) &&
|
||||||
|
(!grandparentAuthor || grandparentAuthor.did === author.did)
|
||||||
|
) {
|
||||||
|
// Always show self-threads.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// From this point on we need at least one more reason to show it.
|
||||||
|
if (
|
||||||
|
parentAuthor &&
|
||||||
|
parentAuthor.did !== author.did &&
|
||||||
|
isSelfOrFollowing(parentAuthor, userDid)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
grandparentAuthor &&
|
||||||
|
grandparentAuthor.did !== author.did &&
|
||||||
|
isSelfOrFollowing(grandparentAuthor, userDid)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rootAuthor &&
|
||||||
|
rootAuthor.did !== author.did &&
|
||||||
|
isSelfOrFollowing(rootAuthor, userDid)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelfOrFollowing(
|
||||||
|
profile: AppBskyActorDefs.ProfileViewBasic,
|
||||||
|
userDid: string,
|
||||||
|
) {
|
||||||
|
return Boolean(profile.did === userDid || profile.viewer?.following)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,12 +193,6 @@ class MergeFeedSource {
|
|||||||
return this.hasMore && this.queue.length === 0
|
return this.hasMore && this.queue.length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.cursor = undefined
|
|
||||||
this.queue = []
|
|
||||||
this.hasMore = true
|
|
||||||
}
|
|
||||||
|
|
||||||
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
|
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
|
||||||
return this.queue.splice(0, n)
|
return this.queue.splice(0, n)
|
||||||
}
|
}
|
||||||
@@ -232,11 +226,6 @@ class MergeFeedSource {
|
|||||||
class MergeFeedSource_Following extends MergeFeedSource {
|
class MergeFeedSource_Following extends MergeFeedSource {
|
||||||
tuner = new FeedTuner(this.feedTuners)
|
tuner = new FeedTuner(this.feedTuners)
|
||||||
|
|
||||||
reset() {
|
|
||||||
super.reset()
|
|
||||||
this.tuner.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchNext(n: number) {
|
async fetchNext(n: number) {
|
||||||
return this._fetchNextInner(n)
|
return this._fetchNextInner(n)
|
||||||
}
|
}
|
||||||
@@ -249,7 +238,6 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
|||||||
// run the tuner pre-emptively to ensure better mixing
|
// run the tuner pre-emptively to ensure better mixing
|
||||||
const slices = this.tuner.tune(res.data.feed, {
|
const slices = this.tuner.tune(res.data.feed, {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
maintainOrder: true,
|
|
||||||
})
|
})
|
||||||
res.data.feed = slices.map(slice => slice._feedPost)
|
res.data.feed = slices.map(slice => slice._feedPost)
|
||||||
return res
|
return res
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ interface PostOpts {
|
|||||||
uri: string
|
uri: string
|
||||||
cid: string
|
cid: string
|
||||||
}
|
}
|
||||||
|
video?: {
|
||||||
|
uri: string
|
||||||
|
cid: string
|
||||||
|
}
|
||||||
extLink?: ExternalEmbedDraft
|
extLink?: ExternalEmbedDraft
|
||||||
images?: ImageModel[]
|
images?: ImageModel[]
|
||||||
labels?: string[]
|
labels?: string[]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from 'react'
|
|||||||
export const useDedupe = () => {
|
export const useDedupe = () => {
|
||||||
const canDo = React.useRef(true)
|
const canDo = React.useRef(true)
|
||||||
|
|
||||||
return React.useRef((cb: () => unknown) => {
|
return React.useCallback((cb: () => unknown) => {
|
||||||
if (canDo.current) {
|
if (canDo.current) {
|
||||||
canDo.current = false
|
canDo.current = false
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -13,5 +13,5 @@ export const useDedupe = () => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}).current
|
}, [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@ export function useModerationCauseDescription(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
||||||
strings.name = 'Adult Content'
|
strings.name = _(msg`Adult Content`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type CommonNavigatorParams = {
|
|||||||
PreferencesThreads: undefined
|
PreferencesThreads: undefined
|
||||||
PreferencesExternalEmbeds: undefined
|
PreferencesExternalEmbeds: undefined
|
||||||
AccessibilitySettings: undefined
|
AccessibilitySettings: undefined
|
||||||
|
AppearanceSettings: undefined
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
MessagesConversation: {conversation: string; embed?: string}
|
MessagesConversation: {conversation: string; embed?: string}
|
||||||
|
|||||||
@@ -211,6 +211,12 @@ export type LogEvents = {
|
|||||||
'feed:interstitial:profileCard:press': {}
|
'feed:interstitial:profileCard:press': {}
|
||||||
'feed:interstitial:feedCard:press': {}
|
'feed:interstitial:feedCard:press': {}
|
||||||
|
|
||||||
|
'debug:followingPrefs': {
|
||||||
|
followingShowRepliesFromPref: 'all' | 'following' | 'off'
|
||||||
|
followingRepliesMinLikePref: number
|
||||||
|
}
|
||||||
|
'debug:followingDisplayed': {}
|
||||||
|
|
||||||
'test:all:always': {}
|
'test:all:always': {}
|
||||||
'test:all:sometimes': {}
|
'test:all:sometimes': {}
|
||||||
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ export type Gate =
|
|||||||
| 'suggested_feeds_interstitial'
|
| 'suggested_feeds_interstitial'
|
||||||
| 'suggested_follows_interstitial'
|
| 'suggested_follows_interstitial'
|
||||||
| 'ungroup_follow_backs'
|
| 'ungroup_follow_backs'
|
||||||
|
| 'video_debug'
|
||||||
| 'videos'
|
| 'videos'
|
||||||
| 'small_avi_thumb'
|
| 'small_avi_thumb'
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {Platform} from 'react-native'
|
import {Platform} from 'react-native'
|
||||||
import {isReducedMotion} from 'react-native-reanimated'
|
|
||||||
import {getLocales} from 'expo-localization'
|
import {getLocales} from 'expo-localization'
|
||||||
|
|
||||||
import {fixLegacyLanguageCode} from '#/locale/helpers'
|
import {fixLegacyLanguageCode} from '#/locale/helpers'
|
||||||
@@ -15,11 +14,10 @@ export const isMobileWeb =
|
|||||||
isWeb &&
|
isWeb &&
|
||||||
// @ts-ignore we know window exists -prf
|
// @ts-ignore we know window exists -prf
|
||||||
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
||||||
|
export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent)
|
||||||
|
|
||||||
export const deviceLocales = dedupArray(
|
export const deviceLocales = dedupArray(
|
||||||
getLocales?.()
|
getLocales?.()
|
||||||
.map?.(locale => fixLegacyLanguageCode(locale.languageCode))
|
.map?.(locale => fixLegacyLanguageCode(locale.languageCode))
|
||||||
.filter(code => typeof code === 'string'),
|
.filter(code => typeof code === 'string'),
|
||||||
) as string[]
|
) as string[]
|
||||||
|
|
||||||
export const prefersReducedMotion = isReducedMotion()
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const router = new Router({
|
|||||||
PreferencesThreads: '/settings/threads',
|
PreferencesThreads: '/settings/threads',
|
||||||
PreferencesExternalEmbeds: '/settings/external-embeds',
|
PreferencesExternalEmbeds: '/settings/external-embeds',
|
||||||
AccessibilitySettings: '/settings/accessibility',
|
AccessibilitySettings: '/settings/accessibility',
|
||||||
|
AppearanceSettings: '/settings/appearance',
|
||||||
SavedFeeds: '/settings/saved-feeds',
|
SavedFeeds: '/settings/saved-feeds',
|
||||||
Support: '/support',
|
Support: '/support',
|
||||||
PrivacyPolicy: '/support/privacy',
|
PrivacyPolicy: '/support/privacy',
|
||||||
|
|||||||
@@ -387,9 +387,6 @@ export function MessagesList({
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
disableFullWindowScroll={true}
|
disableFullWindowScroll={true}
|
||||||
// Prevents wrong position in Firefox when sending a message
|
|
||||||
// as well as scroll getting stuck on Chome when scrolling upwards.
|
|
||||||
disableContainStyle={true}
|
|
||||||
disableVirtualization={true}
|
disableVirtualization={true}
|
||||||
style={animatedListStyle}
|
style={animatedListStyle}
|
||||||
// The extra two items account for the header and the footer components
|
// The extra two items account for the header and the footer components
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const ProfileFeedSection = React.forwardRef<
|
|||||||
headerOffset={headerHeight}
|
headerOffset={headerHeight}
|
||||||
renderEndOfFeed={ProfileEndOfFeed}
|
renderEndOfFeed={ProfileEndOfFeed}
|
||||||
ignoreFilterFor={ignoreFilterFor}
|
ignoreFilterFor={ignoreFilterFor}
|
||||||
|
outsideHeaderOffset={headerHeight}
|
||||||
/>
|
/>
|
||||||
{(isScrolledDown || hasNew) && (
|
{(isScrolledDown || hasNew) && (
|
||||||
<LoadLatestBtn
|
<LoadLatestBtn
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import React, {useCallback} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
FadeInDown,
|
||||||
|
FadeOutDown,
|
||||||
|
LayoutAnimationConfig,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
|
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||||
|
import {s} from '#/lib/styles'
|
||||||
|
import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
|
||||||
|
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
||||||
|
import {ScrollView} from '#/view/com/util/Views'
|
||||||
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||||
|
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
|
||||||
|
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'>
|
||||||
|
export function AppearanceSettingsScreen({}: Props) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {isTabletOrMobile} = useWebMediaQueries()
|
||||||
|
|
||||||
|
const {colorMode, darkTheme} = useThemePrefs()
|
||||||
|
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
||||||
|
|
||||||
|
const onChangeAppearance = useCallback(
|
||||||
|
(keys: string[]) => {
|
||||||
|
const appearance = keys.find(key => key !== colorMode) as
|
||||||
|
| 'system'
|
||||||
|
| 'light'
|
||||||
|
| 'dark'
|
||||||
|
| undefined
|
||||||
|
if (!appearance) return
|
||||||
|
setColorMode(appearance)
|
||||||
|
},
|
||||||
|
[setColorMode, colorMode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onChangeDarkTheme = useCallback(
|
||||||
|
(keys: string[]) => {
|
||||||
|
const theme = keys.find(key => key !== darkTheme) as
|
||||||
|
| 'dim'
|
||||||
|
| 'dark'
|
||||||
|
| undefined
|
||||||
|
if (!theme) return
|
||||||
|
setDarkTheme(theme)
|
||||||
|
},
|
||||||
|
[setDarkTheme, darkTheme],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutAnimationConfig skipExiting skipEntering>
|
||||||
|
<View testID="preferencesThreadsScreen" style={s.hContentRegion}>
|
||||||
|
<ScrollView
|
||||||
|
// @ts-ignore web only -prf
|
||||||
|
dataSet={{'stable-gutters': 1}}
|
||||||
|
contentContainerStyle={{paddingBottom: 75}}>
|
||||||
|
<SimpleViewHeader
|
||||||
|
showBackButton={isTabletOrMobile}
|
||||||
|
style={[t.atoms.border_contrast_medium, a.border_b]}>
|
||||||
|
<View style={a.flex_1}>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold]}>
|
||||||
|
<Trans>Appearance</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SimpleViewHeader>
|
||||||
|
|
||||||
|
<View style={[a.p_xl, a.gap_lg]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||||
|
<PhoneIcon style={t.atoms.text} />
|
||||||
|
<Text style={a.text_md}>
|
||||||
|
<Trans>Mode</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ToggleButton.Group
|
||||||
|
label={_(msg`Dark mode`)}
|
||||||
|
values={[colorMode]}
|
||||||
|
onChange={onChangeAppearance}>
|
||||||
|
<ToggleButton.Button label={_(msg`System`)} name="system">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
<ToggleButton.Button label={_(msg`Light`)} name="light">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Light</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
<ToggleButton.Button label={_(msg`Dark`)} name="dark">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Dark</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
</ToggleButton.Group>
|
||||||
|
{colorMode !== 'light' && (
|
||||||
|
<Animated.View
|
||||||
|
entering={native(FadeInDown)}
|
||||||
|
exiting={native(FadeOutDown)}
|
||||||
|
style={[a.mt_md, a.gap_lg]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||||
|
<MoonIcon style={t.atoms.text} />
|
||||||
|
<Text style={a.text_md}>
|
||||||
|
<Trans>Dark theme</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ToggleButton.Group
|
||||||
|
label={_(msg`Dark theme`)}
|
||||||
|
values={[darkTheme ?? 'dim']}
|
||||||
|
onChange={onChangeDarkTheme}>
|
||||||
|
<ToggleButton.Button label={_(msg`Dim`)} name="dim">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Dim</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
<ToggleButton.Button label={_(msg`Dark`)} name="dark">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Dark</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
</ToggleButton.Group>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</LayoutAnimationConfig>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import {useA11y} from '#/state/a11y'
|
|||||||
import {DISCOVER_FEED_URI} from 'lib/constants'
|
import {DISCOVER_FEED_URI} from 'lib/constants'
|
||||||
import {
|
import {
|
||||||
useGetPopularFeedsQuery,
|
useGetPopularFeedsQuery,
|
||||||
|
usePopularFeedsSearch,
|
||||||
useSavedFeeds,
|
useSavedFeeds,
|
||||||
useSearchPopularFeedsQuery,
|
|
||||||
} from 'state/queries/feed'
|
} from 'state/queries/feed'
|
||||||
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
||||||
import {List} from 'view/com/util/List'
|
import {List} from 'view/com/util/List'
|
||||||
@@ -59,7 +59,7 @@ export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const {data: searchedFeeds, isFetching: isFetchingSearchedFeeds} =
|
const {data: searchedFeeds, isFetching: isFetchingSearchedFeeds} =
|
||||||
useSearchPopularFeedsQuery({q: throttledQuery})
|
usePopularFeedsSearch({query: throttledQuery})
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
!isFetchedSavedFeeds || isLoadingPopularFeeds || isFetchingSearchedFeeds
|
!isFetchedSavedFeeds || isLoadingPopularFeeds || isFetchingSearchedFeeds
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {AccessibilityInfo} from 'react-native'
|
import {AccessibilityInfo} from 'react-native'
|
||||||
import {isReducedMotion} from 'react-native-reanimated'
|
|
||||||
|
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {PlatformInfo} from '../../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
const Context = React.createContext({
|
const Context = React.createContext({
|
||||||
reduceMotionEnabled: false,
|
reduceMotionEnabled: false,
|
||||||
@@ -15,7 +15,7 @@ export function useA11y() {
|
|||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>
|
const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>
|
||||||
isReducedMotion(),
|
PlatformInfo.getIsReducedMotionEnabled(),
|
||||||
)
|
)
|
||||||
const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)
|
const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
|||||||
toString({
|
toString({
|
||||||
item: postItem.uri,
|
item: postItem.uri,
|
||||||
event: 'app.bsky.feed.defs#interactionSeen',
|
event: 'app.bsky.feed.defs#interactionSeen',
|
||||||
feedContext: postItem.feedContext,
|
feedContext: slice.feedContext,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
sendToFeed()
|
sendToFeed()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import * as persisted from '#/state/persisted'
|
import * as persisted from '#/state/persisted'
|
||||||
|
|
||||||
type StateContext = persisted.Schema['invites']
|
type StateContext = persisted.Schema['invites']
|
||||||
@@ -35,8 +36,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate('invites', nextInvites => {
|
||||||
setState(persisted.get('invites'))
|
setState(nextInvites)
|
||||||
})
|
})
|
||||||
}, [setState])
|
}, [setState])
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import type {LegacySchema} from '#/state/persisted/legacy'
|
|
||||||
|
|
||||||
export const ALICE_DID = 'did:plc:ALICE_DID'
|
|
||||||
export const BOB_DID = 'did:plc:BOB_DID'
|
|
||||||
|
|
||||||
export const LEGACY_DATA_DUMP: LegacySchema = {
|
|
||||||
session: {
|
|
||||||
data: {
|
|
||||||
service: 'https://bsky.social/',
|
|
||||||
did: ALICE_DID,
|
|
||||||
},
|
|
||||||
accounts: [
|
|
||||||
{
|
|
||||||
service: 'https://bsky.social',
|
|
||||||
did: ALICE_DID,
|
|
||||||
refreshJwt: 'refreshJwt',
|
|
||||||
accessJwt: 'accessJwt',
|
|
||||||
handle: 'alice.test',
|
|
||||||
email: 'alice@bsky.test',
|
|
||||||
displayName: 'Alice',
|
|
||||||
aviUrl: 'avi',
|
|
||||||
emailConfirmed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
service: 'https://bsky.social',
|
|
||||||
did: BOB_DID,
|
|
||||||
refreshJwt: 'refreshJwt',
|
|
||||||
accessJwt: 'accessJwt',
|
|
||||||
handle: 'bob.test',
|
|
||||||
email: 'bob@bsky.test',
|
|
||||||
displayName: 'Bob',
|
|
||||||
aviUrl: 'avi',
|
|
||||||
emailConfirmed: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
me: {
|
|
||||||
did: ALICE_DID,
|
|
||||||
handle: 'alice.test',
|
|
||||||
displayName: 'Alice',
|
|
||||||
description: '',
|
|
||||||
avatar: 'avi',
|
|
||||||
},
|
|
||||||
onboarding: {step: 'Home'},
|
|
||||||
shell: {colorMode: 'system'},
|
|
||||||
preferences: {
|
|
||||||
primaryLanguage: 'en',
|
|
||||||
contentLanguages: ['en'],
|
|
||||||
postLanguage: 'en',
|
|
||||||
postLanguageHistory: ['en', 'en', 'ja', 'pt', 'de', 'en'],
|
|
||||||
contentLabels: {
|
|
||||||
nsfw: 'warn',
|
|
||||||
nudity: 'warn',
|
|
||||||
suggestive: 'warn',
|
|
||||||
gore: 'warn',
|
|
||||||
hate: 'hide',
|
|
||||||
spam: 'hide',
|
|
||||||
impersonation: 'warn',
|
|
||||||
},
|
|
||||||
savedFeeds: ['feed_a', 'feed_b', 'feed_c'],
|
|
||||||
pinnedFeeds: ['feed_a', 'feed_b'],
|
|
||||||
requireAltTextEnabled: false,
|
|
||||||
},
|
|
||||||
invitedUsers: {seenDids: [], copiedInvites: []},
|
|
||||||
mutedThreads: {uris: []},
|
|
||||||
reminders: {},
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import {jest, expect, test, afterEach} from '@jest/globals'
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
|
|
||||||
import {defaults} from '#/state/persisted/schema'
|
|
||||||
import {migrate} from '#/state/persisted/legacy'
|
|
||||||
import * as store from '#/state/persisted/store'
|
|
||||||
import * as persisted from '#/state/persisted'
|
|
||||||
|
|
||||||
const write = jest.mocked(store.write)
|
|
||||||
const read = jest.mocked(store.read)
|
|
||||||
|
|
||||||
jest.mock('#/logger')
|
|
||||||
jest.mock('#/state/persisted/legacy', () => ({
|
|
||||||
migrate: jest.fn(),
|
|
||||||
}))
|
|
||||||
jest.mock('#/state/persisted/store', () => ({
|
|
||||||
write: jest.fn(),
|
|
||||||
read: jest.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useFakeTimers()
|
|
||||||
jest.clearAllMocks()
|
|
||||||
AsyncStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('init: fresh install, no migration', async () => {
|
|
||||||
await persisted.init()
|
|
||||||
|
|
||||||
expect(migrate).toHaveBeenCalledTimes(1)
|
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
|
||||||
expect(write).toHaveBeenCalledWith(defaults)
|
|
||||||
|
|
||||||
// default value
|
|
||||||
expect(persisted.get('colorMode')).toBe('system')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('init: fresh install, migration ran', async () => {
|
|
||||||
read.mockResolvedValueOnce(defaults)
|
|
||||||
|
|
||||||
await persisted.init()
|
|
||||||
|
|
||||||
expect(migrate).toHaveBeenCalledTimes(1)
|
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
|
||||||
expect(write).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
// default value
|
|
||||||
expect(persisted.get('colorMode')).toBe('system')
|
|
||||||
})
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import {jest, expect, test, afterEach} from '@jest/globals'
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
|
|
||||||
import {defaults, schema} from '#/state/persisted/schema'
|
|
||||||
import {transform, migrate} from '#/state/persisted/legacy'
|
|
||||||
import * as store from '#/state/persisted/store'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import * as fixtures from '#/state/persisted/__tests__/fixtures'
|
|
||||||
|
|
||||||
const write = jest.mocked(store.write)
|
|
||||||
const read = jest.mocked(store.read)
|
|
||||||
|
|
||||||
jest.mock('#/logger')
|
|
||||||
jest.mock('#/state/persisted/store', () => ({
|
|
||||||
write: jest.fn(),
|
|
||||||
read: jest.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks()
|
|
||||||
AsyncStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('migrate: fresh install', async () => {
|
|
||||||
await migrate()
|
|
||||||
|
|
||||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
|
||||||
expect(logger.debug).toHaveBeenCalledWith(
|
|
||||||
'persisted state: no migration needed',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('migrate: fresh install, existing new storage', async () => {
|
|
||||||
read.mockResolvedValueOnce(defaults)
|
|
||||||
|
|
||||||
await migrate()
|
|
||||||
|
|
||||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
|
||||||
expect(logger.debug).toHaveBeenCalledWith(
|
|
||||||
'persisted state: no migration needed',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('migrate: fresh install, AsyncStorage error', async () => {
|
|
||||||
const prevGetItem = AsyncStorage.getItem
|
|
||||||
|
|
||||||
const error = new Error('test error')
|
|
||||||
|
|
||||||
AsyncStorage.getItem = jest.fn(() => {
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
|
|
||||||
await migrate()
|
|
||||||
|
|
||||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(error, {
|
|
||||||
message: 'persisted state: error migrating legacy storage',
|
|
||||||
})
|
|
||||||
|
|
||||||
AsyncStorage.getItem = prevGetItem
|
|
||||||
})
|
|
||||||
|
|
||||||
test('migrate: has legacy data', async () => {
|
|
||||||
await AsyncStorage.setItem('root', JSON.stringify(fixtures.LEGACY_DATA_DUMP))
|
|
||||||
|
|
||||||
await migrate()
|
|
||||||
|
|
||||||
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
|
|
||||||
expect(logger.debug).toHaveBeenCalledWith(
|
|
||||||
'persisted state: migrated legacy storage',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('migrate: has legacy data, fails validation', async () => {
|
|
||||||
const legacy = fixtures.LEGACY_DATA_DUMP
|
|
||||||
// @ts-ignore
|
|
||||||
legacy.shell.colorMode = 'invalid'
|
|
||||||
await AsyncStorage.setItem('root', JSON.stringify(legacy))
|
|
||||||
|
|
||||||
await migrate()
|
|
||||||
|
|
||||||
const transformed = transform(legacy)
|
|
||||||
const validate = schema.safeParse(transformed)
|
|
||||||
|
|
||||||
expect(write).not.toHaveBeenCalled()
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
'persisted state: legacy data failed validation',
|
|
||||||
// @ts-ignore
|
|
||||||
{message: validate.error},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import {expect, test} from '@jest/globals'
|
|
||||||
|
|
||||||
import {transform} from '#/state/persisted/legacy'
|
|
||||||
import {defaults, schema} from '#/state/persisted/schema'
|
|
||||||
import * as fixtures from '#/state/persisted/__tests__/fixtures'
|
|
||||||
|
|
||||||
test('defaults', () => {
|
|
||||||
expect(() => schema.parse(defaults)).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('transform', () => {
|
|
||||||
const data = transform({})
|
|
||||||
expect(() => schema.parse(data)).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('transform: legacy fixture', () => {
|
|
||||||
const data = transform(fixtures.LEGACY_DATA_DUMP)
|
|
||||||
expect(() => schema.parse(data)).not.toThrow()
|
|
||||||
expect(data.session.currentAccount?.did).toEqual(fixtures.ALICE_DID)
|
|
||||||
expect(data.session.accounts.length).toEqual(2)
|
|
||||||
})
|
|
||||||
@@ -1,97 +1,86 @@
|
|||||||
import EventEmitter from 'eventemitter3'
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
|
||||||
import BroadcastChannel from '#/lib/broadcast'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {migrate} from '#/state/persisted/legacy'
|
import {
|
||||||
import {defaults, Schema} from '#/state/persisted/schema'
|
defaults,
|
||||||
import * as store from '#/state/persisted/store'
|
Schema,
|
||||||
|
tryParse,
|
||||||
|
tryStringify,
|
||||||
|
} from '#/state/persisted/schema'
|
||||||
|
import {PersistedApi} from './types'
|
||||||
|
|
||||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||||
export {defaults} from '#/state/persisted/schema'
|
export {defaults} from '#/state/persisted/schema'
|
||||||
|
|
||||||
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||||
const UPDATE_EVENT = 'BSKY_UPDATE'
|
|
||||||
|
|
||||||
let _state: Schema = defaults
|
let _state: Schema = defaults
|
||||||
const _emitter = new EventEmitter()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes and returns persisted data state, so that it can be passed to
|
|
||||||
* the Provider.
|
|
||||||
*/
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
logger.debug('persisted state: initializing')
|
const stored = await readFromStorage()
|
||||||
|
if (stored) {
|
||||||
broadcast.onmessage = onBroadcastMessage
|
_state = stored
|
||||||
|
|
||||||
try {
|
|
||||||
await migrate() // migrate old store
|
|
||||||
const stored = await store.read() // check for new store
|
|
||||||
if (!stored) {
|
|
||||||
logger.debug('persisted state: initializing default storage')
|
|
||||||
await store.write(defaults) // opt: init new store
|
|
||||||
}
|
|
||||||
_state = stored || defaults // return new store
|
|
||||||
logger.debug('persisted state: initialized')
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('persisted state: failed to load root state from storage', {
|
|
||||||
message: e,
|
|
||||||
})
|
|
||||||
// AsyncStorage failure, but we can still continue in memory
|
|
||||||
return defaults
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
init satisfies PersistedApi['init']
|
||||||
|
|
||||||
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||||
return _state[key]
|
return _state[key]
|
||||||
}
|
}
|
||||||
|
get satisfies PersistedApi['get']
|
||||||
|
|
||||||
export async function write<K extends keyof Schema>(
|
export async function write<K extends keyof Schema>(
|
||||||
key: K,
|
key: K,
|
||||||
value: Schema[K],
|
value: Schema[K],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
_state = {
|
||||||
|
..._state,
|
||||||
|
[key]: value,
|
||||||
|
}
|
||||||
|
await writeToStorage(_state)
|
||||||
|
}
|
||||||
|
write satisfies PersistedApi['write']
|
||||||
|
|
||||||
|
export function onUpdate<K extends keyof Schema>(
|
||||||
|
_key: K,
|
||||||
|
_cb: (v: Schema[K]) => void,
|
||||||
|
): () => void {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
onUpdate satisfies PersistedApi['onUpdate']
|
||||||
|
|
||||||
|
export async function clearStorage() {
|
||||||
try {
|
try {
|
||||||
_state[key] = value
|
await AsyncStorage.removeItem(BSKY_STORAGE)
|
||||||
await store.write(_state)
|
} catch (e: any) {
|
||||||
// must happen on next tick, otherwise the tab will read stale storage data
|
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
||||||
setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0)
|
}
|
||||||
logger.debug(`persisted state: wrote root state to storage`, {
|
}
|
||||||
updatedKey: key,
|
clearStorage satisfies PersistedApi['clearStorage']
|
||||||
})
|
|
||||||
|
async function writeToStorage(value: Schema) {
|
||||||
|
const rawData = tryStringify(value)
|
||||||
|
if (rawData) {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(BSKY_STORAGE, rawData)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`persisted state: failed writing root state to storage`, {
|
logger.error(`persisted state: failed writing root state to storage`, {
|
||||||
message: e,
|
message: e,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onUpdate(cb: () => void): () => void {
|
async function readFromStorage(): Promise<Schema | undefined> {
|
||||||
_emitter.addListener('update', cb)
|
let rawData: string | null = null
|
||||||
return () => _emitter.removeListener('update', cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onBroadcastMessage({data}: MessageEvent) {
|
|
||||||
// validate event
|
|
||||||
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
|
|
||||||
try {
|
try {
|
||||||
// read next state, possibly updated by another tab
|
rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
||||||
const next = await store.read()
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
logger.debug(`persisted state: handling update from broadcast channel`)
|
|
||||||
_state = next
|
|
||||||
_emitter.emit('update')
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
`persisted state: handled update update from broadcast channel, but found no data`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(`persisted state: failed reading root state from storage`, {
|
||||||
`persisted state: failed handling update from broadcast channel`,
|
|
||||||
{
|
|
||||||
message: e,
|
message: e,
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if (rawData) {
|
||||||
|
return tryParse(rawData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
|
import BroadcastChannel from '#/lib/broadcast'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {
|
||||||
|
defaults,
|
||||||
|
Schema,
|
||||||
|
tryParse,
|
||||||
|
tryStringify,
|
||||||
|
} from '#/state/persisted/schema'
|
||||||
|
import {PersistedApi} from './types'
|
||||||
|
|
||||||
|
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||||
|
export {defaults} from '#/state/persisted/schema'
|
||||||
|
|
||||||
|
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||||
|
|
||||||
|
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
||||||
|
const UPDATE_EVENT = 'BSKY_UPDATE'
|
||||||
|
|
||||||
|
let _state: Schema = defaults
|
||||||
|
const _emitter = new EventEmitter()
|
||||||
|
|
||||||
|
export async function init() {
|
||||||
|
broadcast.onmessage = onBroadcastMessage
|
||||||
|
const stored = readFromStorage()
|
||||||
|
if (stored) {
|
||||||
|
_state = stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init satisfies PersistedApi['init']
|
||||||
|
|
||||||
|
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||||
|
return _state[key]
|
||||||
|
}
|
||||||
|
get satisfies PersistedApi['get']
|
||||||
|
|
||||||
|
export async function write<K extends keyof Schema>(
|
||||||
|
key: K,
|
||||||
|
value: Schema[K],
|
||||||
|
): Promise<void> {
|
||||||
|
const next = readFromStorage()
|
||||||
|
if (next) {
|
||||||
|
// The storage could have been updated by a different tab before this tab is notified.
|
||||||
|
// Make sure this write is applied on top of the latest data in the storage as long as it's valid.
|
||||||
|
_state = next
|
||||||
|
// Don't fire the update listeners yet to avoid a loop.
|
||||||
|
// If there was a change, we'll receive the broadcast event soon enough which will do that.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) {
|
||||||
|
// Fast path for updates that are guaranteed to be noops.
|
||||||
|
// This is good mostly because it avoids useless broadcasts to other tabs.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore and go through the normal path.
|
||||||
|
}
|
||||||
|
_state = {
|
||||||
|
..._state,
|
||||||
|
[key]: value,
|
||||||
|
}
|
||||||
|
writeToStorage(_state)
|
||||||
|
broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
|
||||||
|
broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading
|
||||||
|
}
|
||||||
|
write satisfies PersistedApi['write']
|
||||||
|
|
||||||
|
export function onUpdate<K extends keyof Schema>(
|
||||||
|
key: K,
|
||||||
|
cb: (v: Schema[K]) => void,
|
||||||
|
): () => void {
|
||||||
|
const listener = () => cb(get(key))
|
||||||
|
_emitter.addListener('update', listener) // Backcompat while upgrading
|
||||||
|
_emitter.addListener('update:' + key, listener)
|
||||||
|
return () => {
|
||||||
|
_emitter.removeListener('update', listener) // Backcompat while upgrading
|
||||||
|
_emitter.removeListener('update:' + key, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onUpdate satisfies PersistedApi['onUpdate']
|
||||||
|
|
||||||
|
export async function clearStorage() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(BSKY_STORAGE)
|
||||||
|
} catch (e: any) {
|
||||||
|
// Expected on the web in private mode.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearStorage satisfies PersistedApi['clearStorage']
|
||||||
|
|
||||||
|
async function onBroadcastMessage({data}: MessageEvent) {
|
||||||
|
if (
|
||||||
|
typeof data === 'object' &&
|
||||||
|
(data.event === UPDATE_EVENT || // Backcompat while upgrading
|
||||||
|
data.event?.type === UPDATE_EVENT)
|
||||||
|
) {
|
||||||
|
// read next state, possibly updated by another tab
|
||||||
|
const next = readFromStorage()
|
||||||
|
if (next === _state) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (next) {
|
||||||
|
_state = next
|
||||||
|
if (typeof data.event.key === 'string') {
|
||||||
|
_emitter.emit('update:' + data.event.key)
|
||||||
|
} else {
|
||||||
|
_emitter.emit('update') // Backcompat while upgrading
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
`persisted state: handled update update from broadcast channel, but found no data`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToStorage(value: Schema) {
|
||||||
|
const rawData = tryStringify(value)
|
||||||
|
if (rawData) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(BSKY_STORAGE, rawData)
|
||||||
|
} catch (e) {
|
||||||
|
// Expected on the web in private mode.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastRawData: string | undefined
|
||||||
|
let lastResult: Schema | undefined
|
||||||
|
function readFromStorage(): Schema | undefined {
|
||||||
|
let rawData: string | null = null
|
||||||
|
try {
|
||||||
|
rawData = localStorage.getItem(BSKY_STORAGE)
|
||||||
|
} catch (e) {
|
||||||
|
// Expected on the web in private mode.
|
||||||
|
}
|
||||||
|
if (rawData) {
|
||||||
|
if (rawData === lastRawData) {
|
||||||
|
return lastResult
|
||||||
|
} else {
|
||||||
|
const result = tryParse(rawData)
|
||||||
|
lastRawData = rawData
|
||||||
|
lastResult = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {defaults, Schema, schema} from '#/state/persisted/schema'
|
|
||||||
import {read, write} from '#/state/persisted/store'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The shape of the serialized data from our legacy Mobx store.
|
|
||||||
*/
|
|
||||||
export type LegacySchema = {
|
|
||||||
shell: {
|
|
||||||
colorMode: 'system' | 'light' | 'dark'
|
|
||||||
}
|
|
||||||
session: {
|
|
||||||
data: {
|
|
||||||
service: string
|
|
||||||
did: `did:plc:${string}`
|
|
||||||
} | null
|
|
||||||
accounts: {
|
|
||||||
service: string
|
|
||||||
did: `did:plc:${string}`
|
|
||||||
refreshJwt: string
|
|
||||||
accessJwt: string
|
|
||||||
handle: string
|
|
||||||
email: string
|
|
||||||
displayName: string
|
|
||||||
aviUrl: string
|
|
||||||
emailConfirmed: boolean
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
me: {
|
|
||||||
did: `did:plc:${string}`
|
|
||||||
handle: string
|
|
||||||
displayName: string
|
|
||||||
description: string
|
|
||||||
avatar: string
|
|
||||||
}
|
|
||||||
onboarding: {
|
|
||||||
step: string
|
|
||||||
}
|
|
||||||
preferences: {
|
|
||||||
primaryLanguage: string
|
|
||||||
contentLanguages: string[]
|
|
||||||
postLanguage: string
|
|
||||||
postLanguageHistory: string[]
|
|
||||||
contentLabels: {
|
|
||||||
nsfw: string
|
|
||||||
nudity: string
|
|
||||||
suggestive: string
|
|
||||||
gore: string
|
|
||||||
hate: string
|
|
||||||
spam: string
|
|
||||||
impersonation: string
|
|
||||||
}
|
|
||||||
savedFeeds: string[]
|
|
||||||
pinnedFeeds: string[]
|
|
||||||
requireAltTextEnabled: boolean
|
|
||||||
}
|
|
||||||
invitedUsers: {
|
|
||||||
seenDids: string[]
|
|
||||||
copiedInvites: string[]
|
|
||||||
}
|
|
||||||
mutedThreads: {uris: string[]}
|
|
||||||
reminders: {lastEmailConfirm?: string}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
|
|
||||||
|
|
||||||
export function transform(legacy: Partial<LegacySchema>): Schema {
|
|
||||||
return {
|
|
||||||
colorMode: legacy.shell?.colorMode || defaults.colorMode,
|
|
||||||
darkTheme: defaults.darkTheme,
|
|
||||||
session: {
|
|
||||||
accounts: legacy.session?.accounts || defaults.session.accounts,
|
|
||||||
currentAccount:
|
|
||||||
legacy.session?.accounts?.find(
|
|
||||||
a => a.did === legacy.session?.data?.did,
|
|
||||||
) || defaults.session.currentAccount,
|
|
||||||
},
|
|
||||||
reminders: {
|
|
||||||
lastEmailConfirm:
|
|
||||||
legacy.reminders?.lastEmailConfirm ||
|
|
||||||
defaults.reminders.lastEmailConfirm,
|
|
||||||
},
|
|
||||||
languagePrefs: {
|
|
||||||
primaryLanguage:
|
|
||||||
legacy.preferences?.primaryLanguage ||
|
|
||||||
defaults.languagePrefs.primaryLanguage,
|
|
||||||
contentLanguages:
|
|
||||||
legacy.preferences?.contentLanguages ||
|
|
||||||
defaults.languagePrefs.contentLanguages,
|
|
||||||
postLanguage:
|
|
||||||
legacy.preferences?.postLanguage || defaults.languagePrefs.postLanguage,
|
|
||||||
postLanguageHistory:
|
|
||||||
legacy.preferences?.postLanguageHistory ||
|
|
||||||
defaults.languagePrefs.postLanguageHistory,
|
|
||||||
appLanguage:
|
|
||||||
legacy.preferences?.primaryLanguage ||
|
|
||||||
defaults.languagePrefs.appLanguage,
|
|
||||||
},
|
|
||||||
requireAltTextEnabled:
|
|
||||||
legacy.preferences?.requireAltTextEnabled ||
|
|
||||||
defaults.requireAltTextEnabled,
|
|
||||||
mutedThreads: legacy.mutedThreads?.uris || defaults.mutedThreads,
|
|
||||||
invites: {
|
|
||||||
copiedInvites:
|
|
||||||
legacy.invitedUsers?.copiedInvites || defaults.invites.copiedInvites,
|
|
||||||
},
|
|
||||||
onboarding: {
|
|
||||||
step: legacy.onboarding?.step || defaults.onboarding.step,
|
|
||||||
},
|
|
||||||
hiddenPosts: defaults.hiddenPosts,
|
|
||||||
externalEmbeds: defaults.externalEmbeds,
|
|
||||||
lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
|
|
||||||
pdsAddressHistory: defaults.pdsAddressHistory,
|
|
||||||
disableHaptics: defaults.disableHaptics,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates legacy persisted state to new store if new store doesn't exist in
|
|
||||||
* local storage AND old storage exists.
|
|
||||||
*/
|
|
||||||
export async function migrate() {
|
|
||||||
logger.debug('persisted state: check need to migrate')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rawLegacyData = await AsyncStorage.getItem(
|
|
||||||
DEPRECATED_ROOT_STATE_STORAGE_KEY,
|
|
||||||
)
|
|
||||||
const newData = await read()
|
|
||||||
const alreadyMigrated = Boolean(newData)
|
|
||||||
|
|
||||||
if (!alreadyMigrated && rawLegacyData) {
|
|
||||||
logger.debug('persisted state: migrating legacy storage')
|
|
||||||
|
|
||||||
const legacyData = JSON.parse(rawLegacyData)
|
|
||||||
const newData = transform(legacyData)
|
|
||||||
const validate = schema.safeParse(newData)
|
|
||||||
|
|
||||||
if (validate.success) {
|
|
||||||
await write(newData)
|
|
||||||
logger.debug('persisted state: migrated legacy storage')
|
|
||||||
} else {
|
|
||||||
logger.error('persisted state: legacy data failed validation', {
|
|
||||||
message: validate.error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.debug('persisted state: no migration needed')
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
logger.error(e, {
|
|
||||||
message: 'persisted state: error migrating legacy storage',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearLegacyStorage() {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.removeItem(DEPRECATED_ROOT_STATE_STORAGE_KEY)
|
|
||||||
} catch (e: any) {
|
|
||||||
logger.error(`persisted legacy store: failed to clear`, {
|
|
||||||
message: e.toString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import {z} from 'zod'
|
import {z} from 'zod'
|
||||||
|
|
||||||
import {deviceLocales, prefersReducedMotion} from '#/platform/detection'
|
import {logger} from '#/logger'
|
||||||
|
import {deviceLocales} from '#/platform/detection'
|
||||||
|
import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
const externalEmbedOptions = ['show', 'hide'] as const
|
const externalEmbedOptions = ['show', 'hide'] as const
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ const currentAccountSchema = accountSchema.extend({
|
|||||||
})
|
})
|
||||||
export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
|
export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
|
||||||
|
|
||||||
export const schema = z.object({
|
const schema = z.object({
|
||||||
colorMode: z.enum(['system', 'light', 'dark']),
|
colorMode: z.enum(['system', 'light', 'dark']),
|
||||||
darkTheme: z.enum(['dim', 'dark']).optional(),
|
darkTheme: z.enum(['dim', 'dark']).optional(),
|
||||||
session: z.object({
|
session: z.object({
|
||||||
@@ -89,6 +91,7 @@ export const schema = z.object({
|
|||||||
disableAutoplay: z.boolean().optional(),
|
disableAutoplay: z.boolean().optional(),
|
||||||
kawaii: z.boolean().optional(),
|
kawaii: z.boolean().optional(),
|
||||||
hasCheckedForStarterPack: z.boolean().optional(),
|
hasCheckedForStarterPack: z.boolean().optional(),
|
||||||
|
subtitlesEnabled: z.boolean().optional(),
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
mutedThreads: z.array(z.string()),
|
mutedThreads: z.array(z.string()),
|
||||||
})
|
})
|
||||||
@@ -128,7 +131,48 @@ export const defaults: Schema = {
|
|||||||
lastSelectedHomeFeed: undefined,
|
lastSelectedHomeFeed: undefined,
|
||||||
pdsAddressHistory: [],
|
pdsAddressHistory: [],
|
||||||
disableHaptics: false,
|
disableHaptics: false,
|
||||||
disableAutoplay: prefersReducedMotion,
|
disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
|
||||||
kawaii: false,
|
kawaii: false,
|
||||||
hasCheckedForStarterPack: false,
|
hasCheckedForStarterPack: false,
|
||||||
|
subtitlesEnabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryParse(rawData: string): Schema | undefined {
|
||||||
|
let objData
|
||||||
|
try {
|
||||||
|
objData = JSON.parse(rawData)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('persisted state: failed to parse root state from storage', {
|
||||||
|
message: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!objData) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(objData)
|
||||||
|
if (parsed.success) {
|
||||||
|
return objData
|
||||||
|
} else {
|
||||||
|
const errors =
|
||||||
|
parsed.error?.errors?.map(e => ({
|
||||||
|
code: e.code,
|
||||||
|
// @ts-ignore exists on some types
|
||||||
|
expected: e?.expected,
|
||||||
|
path: e.path?.join('.'),
|
||||||
|
})) || []
|
||||||
|
logger.error(`persisted store: data failed validation on read`, {errors})
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryStringify(value: Schema): string | undefined {
|
||||||
|
try {
|
||||||
|
schema.parse(value)
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`persisted state: failed stringifying root state`, {
|
||||||
|
message: e,
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {Schema, schema} from '#/state/persisted/schema'
|
|
||||||
|
|
||||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
|
||||||
|
|
||||||
export async function write(value: Schema) {
|
|
||||||
schema.parse(value)
|
|
||||||
await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function read(): Promise<Schema | undefined> {
|
|
||||||
const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
|
||||||
const objData = rawData ? JSON.parse(rawData) : undefined
|
|
||||||
|
|
||||||
// new user
|
|
||||||
if (!objData) return undefined
|
|
||||||
|
|
||||||
// existing user, validate
|
|
||||||
const parsed = schema.safeParse(objData)
|
|
||||||
|
|
||||||
if (parsed.success) {
|
|
||||||
return objData
|
|
||||||
} else {
|
|
||||||
const errors =
|
|
||||||
parsed.error?.errors?.map(e => ({
|
|
||||||
code: e.code,
|
|
||||||
// @ts-ignore exists on some types
|
|
||||||
expected: e?.expected,
|
|
||||||
path: e.path?.join('.'),
|
|
||||||
})) || []
|
|
||||||
logger.error(`persisted store: data failed validation on read`, {errors})
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clear() {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.removeItem(BSKY_STORAGE)
|
|
||||||
} catch (e: any) {
|
|
||||||
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type {Schema} from './schema'
|
||||||
|
|
||||||
|
export type PersistedApi = {
|
||||||
|
init(): Promise<void>
|
||||||
|
get<K extends keyof Schema>(key: K): Schema[K]
|
||||||
|
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void>
|
||||||
|
onUpdate<K extends keyof Schema>(
|
||||||
|
key: K,
|
||||||
|
cb: (v: Schema[K]) => void,
|
||||||
|
): () => void
|
||||||
|
clearStorage: () => Promise<void>
|
||||||
|
}
|
||||||
@@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate(
|
||||||
setState(persisted.get('requireAltTextEnabled'))
|
'requireAltTextEnabled',
|
||||||
})
|
nextRequireAltTextEnabled => {
|
||||||
|
setState(nextRequireAltTextEnabled)
|
||||||
|
},
|
||||||
|
)
|
||||||
}, [setStateWrapped])
|
}, [setStateWrapped])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate('disableAutoplay', nextDisableAutoplay => {
|
||||||
setState(Boolean(persisted.get('disableAutoplay')))
|
setState(Boolean(nextDisableAutoplay))
|
||||||
})
|
})
|
||||||
}, [setStateWrapped])
|
}, [setStateWrapped])
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate('disableHaptics', nextDisableHaptics => {
|
||||||
setState(Boolean(persisted.get('disableHaptics')))
|
setState(Boolean(nextDisableHaptics))
|
||||||
})
|
})
|
||||||
}, [setStateWrapped])
|
}, [setStateWrapped])
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate('externalEmbeds', nextExternalEmbeds => {
|
||||||
setState(persisted.get('externalEmbeds'))
|
setState(nextExternalEmbeds)
|
||||||
})
|
})
|
||||||
}, [setStateWrapped])
|
}, [setStateWrapped])
|
||||||
|
|
||||||
|
|||||||
@@ -19,63 +19,51 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (feedDesc.startsWith('feedgen')) {
|
if (feedDesc.startsWith('feedgen')) {
|
||||||
return [
|
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
|
||||||
FeedTuner.dedupReposts,
|
|
||||||
FeedTuner.preferredLangOnly(langPrefs.contentLanguages),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
if (feedDesc.startsWith('list')) {
|
if (feedDesc.startsWith('list')) {
|
||||||
const feedTuners = []
|
let feedTuners = []
|
||||||
|
|
||||||
if (feedDesc.endsWith('|as_following')) {
|
if (feedDesc.endsWith('|as_following')) {
|
||||||
// Same as Following tuners below, copypaste for now.
|
// Same as Following tuners below, copypaste for now.
|
||||||
|
feedTuners.push(FeedTuner.removeOrphans)
|
||||||
if (preferences?.feedViewPrefs.hideReposts) {
|
if (preferences?.feedViewPrefs.hideReposts) {
|
||||||
feedTuners.push(FeedTuner.removeReposts)
|
feedTuners.push(FeedTuner.removeReposts)
|
||||||
} else {
|
|
||||||
feedTuners.push(FeedTuner.dedupReposts)
|
|
||||||
}
|
}
|
||||||
if (preferences?.feedViewPrefs.hideReplies) {
|
if (preferences?.feedViewPrefs.hideReplies) {
|
||||||
feedTuners.push(FeedTuner.removeReplies)
|
feedTuners.push(FeedTuner.removeReplies)
|
||||||
} else {
|
} else {
|
||||||
feedTuners.push(
|
feedTuners.push(
|
||||||
FeedTuner.thresholdRepliesOnly({
|
FeedTuner.followedRepliesOnly({
|
||||||
userDid: currentAccount?.did || '',
|
userDid: currentAccount?.did || '',
|
||||||
minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0,
|
|
||||||
followedOnly:
|
|
||||||
!!preferences?.feedViewPrefs.hideRepliesByUnfollowed,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
||||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||||
}
|
}
|
||||||
} else {
|
feedTuners.push(FeedTuner.dedupThreads)
|
||||||
feedTuners.push(FeedTuner.dedupReposts)
|
|
||||||
}
|
}
|
||||||
return feedTuners
|
return feedTuners
|
||||||
}
|
}
|
||||||
if (feedDesc === 'following') {
|
if (feedDesc === 'following') {
|
||||||
const feedTuners = []
|
const feedTuners = [FeedTuner.removeOrphans]
|
||||||
|
|
||||||
if (preferences?.feedViewPrefs.hideReposts) {
|
if (preferences?.feedViewPrefs.hideReposts) {
|
||||||
feedTuners.push(FeedTuner.removeReposts)
|
feedTuners.push(FeedTuner.removeReposts)
|
||||||
} else {
|
|
||||||
feedTuners.push(FeedTuner.dedupReposts)
|
|
||||||
}
|
}
|
||||||
if (preferences?.feedViewPrefs.hideReplies) {
|
if (preferences?.feedViewPrefs.hideReplies) {
|
||||||
feedTuners.push(FeedTuner.removeReplies)
|
feedTuners.push(FeedTuner.removeReplies)
|
||||||
} else {
|
} else {
|
||||||
feedTuners.push(
|
feedTuners.push(
|
||||||
FeedTuner.thresholdRepliesOnly({
|
FeedTuner.followedRepliesOnly({
|
||||||
userDid: currentAccount?.did || '',
|
userDid: currentAccount?.did || '',
|
||||||
minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0,
|
|
||||||
followedOnly: !!preferences?.feedViewPrefs.hideRepliesByUnfollowed,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
||||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||||
}
|
}
|
||||||
|
feedTuners.push(FeedTuner.dedupThreads)
|
||||||
|
|
||||||
return feedTuners
|
return feedTuners
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate('hiddenPosts', nextHiddenPosts => {
|
||||||
setState(persisted.get('hiddenPosts'))
|
setState(nextHiddenPosts)
|
||||||
})
|
})
|
||||||
}, [setStateWrapped])
|
}, [setStateWrapped])
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate('useInAppBrowser', nextUseInAppBrowser => {
|
||||||
setState(persisted.get('useInAppBrowser'))
|
setState(nextUseInAppBrowser)
|
||||||
})
|
})
|
||||||
}, [setStateWrapped])
|
}, [setStateWrapped])
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser'
|
|||||||
import {Provider as KawaiiProvider} from './kawaii'
|
import {Provider as KawaiiProvider} from './kawaii'
|
||||||
import {Provider as LanguagesProvider} from './languages'
|
import {Provider as LanguagesProvider} from './languages'
|
||||||
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
|
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
|
||||||
|
import {Provider as SubtitlesProvider} from './subtitles'
|
||||||
import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
|
import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -24,6 +25,7 @@ export {
|
|||||||
export * from './hidden-posts'
|
export * from './hidden-posts'
|
||||||
export {useLabelDefinitions} from './label-defs'
|
export {useLabelDefinitions} from './label-defs'
|
||||||
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
|
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
|
||||||
|
export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles'
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +38,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
<DisableHapticsProvider>
|
<DisableHapticsProvider>
|
||||||
<AutoplayProvider>
|
<AutoplayProvider>
|
||||||
<UsedStarterPacksProvider>
|
<UsedStarterPacksProvider>
|
||||||
|
<SubtitlesProvider>
|
||||||
<KawaiiProvider>{children}</KawaiiProvider>
|
<KawaiiProvider>{children}</KawaiiProvider>
|
||||||
|
</SubtitlesProvider>
|
||||||
</UsedStarterPacksProvider>
|
</UsedStarterPacksProvider>
|
||||||
</AutoplayProvider>
|
</AutoplayProvider>
|
||||||
</DisableHapticsProvider>
|
</DisableHapticsProvider>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate('kawaii', nextKawaii => {
|
||||||
setState(persisted.get('kawaii'))
|
setState(nextKawaii)
|
||||||
})
|
})
|
||||||
}, [setStateWrapped])
|
}, [setStateWrapped])
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate('languagePrefs', nextLanguagePrefs => {
|
||||||
setState(persisted.get('languagePrefs'))
|
setState(nextLanguagePrefs)
|
||||||
})
|
})
|
||||||
}, [setStateWrapped])
|
}, [setStateWrapped])
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate(
|
||||||
setState(persisted.get('largeAltBadgeEnabled'))
|
'largeAltBadgeEnabled',
|
||||||
})
|
nextLargeAltBadgeEnabled => {
|
||||||
|
setState(nextLargeAltBadgeEnabled)
|
||||||
|
},
|
||||||
|
)
|
||||||
}, [setStateWrapped])
|
}, [setStateWrapped])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import * as persisted from '#/state/persisted'
|
||||||
|
|
||||||
|
type StateContext = boolean
|
||||||
|
type SetContext = (v: boolean) => void
|
||||||
|
|
||||||
|
const stateContext = React.createContext<StateContext>(
|
||||||
|
Boolean(persisted.defaults.subtitlesEnabled),
|
||||||
|
)
|
||||||
|
const setContext = React.createContext<SetContext>((_: boolean) => {})
|
||||||
|
|
||||||
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
|
const [state, setState] = React.useState(
|
||||||
|
Boolean(persisted.get('subtitlesEnabled')),
|
||||||
|
)
|
||||||
|
|
||||||
|
const setStateWrapped = React.useCallback(
|
||||||
|
(subtitlesEnabled: persisted.Schema['subtitlesEnabled']) => {
|
||||||
|
setState(Boolean(subtitlesEnabled))
|
||||||
|
persisted.write('subtitlesEnabled', subtitlesEnabled)
|
||||||
|
},
|
||||||
|
[setState],
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return persisted.onUpdate('subtitlesEnabled', nextSubtitlesEnabled => {
|
||||||
|
setState(Boolean(nextSubtitlesEnabled))
|
||||||
|
})
|
||||||
|
}, [setStateWrapped])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<stateContext.Provider value={state}>
|
||||||
|
<setContext.Provider value={setStateWrapped}>
|
||||||
|
{children}
|
||||||
|
</setContext.Provider>
|
||||||
|
</stateContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSubtitlesEnabled = () => React.useContext(stateContext)
|
||||||
|
export const useSetSubtitlesEnabled = () => React.useContext(setContext)
|
||||||
@@ -19,9 +19,12 @@ export function Provider({children}: {children: React.ReactNode}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate(
|
||||||
setState(persisted.get('hasCheckedForStarterPack'))
|
'hasCheckedForStarterPack',
|
||||||
})
|
nextHasCheckedForStarterPack => {
|
||||||
|
setState(nextHasCheckedForStarterPack)
|
||||||
|
},
|
||||||
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
AppBskyGraphDefs,
|
AppBskyGraphDefs,
|
||||||
AppBskyUnspeccedGetPopularFeedGenerators,
|
AppBskyUnspeccedGetPopularFeedGenerators,
|
||||||
AtUri,
|
AtUri,
|
||||||
|
moderateFeedGenerator,
|
||||||
RichText,
|
RichText,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +27,7 @@ import {RQKEY as listQueryKey} from '#/state/queries/list'
|
|||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {router} from '#/routes'
|
import {router} from '#/routes'
|
||||||
|
import {useModerationOpts} from '../preferences/moderation-opts'
|
||||||
import {FeedDescriptor} from './post-feed'
|
import {FeedDescriptor} from './post-feed'
|
||||||
import {precacheResolvedUri} from './resolve-uri'
|
import {precacheResolvedUri} from './resolve-uri'
|
||||||
|
|
||||||
@@ -207,14 +209,16 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
|||||||
const limit = options?.limit || 10
|
const limit = options?.limit || 10
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
// Make sure this doesn't invalidate unless really needed.
|
// Make sure this doesn't invalidate unless really needed.
|
||||||
const selectArgs = useMemo(
|
const selectArgs = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
hasSession,
|
hasSession,
|
||||||
savedFeeds: preferences?.savedFeeds || [],
|
savedFeeds: preferences?.savedFeeds || [],
|
||||||
|
moderationOpts,
|
||||||
}),
|
}),
|
||||||
[hasSession, preferences?.savedFeeds],
|
[hasSession, preferences?.savedFeeds, moderationOpts],
|
||||||
)
|
)
|
||||||
const lastPageCountRef = useRef(0)
|
const lastPageCountRef = useRef(0)
|
||||||
|
|
||||||
@@ -225,6 +229,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
|||||||
QueryKey,
|
QueryKey,
|
||||||
string | undefined
|
string | undefined
|
||||||
>({
|
>({
|
||||||
|
enabled: Boolean(moderationOpts),
|
||||||
queryKey: createGetPopularFeedsQueryKey(options),
|
queryKey: createGetPopularFeedsQueryKey(options),
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
||||||
@@ -246,7 +251,11 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
|||||||
(
|
(
|
||||||
data: InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
|
data: InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
|
||||||
) => {
|
) => {
|
||||||
const {savedFeeds, hasSession: hasSessionInner} = selectArgs
|
const {
|
||||||
|
savedFeeds,
|
||||||
|
hasSession: hasSessionInner,
|
||||||
|
moderationOpts,
|
||||||
|
} = selectArgs
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
pages: data.pages.map(page => {
|
pages: data.pages.map(page => {
|
||||||
@@ -264,7 +273,8 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
|||||||
return f.value === feed.uri
|
return f.value === feed.uri
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return !alreadySaved
|
const decision = moderateFeedGenerator(feed, moderationOpts!)
|
||||||
|
return !alreadySaved && !decision.ui('contentList').filter
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -304,6 +314,8 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
|||||||
|
|
||||||
export function useSearchPopularFeedsMutation() {
|
export function useSearchPopularFeedsMutation() {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (query: string) => {
|
mutationFn: async (query: string) => {
|
||||||
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
||||||
@@ -311,24 +323,15 @@ export function useSearchPopularFeedsMutation() {
|
|||||||
query: query,
|
query: query,
|
||||||
})
|
})
|
||||||
|
|
||||||
return res.data.feeds
|
if (moderationOpts) {
|
||||||
},
|
return res.data.feeds.filter(feed => {
|
||||||
})
|
const decision = moderateFeedGenerator(feed, moderationOpts)
|
||||||
}
|
return !decision.ui('contentList').filter
|
||||||
|
|
||||||
export function useSearchPopularFeedsQuery({q}: {q: string}) {
|
|
||||||
const agent = useAgent()
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['searchPopularFeeds', q],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
|
||||||
limit: 15,
|
|
||||||
query: q,
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return res.data.feeds
|
return res.data.feeds
|
||||||
},
|
},
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,17 +349,27 @@ export function usePopularFeedsSearch({
|
|||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const enabledInner = enabled ?? Boolean(moderationOpts)
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
enabled,
|
enabled: enabledInner,
|
||||||
queryKey: createPopularFeedsSearchQueryKey(query),
|
queryKey: createPopularFeedsSearchQueryKey(query),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
||||||
limit: 10,
|
limit: 15,
|
||||||
query: query,
|
query: query,
|
||||||
})
|
})
|
||||||
|
|
||||||
return res.data.feeds
|
return res.data.feeds
|
||||||
},
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
select(data) {
|
||||||
|
return data.filter(feed => {
|
||||||
|
const decision = moderateFeedGenerator(feed, moderationOpts!)
|
||||||
|
return !decision.ui('contentList').filter
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export function useNotificationFeedQuery(opts?: {
|
|||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const unreads = useUnreadNotificationsApi()
|
const unreads = useUnreadNotificationsApi()
|
||||||
const enabled = opts?.enabled !== false
|
const enabled = opts?.enabled !== false
|
||||||
const lastPageCountRef = useRef(0)
|
|
||||||
const gate = useGate()
|
const gate = useGate()
|
||||||
|
|
||||||
// false: force showing all notifications
|
// false: force showing all notifications
|
||||||
@@ -121,28 +120,52 @@ export function useNotificationFeedQuery(opts?: {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The server may end up returning an empty page, a page with too few items,
|
||||||
|
// or a page with items that end up getting filtered out. When we fetch pages,
|
||||||
|
// we'll keep track of how many items we actually hope to see. If the server
|
||||||
|
// doesn't return enough items, we're going to continue asking for more items.
|
||||||
|
const lastItemCount = useRef(0)
|
||||||
|
const wantedItemCount = useRef(0)
|
||||||
|
const autoPaginationAttemptCount = useRef(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const {isFetching, hasNextPage, data} = query
|
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
|
||||||
if (isFetching || !hasNextPage) {
|
query
|
||||||
return
|
// Count the items that we already have.
|
||||||
}
|
let itemCount = 0
|
||||||
|
|
||||||
// avoid double-fires of fetchNextPage()
|
|
||||||
if (
|
|
||||||
lastPageCountRef.current !== 0 &&
|
|
||||||
lastPageCountRef.current === data?.pages?.length
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch next page if we haven't gotten a full page of content
|
|
||||||
let count = 0
|
|
||||||
for (const page of data?.pages || []) {
|
for (const page of data?.pages || []) {
|
||||||
count += page.items.length
|
itemCount += page.items.length
|
||||||
}
|
}
|
||||||
if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) {
|
|
||||||
|
// If items got truncated, reset the state we're tracking below.
|
||||||
|
if (itemCount !== lastItemCount.current) {
|
||||||
|
if (itemCount < lastItemCount.current) {
|
||||||
|
wantedItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
lastItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now track how many items we really want, and fetch more if needed.
|
||||||
|
if (isLoading || isRefetching) {
|
||||||
|
// During the initial fetch, we want to get an entire page's worth of items.
|
||||||
|
wantedItemCount.current = PAGE_SIZE
|
||||||
|
} else if (isFetchingNextPage) {
|
||||||
|
if (itemCount > wantedItemCount.current) {
|
||||||
|
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
||||||
|
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
||||||
|
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
||||||
|
wantedItemCount.current = itemCount + PAGE_SIZE
|
||||||
|
}
|
||||||
|
} else if (hasNextPage) {
|
||||||
|
// At this point we're not fetching anymore, so it's time to make a decision.
|
||||||
|
// If we didn't receive enough items from the server, paginate again until we do.
|
||||||
|
if (itemCount < wantedItemCount.current) {
|
||||||
|
autoPaginationAttemptCount.current++
|
||||||
|
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
|
||||||
query.fetchNextPage()
|
query.fetchNextPage()
|
||||||
lastPageCountRef.current = data?.pages?.length || 0
|
}
|
||||||
|
} else {
|
||||||
|
autoPaginationAttemptCount.current = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
|
|||||||
import {ListFeedAPI} from 'lib/api/feed/list'
|
import {ListFeedAPI} from 'lib/api/feed/list'
|
||||||
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
||||||
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
|
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
|
||||||
import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
|
import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip'
|
||||||
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
||||||
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
||||||
import {useFeedTuners} from '../preferences/feed-tuners'
|
import {useFeedTuners} from '../preferences/feed-tuners'
|
||||||
@@ -61,7 +61,6 @@ export type FeedDescriptor =
|
|||||||
| `list|${ListUri}`
|
| `list|${ListUri}`
|
||||||
| `list|${ListUri}|${ListFilter}`
|
| `list|${ListUri}|${ListFilter}`
|
||||||
export interface FeedParams {
|
export interface FeedParams {
|
||||||
disableTuner?: boolean
|
|
||||||
mergeFeedEnabled?: boolean
|
mergeFeedEnabled?: boolean
|
||||||
mergeFeedSources?: string[]
|
mergeFeedSources?: string[]
|
||||||
}
|
}
|
||||||
@@ -78,11 +77,6 @@ export interface FeedPostSliceItem {
|
|||||||
uri: string
|
uri: string
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
reason?:
|
|
||||||
| AppBskyFeedDefs.ReasonRepost
|
|
||||||
| ReasonFeedSource
|
|
||||||
| {[k: string]: unknown; $type: string}
|
|
||||||
feedContext: string | undefined
|
|
||||||
moderation: ModerationDecision
|
moderation: ModerationDecision
|
||||||
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
||||||
isParentBlocked?: boolean
|
isParentBlocked?: boolean
|
||||||
@@ -91,9 +85,14 @@ export interface FeedPostSliceItem {
|
|||||||
export interface FeedPostSlice {
|
export interface FeedPostSlice {
|
||||||
_isFeedPostSlice: boolean
|
_isFeedPostSlice: boolean
|
||||||
_reactKey: string
|
_reactKey: string
|
||||||
rootUri: string
|
|
||||||
isThread: boolean
|
|
||||||
items: FeedPostSliceItem[]
|
items: FeedPostSliceItem[]
|
||||||
|
isIncompleteThread: boolean
|
||||||
|
isFallbackMarker: boolean
|
||||||
|
feedContext: string | undefined
|
||||||
|
reason?:
|
||||||
|
| AppBskyFeedDefs.ReasonRepost
|
||||||
|
| ReasonFeedSource
|
||||||
|
| {[k: string]: unknown; $type: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedPageUnselected {
|
export interface FeedPageUnselected {
|
||||||
@@ -105,7 +104,7 @@ export interface FeedPageUnselected {
|
|||||||
|
|
||||||
export interface FeedPage {
|
export interface FeedPage {
|
||||||
api: FeedAPI
|
api: FeedAPI
|
||||||
tuner: FeedTuner | NoopFeedTuner
|
tuner: FeedTuner
|
||||||
cursor: string | undefined
|
cursor: string | undefined
|
||||||
slices: FeedPostSlice[]
|
slices: FeedPostSlice[]
|
||||||
fetchedAt: number
|
fetchedAt: number
|
||||||
@@ -135,25 +134,17 @@ export function usePostFeedQuery(
|
|||||||
args: typeof selectArgs
|
args: typeof selectArgs
|
||||||
result: InfiniteData<FeedPage>
|
result: InfiniteData<FeedPage>
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const lastPageCountRef = useRef(0)
|
|
||||||
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
|
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
|
||||||
|
|
||||||
// Make sure this doesn't invalidate unless really needed.
|
// Make sure this doesn't invalidate unless really needed.
|
||||||
const selectArgs = React.useMemo(
|
const selectArgs = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
feedTuners,
|
feedTuners,
|
||||||
disableTuner: params?.disableTuner,
|
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
ignoreFilterFor: opts?.ignoreFilterFor,
|
ignoreFilterFor: opts?.ignoreFilterFor,
|
||||||
isDiscover,
|
isDiscover,
|
||||||
}),
|
}),
|
||||||
[
|
[feedTuners, moderationOpts, opts?.ignoreFilterFor, isDiscover],
|
||||||
feedTuners,
|
|
||||||
params?.disableTuner,
|
|
||||||
moderationOpts,
|
|
||||||
opts?.ignoreFilterFor,
|
|
||||||
isDiscover,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const query = useInfiniteQuery<
|
const query = useInfiniteQuery<
|
||||||
@@ -232,17 +223,10 @@ export function usePostFeedQuery(
|
|||||||
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
|
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
|
||||||
// If the selection depends on some data, that data should
|
// If the selection depends on some data, that data should
|
||||||
// be included in the selectArgs object and read here.
|
// be included in the selectArgs object and read here.
|
||||||
const {
|
const {feedTuners, moderationOpts, ignoreFilterFor, isDiscover} =
|
||||||
feedTuners,
|
selectArgs
|
||||||
disableTuner,
|
|
||||||
moderationOpts,
|
|
||||||
ignoreFilterFor,
|
|
||||||
isDiscover,
|
|
||||||
} = selectArgs
|
|
||||||
|
|
||||||
const tuner = disableTuner
|
const tuner = new FeedTuner(feedTuners)
|
||||||
? new NoopFeedTuner()
|
|
||||||
: new FeedTuner(feedTuners)
|
|
||||||
|
|
||||||
// Keep track of the last run and whether we can reuse
|
// Keep track of the last run and whether we can reuse
|
||||||
// some already selected pages from there.
|
// some already selected pages from there.
|
||||||
@@ -329,53 +313,22 @@ export function usePostFeedQuery(
|
|||||||
const feedPostSlice: FeedPostSlice = {
|
const feedPostSlice: FeedPostSlice = {
|
||||||
_reactKey: slice._reactKey,
|
_reactKey: slice._reactKey,
|
||||||
_isFeedPostSlice: true,
|
_isFeedPostSlice: true,
|
||||||
rootUri: slice.uri,
|
isIncompleteThread: slice.isIncompleteThread,
|
||||||
isThread:
|
isFallbackMarker: slice.isFallbackMarker,
|
||||||
slice.items.length > 1 &&
|
feedContext: slice.feedContext,
|
||||||
slice.items.every(
|
reason: slice.reason,
|
||||||
item =>
|
items: slice.items.map((item, i) => {
|
||||||
item.post.author.did ===
|
|
||||||
slice.items[0].post.author.did,
|
|
||||||
),
|
|
||||||
items: slice.items
|
|
||||||
.map((item, i) => {
|
|
||||||
if (
|
|
||||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
|
||||||
AppBskyFeedPost.validateRecord(item.post.record)
|
|
||||||
.success
|
|
||||||
) {
|
|
||||||
const parent = item.reply?.parent
|
|
||||||
let parentAuthor:
|
|
||||||
| AppBskyActorDefs.ProfileViewBasic
|
|
||||||
| undefined
|
|
||||||
if (AppBskyFeedDefs.isPostView(parent)) {
|
|
||||||
parentAuthor = parent.author
|
|
||||||
}
|
|
||||||
if (!parentAuthor) {
|
|
||||||
parentAuthor =
|
|
||||||
slice.items[i + 1]?.reply?.grandparentAuthor
|
|
||||||
}
|
|
||||||
const replyRef = item.reply
|
|
||||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
|
|
||||||
replyRef?.parent,
|
|
||||||
)
|
|
||||||
|
|
||||||
const feedPostSliceItem: FeedPostSliceItem = {
|
const feedPostSliceItem: FeedPostSliceItem = {
|
||||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||||
uri: item.post.uri,
|
uri: item.post.uri,
|
||||||
post: item.post,
|
post: item.post,
|
||||||
record: item.post.record,
|
record: item.record,
|
||||||
reason: slice.reason,
|
|
||||||
feedContext: slice.feedContext,
|
|
||||||
moderation: moderations[i],
|
moderation: moderations[i],
|
||||||
parentAuthor,
|
parentAuthor: item.parentAuthor,
|
||||||
isParentBlocked,
|
isParentBlocked: item.isParentBlocked,
|
||||||
}
|
}
|
||||||
return feedPostSliceItem
|
return feedPostSliceItem
|
||||||
}
|
}),
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
.filter(n => !!n),
|
|
||||||
}
|
}
|
||||||
return feedPostSlice
|
return feedPostSlice
|
||||||
})
|
})
|
||||||
@@ -391,30 +344,54 @@ export function usePostFeedQuery(
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The server may end up returning an empty page, a page with too few items,
|
||||||
|
// or a page with items that end up getting filtered out. When we fetch pages,
|
||||||
|
// we'll keep track of how many items we actually hope to see. If the server
|
||||||
|
// doesn't return enough items, we're going to continue asking for more items.
|
||||||
|
const lastItemCount = useRef(0)
|
||||||
|
const wantedItemCount = useRef(0)
|
||||||
|
const autoPaginationAttemptCount = useRef(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const {isFetching, hasNextPage, data} = query
|
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
|
||||||
if (isFetching || !hasNextPage) {
|
query
|
||||||
return
|
// Count the items that we already have.
|
||||||
}
|
let itemCount = 0
|
||||||
|
|
||||||
// avoid double-fires of fetchNextPage()
|
|
||||||
if (
|
|
||||||
lastPageCountRef.current !== 0 &&
|
|
||||||
lastPageCountRef.current === data?.pages?.length
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch next page if we haven't gotten a full page of content
|
|
||||||
let count = 0
|
|
||||||
for (const page of data?.pages || []) {
|
for (const page of data?.pages || []) {
|
||||||
for (const slice of page.slices) {
|
for (const slice of page.slices) {
|
||||||
count += slice.items.length
|
itemCount += slice.items.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) {
|
|
||||||
|
// If items got truncated, reset the state we're tracking below.
|
||||||
|
if (itemCount !== lastItemCount.current) {
|
||||||
|
if (itemCount < lastItemCount.current) {
|
||||||
|
wantedItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
lastItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now track how many items we really want, and fetch more if needed.
|
||||||
|
if (isLoading || isRefetching) {
|
||||||
|
// During the initial fetch, we want to get an entire page's worth of items.
|
||||||
|
wantedItemCount.current = PAGE_SIZE
|
||||||
|
} else if (isFetchingNextPage) {
|
||||||
|
if (itemCount > wantedItemCount.current) {
|
||||||
|
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
||||||
|
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
||||||
|
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
||||||
|
wantedItemCount.current = itemCount + PAGE_SIZE
|
||||||
|
}
|
||||||
|
} else if (hasNextPage) {
|
||||||
|
// At this point we're not fetching anymore, so it's time to make a decision.
|
||||||
|
// If we didn't receive enough items from the server, paginate again until we do.
|
||||||
|
if (itemCount < wantedItemCount.current) {
|
||||||
|
autoPaginationAttemptCount.current++
|
||||||
|
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
|
||||||
query.fetchNextPage()
|
query.fetchNextPage()
|
||||||
lastPageCountRef.current = data?.pages?.length || 0
|
}
|
||||||
|
} else {
|
||||||
|
autoPaginationAttemptCount.current = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
@@ -434,7 +411,6 @@ export async function pollLatest(page: FeedPage | undefined) {
|
|||||||
if (post) {
|
if (post) {
|
||||||
const slices = page.tuner.tune([post], {
|
const slices = page.tuner.tune([post], {
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
maintainOrder: true,
|
|
||||||
})
|
})
|
||||||
if (slices[0]) {
|
if (slices[0]) {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export function sortThread(
|
|||||||
node: ThreadNode,
|
node: ThreadNode,
|
||||||
opts: UsePreferencesQueryResponse['threadViewPrefs'],
|
opts: UsePreferencesQueryResponse['threadViewPrefs'],
|
||||||
modCache: ThreadModerationCache,
|
modCache: ThreadModerationCache,
|
||||||
|
currentDid: string | undefined,
|
||||||
): ThreadNode {
|
): ThreadNode {
|
||||||
if (node.type !== 'post') {
|
if (node.type !== 'post') {
|
||||||
return node
|
return node
|
||||||
@@ -159,6 +160,16 @@ export function sortThread(
|
|||||||
return 1 // op's own reply
|
return 1 // op's own reply
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const aIsBySelf = a.post.author.did === currentDid
|
||||||
|
const bIsBySelf = b.post.author.did === currentDid
|
||||||
|
if (aIsBySelf && bIsBySelf) {
|
||||||
|
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||||
|
} else if (aIsBySelf) {
|
||||||
|
return -1 // current account's reply
|
||||||
|
} else if (bIsBySelf) {
|
||||||
|
return 1 // current account's reply
|
||||||
|
}
|
||||||
|
|
||||||
const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
|
const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
|
||||||
const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
|
const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
|
||||||
if (aBlur !== bBlur) {
|
if (aBlur !== bBlur) {
|
||||||
@@ -195,7 +206,7 @@ export function sortThread(
|
|||||||
}
|
}
|
||||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||||
})
|
})
|
||||||
node.replies.forEach(reply => sortThread(reply, opts, modCache))
|
node.replies.forEach(reply => sortThread(reply, opts, modCache, currentDid))
|
||||||
}
|
}
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
|
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
|
||||||
{
|
{
|
||||||
hideReplies: false,
|
hideReplies: false,
|
||||||
hideRepliesByUnfollowed: true,
|
hideRepliesByUnfollowed: true, // Legacy, ignored
|
||||||
hideRepliesByLikeCount: 0,
|
hideRepliesByLikeCount: 0, // Legacy, ignored
|
||||||
hideReposts: false,
|
hideReposts: false,
|
||||||
hideQuotePosts: false,
|
hideQuotePosts: false,
|
||||||
lab_mergeFeedEnabled: false, // experimental
|
lab_mergeFeedEnabled: false, // experimental
|
||||||
|
|||||||
@@ -343,6 +343,21 @@ export function useRemoveMutedWordMutation() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRemoveMutedWordsMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
|
||||||
|
await agent.removeMutedWords(mutedWords)
|
||||||
|
// triggers a refetch
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: preferencesQueryKey,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useQueueNudgesMutation() {
|
export function useQueueNudgesMutation() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {AppBskyFeedGetActorFeeds} from '@atproto/api'
|
import {AppBskyFeedGetActorFeeds, moderateFeedGenerator} from '@atproto/api'
|
||||||
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
|
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
import {useModerationOpts} from '../preferences/moderation-opts'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
type RQPageParam = string | undefined
|
type RQPageParam = string | undefined
|
||||||
@@ -14,7 +15,8 @@ export function useProfileFeedgensQuery(
|
|||||||
did: string,
|
did: string,
|
||||||
opts?: {enabled?: boolean},
|
opts?: {enabled?: boolean},
|
||||||
) {
|
) {
|
||||||
const enabled = opts?.enabled !== false
|
const moderationOpts = useModerationOpts()
|
||||||
|
const enabled = opts?.enabled !== false && Boolean(moderationOpts)
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useInfiniteQuery<
|
return useInfiniteQuery<
|
||||||
AppBskyFeedGetActorFeeds.OutputSchema,
|
AppBskyFeedGetActorFeeds.OutputSchema,
|
||||||
@@ -38,5 +40,21 @@ export function useProfileFeedgensQuery(
|
|||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: lastPage => lastPage.cursor,
|
getNextPageParam: lastPage => lastPage.cursor,
|
||||||
enabled,
|
enabled,
|
||||||
|
select(data) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
pages: data.pages.map(page => {
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
feeds: page.feeds
|
||||||
|
// filter by labels
|
||||||
|
.filter(list => {
|
||||||
|
const decision = moderateFeedGenerator(list, moderationOpts!)
|
||||||
|
return !decision.ui('contentList').filter
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {AppBskyGraphGetLists} from '@atproto/api'
|
import {AppBskyGraphGetLists, moderateUserList} from '@atproto/api'
|
||||||
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
|
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
import {useModerationOpts} from '../preferences/moderation-opts'
|
||||||
|
|
||||||
const PAGE_SIZE = 30
|
const PAGE_SIZE = 30
|
||||||
type RQPageParam = string | undefined
|
type RQPageParam = string | undefined
|
||||||
@@ -10,7 +11,8 @@ const RQKEY_ROOT = 'profile-lists'
|
|||||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||||
|
|
||||||
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
||||||
const enabled = opts?.enabled !== false
|
const moderationOpts = useModerationOpts()
|
||||||
|
const enabled = opts?.enabled !== false && Boolean(moderationOpts)
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useInfiniteQuery<
|
return useInfiniteQuery<
|
||||||
AppBskyGraphGetLists.OutputSchema,
|
AppBskyGraphGetLists.OutputSchema,
|
||||||
@@ -27,17 +29,32 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
|||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Starter packs use a reference list, which we do not want to show on profiles. At some point we could probably
|
return res.data
|
||||||
// just filter this out on the backend instead of in the client.
|
|
||||||
return {
|
|
||||||
...res.data,
|
|
||||||
lists: res.data.lists.filter(
|
|
||||||
l => l.purpose !== 'app.bsky.graph.defs#referencelist',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: lastPage => lastPage.cursor,
|
getNextPageParam: lastPage => lastPage.cursor,
|
||||||
enabled,
|
enabled,
|
||||||
|
select(data) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
pages: data.pages.map(page => {
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
lists: page.lists
|
||||||
|
/*
|
||||||
|
* Starter packs use a reference list, which we do not want to
|
||||||
|
* show on profiles. At some point we could probably just filter
|
||||||
|
* this out on the backend instead of in the client.
|
||||||
|
*/
|
||||||
|
.filter(l => l.purpose !== 'app.bsky.graph.defs#referencelist')
|
||||||
|
// filter by labels
|
||||||
|
.filter(list => {
|
||||||
|
const decision = moderateUserList(list, moderationOpts!)
|
||||||
|
return !decision.ui('contentList').filter
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||