Squashed commit of the following:

commit af5262682e
Author: Minseo Lee <itoupluk427@gmail.com>
Date:   Thu Aug 8 21:12:23 2024 +0900

    Added trans (#4890)

commit a864f69849
Author: dan <dan.abramov@gmail.com>
Date:   Thu Aug 8 06:20:24 2024 +0100

    Keep interstitial fresh on refresh (#4888)

commit 00fea10782
Author: dan <dan.abramov@gmail.com>
Date:   Thu Aug 8 05:56:22 2024 +0100

    Include popcluster in suggestion ranking (#4887)

commit b3092413dd
Author: 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)

commit 1b02f81cb8
Author: 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>

commit fff2c079c2
Author: 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>

commit b701e8c68c
Author: 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>

commit 753a233408
Author: 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)

commit 5845e08eee
Author: dan <dan.abramov@gmail.com>
Date:   Tue Aug 6 17:12:27 2024 +0100

    Show own replies before follows' replies in threads (#4882)

commit b291a1ed8a
Author: dan <dan.abramov@gmail.com>
Date:   Tue Aug 6 16:42:42 2024 +0100

    Show more replies in Following (different heuristic) (#4880)

commit 686d5ebb53
Author: 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

commit 966f6c511f
Author: 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

commit 5bf7f3769d
Author: 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

commit 74b0318d89
Author: 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

commit 18b423396b
Author: Hailey <me@haileyok.com>
Date:   Mon Aug 5 12:21:34 2024 -0700

    Add `PlatformInfo` module (#4877)

commit fb278384c6
Author: bnewbold <bnewbold@robocracy.org>
Date:   Fri Aug 2 15:57:50 2024 -0700

    bskyweb: optional basic auth password middleware (#4759)

commit 6298e6897f
Author: 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>

commit c3d8beee6d
Author: 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

commit 293ac6fab2
Author: 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

commit 7f292abf51
Author: 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

commit f056cb646e
Author: 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

commit c78e9e3147
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Thu Aug 1 19:14:32 2024 +0200

    Move theme controls to its own screen (#4866)

commit 388c157c36
Author: 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)

commit b0e130a4d8
Author: 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>

commit d2e88cc623
Author: 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

commit 70ffd387e3
Author: 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

commit 576cef88b5
Author: 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

commit c75bb65bef
Author: dan <dan.abramov@gmail.com>
Date:   Wed Jul 31 13:00:22 2024 +0100

    Remove unused NoopFeedTuner (#4856)

commit c3e77b56ff
Author: GSMT <samaritanojr006@gmail.com>
Date:   Wed Jul 31 00:19:23 2024 +0200

    useDedupe callback (#4855)

commit 8ddb28d3c5
Author: 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
This commit is contained in:
Minseo Lee
2024-08-08 22:19:44 +09:00
152 changed files with 4308 additions and 1996 deletions
@@ -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

+7
View File
@@ -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"},
},
}, },
}, },
} }
+15
View File
@@ -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)
+13
View File
@@ -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"
] ]
} }
} }
+3 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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';
+9
View File
@@ -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}
+6 -4
View File
@@ -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(
+10 -2
View File
@@ -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}
+35 -21
View File
@@ -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',
},
])
} }
}) })
}}> }}>
+25 -11
View File
@@ -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 (
+253 -60
View File
@@ -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>
</> </>
) )
} }
+1 -1
View File
@@ -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>
+69
View File
@@ -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],
)
}
+17
View File
@@ -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',
})
+9
View File
@@ -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',
})
+5
View File
@@ -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',
})
+17
View File
@@ -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',
})
+5
View File
@@ -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',
})
+8
View File
@@ -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',
}) })
+15 -6
View File
@@ -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} />
</> </>
)}{' '} )}{' '}
+290 -224
View File
@@ -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)
} }
-12
View File
@@ -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
+4
View File
@@ -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[]
+2 -2
View File
@@ -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 }, [])
} }
+36
View File
@@ -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 {
+1
View File
@@ -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}
+6
View File
@@ -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'}
+1
View File
@@ -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 -3
View File
@@ -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()
+1
View File
@@ -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
+1
View File
@@ -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
+135
View File
@@ -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>
)
}
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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()
+3 -2
View File
@@ -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])
-67
View File
@@ -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)
})
+52 -63
View File
@@ -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)
} }
} }
+148
View File
@@ -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
}
}
}
-167
View File
@@ -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(),
})
}
}
+47 -3
View File
@@ -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
}
} }
-44
View File
@@ -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()})
}
}
+12
View File
@@ -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>
}
+6 -3
View File
@@ -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 (
+2 -2
View File
@@ -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])
+2 -2
View File
@@ -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])
+8 -20
View File
@@ -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
} }
+2 -2
View File
@@ -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])
+2 -2
View File
@@ -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])
+4
View File
@@ -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>
+2 -2
View File
@@ -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])
+2 -2
View File
@@ -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])
+6 -3
View File
@@ -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 (
+42
View File
@@ -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)
+6 -3
View File
@@ -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 (
+32 -19
View File
@@ -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
})
},
}) })
} }
+42 -19
View File
@@ -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])
+64 -88
View File
@@ -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
+12 -1
View File
@@ -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
} }
+2 -2
View File
@@ -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
+15
View File
@@ -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()
+20 -2
View File
@@ -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
}),
}
}),
}
},
}) })
} }
+27 -10
View File
@@ -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
}),
}
}),
}
},
}) })
} }

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