3p moderation services [WIP] (#2550)
* Add modservice screen and profile-header-card * Drop the guidelines for now * Remove ununsed constants * Add label & label group descriptions * Not found state * Reorg, add icon * Subheader * Header * Complete header * Clean up * Add all groups * Fix scroll view * Dialogs side quest * Remove log * Add (WIP) debug mod page * Dialog solution * Add note * Clean up and reorganize localized moderation strings * Memoize * Add example * Add first ReportDialog screen * Report dialog step 2 * Submit * Integrate updates * Move moderation screen * Migrate buttons * Migrate everything * Rough sketch * Fix types * Update atoms values * Abstract ModerationServiceCard * Hook up data to settings page * Handle subscription * Rough enablement * Rough enablement * Some validation, fixes * More work on the mod debug screen * Hook up data * Update invalidation * Hook up data to ReportDialog * Fix native error * Refactor/rewrite the entire moderation-application system * Fix toggles * Add copyright and other option to report * Handle reports on profile vs content * Little cleanup * Get post hiding back in gear * Better loading flow on Mod screen * Clean up Mod screen * Clean up ProfileMod screen * Handle muting correctly * Update enablement on ProfileMod screen * Improve Moderation screen and dialog * Styling, handle disabled labelers * Rework list of labels on own content * Use moderateNotification() * ReportDialog updates * Fix button overflow * Simplify the ProfileModerationService ui * Mod screen design * Move moderation card from the profile header to a tab * Small tweaks to the moderation screen * Enable toggle on mod page * Add notifs to debugmod and dont filter notifs from followed users * Add moderator-service profile view * Wire up more of the modservice data to profiles * A bunch of speculative non-working UI * Cleanup: delete old code * Update ModerationDetailsDialog * Update ReportDialog * Update LabelsOnMe dialog * Handle ReportDialog load better * Rename LabelsOnMeDialog, fix close * Experiment to put labeling under a tab of a normal profile * Moderator variation of profile * Remove dead code and start moving toward latest modsdk * Remove a bunch of now-dead label strings * Update ModDebug to be a bit more intuitive and support custom labels * Minor ui tweaks * Improve consistency of display name blurring * Fix profile-card warning rendering * More debugmod UI tuning * Update to use new labeler semantics * Delete some dead code and do some refactoring * Update profile to pull from labeler definition * Implement new label config controls (wip) * Tweak ui * Implement preference controls on labelers * Rework label pref ui * Get moderation screen working * Add asyncstorage query persistence * Implement label handling * Small cleanup * Implement Likes dialog * Fix: remove text outside of text element * Cleanup * Fix likes dialog on mobile * Implement the label appeal flow * Get report flow working again with temporarily fixed report options * Update onboarding * Enforce limit of ten labeler subscriptions * Fix type errors * Fix lint errors * Improve types of RQ * Some work on Likes dialog, needs discussion * Bit of ReportDialog cleanup * Replace non-single-path SVG * Update nudity descriptions * Update to use new sdk updates * Add adult-content-enabled behavior to label config * Use the default setting of custom labels * Handle global moderation label prefs with the global settings * Fix missing postAuthor * Fix empty moderation page * Add mutewords control back to Mod screen * Tweak adult setting styles * Remove deprecated global labels * Handle underage users on mod screen * Adjust font sizes * Swap in RichText * Like button improvements * Tweaks to Labeler profile * Design tweaks for mod pref dialog * Add tertiary button color * Switch moderation UIs to tertiary color * Update mutewords and hiddenposts to use the new sdk * Add test-environment mod authority * Switch 'gore' to 'graphic-media' * Move nudity out of the adult content control * Remove focus styles from buttons - let the browser behavior handle it * Fixes to the adult content age-gating in moderaiton * Ditch tertiary button color, lighten secondary button * Fix some colors * Remove focused overrides from toggles * Liked by screen * Rework the moderationlabelpref * Fix optimistic like * Cleanup * Change how onboarding handles adult content enabled/disabled * Add special handling of the mod authorities * Tweaks * Update the default labeler avatar to a shield * Add route to go server * Avoid dups due to bad config * Fix attrs * Fix: dont try to detect link/label mismatches on post meta * Correctly show the label behavior when adult content is disabled * Readd the local hiddenPosts handling * WIP * Fix bad merge * Conten hider design tweaks * Fix text string breakage * Adjust source text in ContentHider * Fix link bug * Design tweaks to ContentHider and ModDetailsDialog * Adjust spacing of inform badges * Adjust spacing of embeds in posts * Style tweaks to post/profile alerts * Labels on me and dialog * Remove bad focus styles from post dropdown * Better spacing solution * Tune moderation UIs * Moderation UI tweaks for mobile * Move labelers query on Mod screen * Update to use new SDK appLabelers semantics * Implement report submission * Replace the report modal entirely with the report dialog * Add @ to mod details dialog handle * Bump SDK package * Remove silly type * Add to AWS build CI * Fix ToggleButton overflow * Clean up ModServiceCard, rename to LabelingServiceCard * Hackfix to translate gore labels to graphic-media * Tune content hider sizing on web desktop * Handle self labels * Fix spacing below text-only posts * Fix: send appeals to the right labeler * Give mod page links interactive states * Fix references * Remove focus handling * Remove remnant * Remove the like count from the subscribed labeler listing * Bump @atproto/api@0.11.1 * Remove extra @ * Fix: persist labels to local storage to reduce coverage gaps * update dipendencies * revert dipendencies * Add some explainers on how blocking affects labelers * Tweak copy * Fix underline color in header * Fix profile menu * Handle card overflow * Remove metrics from header * Mute 'account' not 'user' * Show metrics if self * Show the labels tab on logged out view * Fix bad merge * Use purple theming on labelers * Tighten space on LabelerCard * Set staleTime to 6hrs for labeler details * Memoize the memoizers * Drop staleTime to 60s * Move label defs into a context to reduce recomputes * Submit view tweaks * Move labeler fetch below auth * Mitigation: hardcode the bluesky moderation labeler name * Bump sdk * Add missing translated string Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> * Add missing translated string Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> * Hailey's fix for incorrect profile tabs Co-authored-by: Hailey <me@haileyok.com> * Feedback * Fix borders, add bottom space * Hailey's fix pt 2 Co-authored-by: Hailey <me@haileyok.com> * Fix post tabs * Integrate feedback pt 1 Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> * Integrate feedback pt 2 Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> * Integrate feedback pt 3 Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> * Integrate feedback pt 4 Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> * Integrate feedback pt 5 Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> * Integrate feedback pt 6 Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> * Integrate feedback pt 7 Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> * Integrate feedback pt 8 Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> * Format * Integrate new bday modal * Use public agent for getServices * Update casing --------- Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com> Co-authored-by: Hailey <me@haileyok.com>zio/stable
|
@ -3,8 +3,8 @@ on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- traffic-reduction
|
- 3p-moderators
|
||||||
- respect-optout-for-embeds
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||||
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
|
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M4.213 6.886c-.673-1.35.334-2.889 1.806-2.889H17.98c1.472 0 2.479 1.539 1.806 2.89l-5.982 11.997c-.74 1.484-2.87 1.484-3.61 0L4.213 6.886Z"/></svg>
|
After Width: | Height: | Size: 240 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 5a1 1 0 0 0 0 2h18a1 1 0 1 0 0-2H3Zm-1 7a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 286 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 263 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 6a1 1 0 0 1 .707.293l8 8a1 1 0 0 1-1.414 1.414L12 8.414l-7.293 7.293a1 1 0 0 1-1.414-1.414l8-8A1 1 0 0 1 12 6Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 256 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 4a8 8 0 0 0-6.32 12.906L16.906 5.68A7.962 7.962 0 0 0 12 4Zm6.32 3.094L7.094 18.32A8 8 0 0 0 18.32 7.094ZM2 12C2 6.477 6.477 2 12 2a9.972 9.972 0 0 1 7.071 2.929A9.972 9.972 0 0 1 22 12c0 5.523-4.477 10-10 10a9.972 9.972 0 0 1-7.071-2.929A9.972 9.972 0 0 1 2 12Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 409 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="M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 674 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z"/></svg>
|
After Width: | Height: | Size: 336 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="M16.734 5.091c-1.238-.276-2.708.047-4.022 1.38a1 1 0 0 1-1.424 0C9.974 5.137 8.504 4.814 7.266 5.09c-1.263.282-2.379 1.206-2.92 2.556C3.33 10.18 4.252 14.84 12 19.348c7.747-4.508 8.67-9.168 7.654-11.7-.541-1.351-1.657-2.275-2.92-2.557Zm4.777 1.812c1.604 4-.494 9.69-9.022 14.47a1 1 0 0 1-.978 0C2.983 16.592.885 10.902 2.49 6.902c.779-1.942 2.414-3.334 4.342-3.764 1.697-.378 3.552.003 5.169 1.286 1.617-1.283 3.472-1.664 5.17-1.286 1.927.43 3.562 1.822 4.34 3.764Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 608 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 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 410 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="M10.25 4a.75.75 0 0 0-.75.75V11a1 1 0 1 1-2 0V6.75a.75.75 0 0 0-1.5 0V14a6 6 0 0 0 12 0V9a2 2 0 0 0-2 2v1.5a1 1 0 0 1-.684.949l-.628.21A2.469 2.469 0 0 0 13 16a1 1 0 1 1-2 0 4.469 4.469 0 0 1 3-4.22V11c0-.703.181-1.364.5-1.938V5.75a.75.75 0 0 0-1.5 0V9a1 1 0 1 1-2 0V4.75a.75.75 0 0 0-.75-.75Zm2.316-.733A2.75 2.75 0 0 1 16.5 5.75v1.54c.463-.187.97-.29 1.5-.29h1a1 1 0 0 1 1 1v6a8 8 0 1 1-16 0V6.75a2.75 2.75 0 0 1 3.571-2.625 2.751 2.751 0 0 1 4.995-.858Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 599 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="M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 572 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="M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-7.293 7.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM3 6a1 1 0 0 1 1-1h5a1 1 0 0 1 0 2H5v12h12v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 342 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="M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 286 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.86 4.494a.995.995 0 0 0-1.72 0L4.14 16.502A.996.996 0 0 0 4.999 18h14.003a.996.996 0 0 0 .86-1.498L12.86 4.494ZM9.413 3.487c1.155-1.983 4.019-1.983 5.174 0l7.002 12.007C22.753 17.491 21.314 20 19.002 20H4.998c-2.312 0-3.751-2.509-2.587-4.506L9.413 3.487ZM12 8.019a1 1 0 0 1 1 1v2.994a1 1 0 1 1-2 0V9.02a1 1 0 0 1 1-1Z" clip-rule="evenodd"/><rect width="2.5" height="2.5" x="10.75" y="13.75" fill="#000" rx="1.25"/></svg>
|
After Width: | Height: | Size: 537 B |
|
@ -188,6 +188,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("/sys/debug", server.WebGeneric)
|
e.GET("/sys/debug", server.WebGeneric)
|
||||||
|
e.GET("/sys/debug-mod", server.WebGeneric)
|
||||||
e.GET("/sys/log", server.WebGeneric)
|
e.GET("/sys/log", server.WebGeneric)
|
||||||
e.GET("/support", server.WebGeneric)
|
e.GET("/support", server.WebGeneric)
|
||||||
e.GET("/support/privacy", server.WebGeneric)
|
e.GET("/support/privacy", server.WebGeneric)
|
||||||
|
@ -203,6 +204,7 @@ func serve(cctx *cli.Context) error {
|
||||||
e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
|
e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
|
||||||
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
|
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
|
||||||
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
|
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
|
||||||
|
e.GET("/profile/:handleOrDID/labeler/liked-by", server.WebGeneric)
|
||||||
|
|
||||||
// profile RSS feed (DID not handle)
|
// profile RSS feed (DID not handle)
|
||||||
e.GET("/profile/:ident/rss", server.WebProfileRSS)
|
e.GET("/profile/:ident/rss", server.WebProfileRSS)
|
||||||
|
|
|
@ -44,7 +44,7 @@
|
||||||
"update-extensions": "scripts/updateExtensions.sh"
|
"update-extensions": "scripts/updateExtensions.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.10.5",
|
"@atproto/api": "^0.11.2",
|
||||||
"@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",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
@ -76,7 +76,9 @@
|
||||||
"@segment/sovran-react-native": "^0.4.5",
|
"@segment/sovran-react-native": "^0.4.5",
|
||||||
"@sentry/react-native": "5.5.0",
|
"@sentry/react-native": "5.5.0",
|
||||||
"@tamagui/focus-scope": "^1.84.1",
|
"@tamagui/focus-scope": "^1.84.1",
|
||||||
|
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||||
"@tanstack/react-query": "^5.8.1",
|
"@tanstack/react-query": "^5.8.1",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.25.0",
|
||||||
"@tiptap/core": "^2.0.0-beta.220",
|
"@tiptap/core": "^2.0.0-beta.220",
|
||||||
"@tiptap/extension-document": "^2.0.0-beta.220",
|
"@tiptap/extension-document": "^2.0.0-beta.220",
|
||||||
"@tiptap/extension-hard-break": "^2.0.3",
|
"@tiptap/extension-hard-break": "^2.0.3",
|
||||||
|
|
|
@ -5,7 +5,7 @@ import React, {useState, useEffect} from 'react'
|
||||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||||
import * as SplashScreen from 'expo-splash-screen'
|
import * as SplashScreen from 'expo-splash-screen'
|
||||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||||
import {QueryClientProvider} from '@tanstack/react-query'
|
import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
|
||||||
import {
|
import {
|
||||||
SafeAreaProvider,
|
SafeAreaProvider,
|
||||||
initialWindowMetrics,
|
initialWindowMetrics,
|
||||||
|
@ -22,7 +22,11 @@ import {s} from 'lib/styles'
|
||||||
import {Shell} from 'view/shell'
|
import {Shell} from 'view/shell'
|
||||||
import * as notifications from 'lib/notifications/notifications'
|
import * as notifications from 'lib/notifications/notifications'
|
||||||
import * as Toast from 'view/com/util/Toast'
|
import * as Toast from 'view/com/util/Toast'
|
||||||
import {queryClient} from 'lib/react-query'
|
import {
|
||||||
|
queryClient,
|
||||||
|
asyncStoragePersister,
|
||||||
|
dehydrateOptions,
|
||||||
|
} from 'lib/react-query'
|
||||||
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
||||||
import {Provider as ShellStateProvider} from 'state/shell'
|
import {Provider as ShellStateProvider} from 'state/shell'
|
||||||
import {Provider as ModalStateProvider} from 'state/modals'
|
import {Provider as ModalStateProvider} from 'state/modals'
|
||||||
|
@ -33,6 +37,7 @@ import {Provider as InvitesStateProvider} from 'state/invites'
|
||||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||||
|
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||||
import I18nProvider from './locale/i18nProvider'
|
import I18nProvider from './locale/i18nProvider'
|
||||||
import {
|
import {
|
||||||
Provider as SessionProvider,
|
Provider as SessionProvider,
|
||||||
|
@ -79,6 +84,7 @@ function InnerApp() {
|
||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
key={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
<StatsigProvider>
|
<StatsigProvider>
|
||||||
|
<LabelDefsProvider>
|
||||||
<LoggedOutViewProvider>
|
<LoggedOutViewProvider>
|
||||||
<SelectedFeedProvider>
|
<SelectedFeedProvider>
|
||||||
<UnreadNotifsProvider>
|
<UnreadNotifsProvider>
|
||||||
|
@ -94,6 +100,7 @@ function InnerApp() {
|
||||||
</UnreadNotifsProvider>
|
</UnreadNotifsProvider>
|
||||||
</SelectedFeedProvider>
|
</SelectedFeedProvider>
|
||||||
</LoggedOutViewProvider>
|
</LoggedOutViewProvider>
|
||||||
|
</LabelDefsProvider>
|
||||||
</StatsigProvider>
|
</StatsigProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
</Splash>
|
</Splash>
|
||||||
|
@ -118,7 +125,9 @@ function App() {
|
||||||
* that is set up in the InnerApp component above.
|
* that is set up in the InnerApp component above.
|
||||||
*/
|
*/
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ShellStateProvider>
|
<ShellStateProvider>
|
||||||
<PrefsStateProvider>
|
<PrefsStateProvider>
|
||||||
|
@ -140,7 +149,7 @@ function App() {
|
||||||
</PrefsStateProvider>
|
</PrefsStateProvider>
|
||||||
</ShellStateProvider>
|
</ShellStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'lib/sentry' // must be near top
|
import 'lib/sentry' // must be near top
|
||||||
|
|
||||||
import React, {useState, useEffect} from 'react'
|
import React, {useState, useEffect} from 'react'
|
||||||
import {QueryClientProvider} from '@tanstack/react-query'
|
import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
|
||||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||||
|
|
||||||
|
@ -13,7 +13,11 @@ import {init as initPersistedState} from '#/state/persisted'
|
||||||
import {Shell} from 'view/shell/index'
|
import {Shell} from 'view/shell/index'
|
||||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||||
import {ThemeProvider} from 'lib/ThemeContext'
|
import {ThemeProvider} from 'lib/ThemeContext'
|
||||||
import {queryClient} from 'lib/react-query'
|
import {
|
||||||
|
queryClient,
|
||||||
|
asyncStoragePersister,
|
||||||
|
dehydrateOptions,
|
||||||
|
} from 'lib/react-query'
|
||||||
import {Provider as ShellStateProvider} from 'state/shell'
|
import {Provider as ShellStateProvider} from 'state/shell'
|
||||||
import {Provider as ModalStateProvider} from 'state/modals'
|
import {Provider as ModalStateProvider} from 'state/modals'
|
||||||
import {Provider as DialogStateProvider} from 'state/dialogs'
|
import {Provider as DialogStateProvider} from 'state/dialogs'
|
||||||
|
@ -23,6 +27,7 @@ import {Provider as InvitesStateProvider} from 'state/invites'
|
||||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||||
|
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||||
import I18nProvider from './locale/i18nProvider'
|
import I18nProvider from './locale/i18nProvider'
|
||||||
import {
|
import {
|
||||||
Provider as SessionProvider,
|
Provider as SessionProvider,
|
||||||
|
@ -56,6 +61,7 @@ function InnerApp() {
|
||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
key={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
<StatsigProvider>
|
<StatsigProvider>
|
||||||
|
<LabelDefsProvider>
|
||||||
<LoggedOutViewProvider>
|
<LoggedOutViewProvider>
|
||||||
<SelectedFeedProvider>
|
<SelectedFeedProvider>
|
||||||
<UnreadNotifsProvider>
|
<UnreadNotifsProvider>
|
||||||
|
@ -71,6 +77,7 @@ function InnerApp() {
|
||||||
</UnreadNotifsProvider>
|
</UnreadNotifsProvider>
|
||||||
</SelectedFeedProvider>
|
</SelectedFeedProvider>
|
||||||
</LoggedOutViewProvider>
|
</LoggedOutViewProvider>
|
||||||
|
</LabelDefsProvider>
|
||||||
</StatsigProvider>
|
</StatsigProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
</Alf>
|
</Alf>
|
||||||
|
@ -93,7 +100,9 @@ function App() {
|
||||||
* that is set up in the InnerApp component above.
|
* that is set up in the InnerApp component above.
|
||||||
*/
|
*/
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ShellStateProvider>
|
<ShellStateProvider>
|
||||||
<PrefsStateProvider>
|
<PrefsStateProvider>
|
||||||
|
@ -115,7 +124,7 @@ function App() {
|
||||||
</PrefsStateProvider>
|
</PrefsStateProvider>
|
||||||
</ShellStateProvider>
|
</ShellStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -46,7 +46,7 @@ import {SearchScreen} from './view/screens/Search'
|
||||||
import {FeedsScreen} from './view/screens/Feeds'
|
import {FeedsScreen} from './view/screens/Feeds'
|
||||||
import {NotificationsScreen} from './view/screens/Notifications'
|
import {NotificationsScreen} from './view/screens/Notifications'
|
||||||
import {ListsScreen} from './view/screens/Lists'
|
import {ListsScreen} from './view/screens/Lists'
|
||||||
import {ModerationScreen} from './view/screens/Moderation'
|
import {ModerationScreen} from '#/screens/Moderation'
|
||||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||||
import {NotFoundScreen} from './view/screens/NotFound'
|
import {NotFoundScreen} from './view/screens/NotFound'
|
||||||
import {SettingsScreen} from './view/screens/Settings'
|
import {SettingsScreen} from './view/screens/Settings'
|
||||||
|
@ -61,6 +61,7 @@ import {PostThreadScreen} from './view/screens/PostThread'
|
||||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||||
import {Storybook} from './view/screens/Storybook'
|
import {Storybook} from './view/screens/Storybook'
|
||||||
|
import {DebugModScreen} from './view/screens/DebugMod'
|
||||||
import {LogScreen} from './view/screens/Log'
|
import {LogScreen} from './view/screens/Log'
|
||||||
import {SupportScreen} from './view/screens/Support'
|
import {SupportScreen} from './view/screens/Support'
|
||||||
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
|
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
|
||||||
|
@ -78,6 +79,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||||
import HashtagScreen from '#/screens/Hashtag'
|
import HashtagScreen from '#/screens/Hashtag'
|
||||||
|
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||||
import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig'
|
import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig'
|
||||||
|
|
||||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||||
|
@ -198,11 +200,21 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||||
getComponent={() => ProfileFeedLikedByScreen}
|
getComponent={() => ProfileFeedLikedByScreen}
|
||||||
options={{title: title(msg`Liked by`)}}
|
options={{title: title(msg`Liked by`)}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="ProfileLabelerLikedBy"
|
||||||
|
getComponent={() => ProfileLabelerLikedByScreen}
|
||||||
|
options={{title: title(msg`Liked by`)}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Debug"
|
name="Debug"
|
||||||
getComponent={() => Storybook}
|
getComponent={() => Storybook}
|
||||||
options={{title: title(msg`Storybook`), requireAuth: true}}
|
options={{title: title(msg`Storybook`), requireAuth: true}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="DebugMod"
|
||||||
|
getComponent={() => DebugModScreen}
|
||||||
|
options={{title: title(msg`Moderation states`), requireAuth: true}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Log"
|
name="Log"
|
||||||
getComponent={() => LogScreen}
|
getComponent={() => LogScreen}
|
||||||
|
|
|
@ -50,6 +50,9 @@ export const atoms = {
|
||||||
h_full: {
|
h_full: {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
|
h_full_vh: web({
|
||||||
|
height: '100vh',
|
||||||
|
}),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Border radius
|
* Border radius
|
||||||
|
@ -243,6 +246,9 @@ export const atoms = {
|
||||||
font_normal: {
|
font_normal: {
|
||||||
fontWeight: tokens.fontWeight.normal,
|
fontWeight: tokens.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
font_semibold: {
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
font_bold: {
|
font_bold: {
|
||||||
fontWeight: tokens.fontWeight.semibold,
|
fontWeight: tokens.fontWeight.semibold,
|
||||||
},
|
},
|
||||||
|
@ -528,6 +534,10 @@ export const atoms = {
|
||||||
/*
|
/*
|
||||||
* Margin
|
* Margin
|
||||||
*/
|
*/
|
||||||
|
mx_auto: {
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginRight: 'auto',
|
||||||
|
},
|
||||||
m_2xs: {
|
m_2xs: {
|
||||||
margin: tokens.space._2xs,
|
margin: tokens.space._2xs,
|
||||||
},
|
},
|
||||||
|
|
|
@ -12,6 +12,9 @@ export const dimScale = generateScale(12, 100)
|
||||||
export const color = {
|
export const color = {
|
||||||
trueBlack: '#000000',
|
trueBlack: '#000000',
|
||||||
|
|
||||||
|
temp_purple: 'rgb(105 0 255)',
|
||||||
|
temp_purple_dark: 'rgb(83 0 202)',
|
||||||
|
|
||||||
gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
|
gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
|
||||||
gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
|
gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
|
||||||
gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
|
gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
|
||||||
|
|
|
@ -15,6 +15,7 @@ import LinearGradient from 'react-native-linear-gradient'
|
||||||
|
|
||||||
import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
|
import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
|
||||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||||
|
import {normalizeTextStyles} from '#/components/Typography'
|
||||||
|
|
||||||
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
|
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
|
||||||
export type ButtonColor =
|
export type ButtonColor =
|
||||||
|
@ -139,7 +140,7 @@ export function Button({
|
||||||
}))
|
}))
|
||||||
}, [setState])
|
}, [setState])
|
||||||
|
|
||||||
const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => {
|
const {baseStyles, hoverStyles} = React.useMemo(() => {
|
||||||
const baseStyles: ViewStyle[] = []
|
const baseStyles: ViewStyle[] = []
|
||||||
const hoverStyles: ViewStyle[] = []
|
const hoverStyles: ViewStyle[] = []
|
||||||
const light = t.name === 'light'
|
const light = t.name === 'light'
|
||||||
|
@ -191,14 +192,14 @@ export function Button({
|
||||||
if (variant === 'solid') {
|
if (variant === 'solid') {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
baseStyles.push({
|
baseStyles.push({
|
||||||
backgroundColor: t.palette.contrast_50,
|
backgroundColor: t.palette.contrast_25,
|
||||||
})
|
})
|
||||||
hoverStyles.push({
|
hoverStyles.push({
|
||||||
backgroundColor: t.palette.contrast_100,
|
backgroundColor: t.palette.contrast_50,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
baseStyles.push({
|
baseStyles.push({
|
||||||
backgroundColor: t.palette.contrast_200,
|
backgroundColor: t.palette.contrast_100,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (variant === 'outline') {
|
} else if (variant === 'outline') {
|
||||||
|
@ -308,12 +309,6 @@ export function Button({
|
||||||
return {
|
return {
|
||||||
baseStyles,
|
baseStyles,
|
||||||
hoverStyles,
|
hoverStyles,
|
||||||
focusStyles: [
|
|
||||||
...hoverStyles,
|
|
||||||
{
|
|
||||||
outline: 0,
|
|
||||||
} as ViewStyle,
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}, [t, variant, color, size, shape, disabled])
|
}, [t, variant, color, size, shape, disabled])
|
||||||
|
|
||||||
|
@ -376,10 +371,8 @@ export function Button({
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.justify_center,
|
a.justify_center,
|
||||||
a.justify_center,
|
|
||||||
flattenedBaseStyles,
|
flattenedBaseStyles,
|
||||||
...(state.hovered || state.pressed ? hoverStyles : []),
|
...(state.hovered || state.pressed ? hoverStyles : []),
|
||||||
...(state.focused ? focusStyles : []),
|
|
||||||
flatten(style),
|
flatten(style),
|
||||||
]}
|
]}
|
||||||
onPressIn={onPressIn}
|
onPressIn={onPressIn}
|
||||||
|
@ -398,7 +391,7 @@ export function Button({
|
||||||
]}>
|
]}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={
|
colors={
|
||||||
state.hovered || state.pressed || state.focused
|
state.hovered || state.pressed
|
||||||
? gradientHoverColors
|
? gradientHoverColors
|
||||||
: gradientColors
|
: gradientColors
|
||||||
}
|
}
|
||||||
|
@ -527,7 +520,14 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
|
||||||
const textStyles = useSharedButtonTextStyles()
|
const textStyles = useSharedButtonTextStyles()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
|
<Text
|
||||||
|
{...rest}
|
||||||
|
style={normalizeTextStyles([
|
||||||
|
a.font_bold,
|
||||||
|
a.text_center,
|
||||||
|
textStyles,
|
||||||
|
style,
|
||||||
|
])}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
|
|
|
@ -99,7 +99,7 @@ export function Outer({
|
||||||
style={[
|
style={[
|
||||||
web(a.fixed),
|
web(a.fixed),
|
||||||
a.inset_0,
|
a.inset_0,
|
||||||
{opacity: 0.5, backgroundColor: t.palette.black},
|
{opacity: 0.8, backgroundColor: t.palette.black},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import React from 'react'
|
||||||
|
import LinearGradient from 'react-native-linear-gradient'
|
||||||
|
|
||||||
|
import {atoms as a, tokens} from '#/alf'
|
||||||
|
|
||||||
|
export function GradientFill({
|
||||||
|
gradient,
|
||||||
|
}: {
|
||||||
|
gradient:
|
||||||
|
| typeof tokens.gradients.sky
|
||||||
|
| typeof tokens.gradients.midnight
|
||||||
|
| typeof tokens.gradients.sunrise
|
||||||
|
| typeof tokens.gradients.sunset
|
||||||
|
| typeof tokens.gradients.bonfire
|
||||||
|
| typeof tokens.gradients.summer
|
||||||
|
| typeof tokens.gradients.nordic
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradient.values.map(c => c[1])}
|
||||||
|
locations={gradient.values.map(c => c[0])}
|
||||||
|
start={{x: 0, y: 0}}
|
||||||
|
end={{x: 1, y: 1}}
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,182 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||||
|
|
||||||
|
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||||
|
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useLabelerInfoQuery} from '#/state/queries/labeler'
|
||||||
|
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||||
|
import {RichText} from '#/components/RichText'
|
||||||
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {pluralize} from '#/lib/strings/helpers'
|
||||||
|
|
||||||
|
type LabelingServiceProps = {
|
||||||
|
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Outer({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: React.PropsWithChildren<ViewStyleProp>) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_md,
|
||||||
|
a.w_full,
|
||||||
|
a.p_lg,
|
||||||
|
a.pr_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({avatar}: {avatar?: string}) {
|
||||||
|
return <UserAvatar type="labeler" size={40} avatar={avatar} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Title({value}: {value: string}) {
|
||||||
|
return <Text style={[a.text_md, a.font_bold]}>{value}</Text>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Description({value, handle}: {value?: string; handle: string}) {
|
||||||
|
return value ? (
|
||||||
|
<Text numberOfLines={2}>
|
||||||
|
<RichText value={value} style={[]} />
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text>
|
||||||
|
<Trans>By {sanitizeHandle(handle, '@')}</Trans>
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LikeCount({count}: {count: number}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.mt_sm,
|
||||||
|
a.text_sm,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
{fontWeight: '500'},
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
Liked by {count} {pluralize(count, 'user')}
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Content({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_md,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
]}>
|
||||||
|
<View style={[a.gap_xs, a.flex_1]}>{children}</View>
|
||||||
|
|
||||||
|
<ChevronRight size="md" style={[a.z_10, t.atoms.text_contrast_low]} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canonical view for a labeling service. Use this or compose your own.
|
||||||
|
*/
|
||||||
|
export function Default({
|
||||||
|
labeler,
|
||||||
|
style,
|
||||||
|
}: LabelingServiceProps & ViewStyleProp) {
|
||||||
|
return (
|
||||||
|
<Outer style={style}>
|
||||||
|
<Avatar />
|
||||||
|
<Content>
|
||||||
|
<Title
|
||||||
|
value={getLabelingServiceTitle({
|
||||||
|
displayName: labeler.creator.displayName,
|
||||||
|
handle: labeler.creator.handle,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Description
|
||||||
|
value={labeler.creator.description}
|
||||||
|
handle={labeler.creator.handle}
|
||||||
|
/>
|
||||||
|
{labeler.likeCount ? <LikeCount count={labeler.likeCount} /> : null}
|
||||||
|
</Content>
|
||||||
|
</Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Link({
|
||||||
|
children,
|
||||||
|
labeler,
|
||||||
|
}: LabelingServiceProps & Pick<LinkProps, 'children'>) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InternalLink
|
||||||
|
to={{
|
||||||
|
screen: 'Profile',
|
||||||
|
params: {
|
||||||
|
name: labeler.creator.handle,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
label={_(
|
||||||
|
msg`View the labeling service provided by @${labeler.creator.handle}`,
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</InternalLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO not finished yet
|
||||||
|
export function DefaultSkeleton() {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text>Loading</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Loader({
|
||||||
|
did,
|
||||||
|
loading: LoadingComponent = DefaultSkeleton,
|
||||||
|
error: ErrorComponent,
|
||||||
|
component: Component,
|
||||||
|
}: {
|
||||||
|
did: string
|
||||||
|
loading?: React.ComponentType<{}>
|
||||||
|
error?: React.ComponentType<{error: string}>
|
||||||
|
component: React.ComponentType<{
|
||||||
|
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||||
|
}>
|
||||||
|
}) {
|
||||||
|
const {isLoading, data, error} = useLabelerInfoQuery({did})
|
||||||
|
|
||||||
|
return isLoading ? (
|
||||||
|
LoadingComponent ? (
|
||||||
|
<LoadingComponent />
|
||||||
|
) : null
|
||||||
|
) : error || !data ? (
|
||||||
|
ErrorComponent ? (
|
||||||
|
<ErrorComponent error={error?.message || 'Unknown error'} />
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
<Component labeler={data} />
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,109 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {List} from '#/view/com/util/List'
|
||||||
|
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||||
|
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||||
|
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
||||||
|
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||||
|
import {ListFooter} from '#/components/Lists'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function LikedByList({uri}: {uri: string}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||||
|
const {
|
||||||
|
data: resolvedUri,
|
||||||
|
error: resolveError,
|
||||||
|
isFetching: isFetchingResolvedUri,
|
||||||
|
} = useResolveUriQuery(uri)
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isFetching,
|
||||||
|
isFetched,
|
||||||
|
isRefetching,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isError,
|
||||||
|
error: likedByError,
|
||||||
|
refetch,
|
||||||
|
} = useLikedByQuery(resolvedUri?.uri)
|
||||||
|
const likes = React.useMemo(() => {
|
||||||
|
if (data?.pages) {
|
||||||
|
return data.pages.flatMap(page => page.likes)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}, [data])
|
||||||
|
const initialNumToRender = useInitialNumToRender()
|
||||||
|
const error = resolveError || likedByError
|
||||||
|
|
||||||
|
const onRefresh = React.useCallback(async () => {
|
||||||
|
setIsPTRing(true)
|
||||||
|
try {
|
||||||
|
await refetch()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to refresh likes', {message: err})
|
||||||
|
}
|
||||||
|
setIsPTRing(false)
|
||||||
|
}, [refetch, setIsPTRing])
|
||||||
|
|
||||||
|
const onEndReached = React.useCallback(async () => {
|
||||||
|
if (isFetching || !hasNextPage || isError) return
|
||||||
|
try {
|
||||||
|
await fetchNextPage()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to load more likes', {message: err})
|
||||||
|
}
|
||||||
|
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
||||||
|
|
||||||
|
const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => {
|
||||||
|
return (
|
||||||
|
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (isFetchingResolvedUri || !isFetched) {
|
||||||
|
return (
|
||||||
|
<View style={[a.w_full, a.align_center, a.p_lg]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return likes.length ? (
|
||||||
|
<List
|
||||||
|
data={likes}
|
||||||
|
keyExtractor={item => item.actor.did}
|
||||||
|
refreshing={isPTRing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
onEndReachedThreshold={3}
|
||||||
|
renderItem={renderItem}
|
||||||
|
initialNumToRender={initialNumToRender}
|
||||||
|
ListFooterComponent={() => (
|
||||||
|
<ListFooter
|
||||||
|
isFetching={isFetching && !isRefetching}
|
||||||
|
isError={isError}
|
||||||
|
error={error ? error.toString() : undefined}
|
||||||
|
onRetry={fetchNextPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[a.p_lg]}>
|
||||||
|
<View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
|
||||||
|
<Text style={[a.text_md, a.leading_snug]}>
|
||||||
|
<Trans>
|
||||||
|
Nobody has liked this yet. Maybe you should be the first!
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
import React, {useMemo, useCallback} from 'react'
|
||||||
|
import {ActivityIndicator, FlatList, View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||||
|
|
||||||
|
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||||
|
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||||
|
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
|
||||||
|
interface LikesDialogProps {
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
uri: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LikesDialog(props: LikesDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={props.control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
|
||||||
|
<LikesDialogInner {...props} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LikesDialogInner({control, uri}: LikesDialogProps) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: resolvedUri,
|
||||||
|
error: resolveError,
|
||||||
|
isFetched: hasFetchedResolvedUri,
|
||||||
|
} = useResolveUriQuery(uri)
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isFetching: isFetchingLikedBy,
|
||||||
|
isFetched: hasFetchedLikedBy,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isError,
|
||||||
|
error: likedByError,
|
||||||
|
} = useLikedByQuery(resolvedUri?.uri)
|
||||||
|
|
||||||
|
const isLoading = !hasFetchedResolvedUri || !hasFetchedLikedBy
|
||||||
|
const likes = useMemo(() => {
|
||||||
|
if (data?.pages) {
|
||||||
|
return data.pages.flatMap(page => page.likes)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const onEndReached = useCallback(async () => {
|
||||||
|
if (isFetchingLikedBy || !hasNextPage || isError) return
|
||||||
|
try {
|
||||||
|
await fetchNextPage()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to load more likes', {message: err})
|
||||||
|
}
|
||||||
|
}, [isFetchingLikedBy, hasNextPage, isError, fetchNextPage])
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({item}: {item: GetLikes.Like}) => {
|
||||||
|
return (
|
||||||
|
<ProfileCardWithFollowBtn
|
||||||
|
key={item.actor.did}
|
||||||
|
profile={item.actor}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[control],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Inner label={_(msg`Users that have liked this content or profile`)}>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_lg]}>
|
||||||
|
<Trans>Liked by</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={{minHeight: 300}}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
) : resolveError || likedByError || !data ? (
|
||||||
|
<ErrorMessage message={cleanError(resolveError || likedByError)} />
|
||||||
|
) : likes.length === 0 ? (
|
||||||
|
<View style={[t.atoms.bg_contrast_50, a.px_md, a.py_xl, a.rounded_md]}>
|
||||||
|
<Text style={[a.text_center]}>
|
||||||
|
<Trans>
|
||||||
|
Nobody has liked this yet. Maybe you should be the first!
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={likes}
|
||||||
|
keyExtractor={item => item.actor.did}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
renderItem={renderItem}
|
||||||
|
initialNumToRender={15}
|
||||||
|
ListFooterComponent={
|
||||||
|
<ListFooterComponent isFetching={isFetchingNextPage} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.Inner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListFooterComponent({isFetching}: {isFetching: boolean}) {
|
||||||
|
if (isFetching) {
|
||||||
|
return (
|
||||||
|
<View style={a.pt_lg}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
|
@ -251,7 +251,7 @@ export function InlineLink({
|
||||||
onIn: onPressIn,
|
onIn: onPressIn,
|
||||||
onOut: onPressOut,
|
onOut: onPressOut,
|
||||||
} = useInteractionState()
|
} = useInteractionState()
|
||||||
const flattenedStyle = flatten(style)
|
const flattenedStyle = flatten(style) || {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
|
|
|
@ -33,7 +33,7 @@ export function ListFooter({
|
||||||
a.border_t,
|
a.border_t,
|
||||||
a.pb_lg,
|
a.pb_lg,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{height: 100},
|
{height: 180},
|
||||||
]}>
|
]}>
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
<Loader size="xl" />
|
<Loader size="xl" />
|
||||||
|
|
|
@ -223,7 +223,7 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
|
||||||
style={flatten([
|
style={flatten([
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.gap_sm,
|
a.gap_lg,
|
||||||
a.py_sm,
|
a.py_sm,
|
||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
{minHeight: 32, paddingHorizontal: 10},
|
{minHeight: 32, paddingHorizontal: 10},
|
||||||
|
|
|
@ -0,0 +1,183 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||||
|
|
||||||
|
import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions'
|
||||||
|
import {DMCA_LINK} from '#/components/ReportDialog/const'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
|
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonIcon,
|
||||||
|
ButtonText,
|
||||||
|
useButtonContext,
|
||||||
|
} from '#/components/Button'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||||
|
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
|
||||||
|
|
||||||
|
import {ReportDialogProps} from './types'
|
||||||
|
|
||||||
|
export function SelectReportOptionView({
|
||||||
|
...props
|
||||||
|
}: ReportDialogProps & {
|
||||||
|
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||||
|
onSelectReportOption: (reportOption: ReportOption) => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const allReportOptions = useReportOptions()
|
||||||
|
const reportOptions = allReportOptions[props.params.type]
|
||||||
|
|
||||||
|
const i18n = React.useMemo(() => {
|
||||||
|
let title = _(msg`Report this content`)
|
||||||
|
let description = _(msg`Why should this content be reviewed?`)
|
||||||
|
|
||||||
|
if (props.params.type === 'account') {
|
||||||
|
title = _(msg`Report this user`)
|
||||||
|
description = _(msg`Why should this user be reviewed?`)
|
||||||
|
} else if (props.params.type === 'post') {
|
||||||
|
title = _(msg`Report this post`)
|
||||||
|
description = _(msg`Why should this post be reviewed?`)
|
||||||
|
} else if (props.params.type === 'list') {
|
||||||
|
title = _(msg`Report this list`)
|
||||||
|
description = _(msg`Why should this list be reviewed?`)
|
||||||
|
} else if (props.params.type === 'feedgen') {
|
||||||
|
title = _(msg`Report this feed`)
|
||||||
|
description = _(msg`Why should this feed be reviewed?`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}
|
||||||
|
}, [_, props.params.type])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.gap_lg]}>
|
||||||
|
<View style={[a.justify_center, a.gap_sm]}>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold]}>{i18n.title}</Text>
|
||||||
|
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
|
{i18n.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}>
|
||||||
|
{reportOptions.map(reportOption => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={reportOption.reason}
|
||||||
|
label={_(msg`Create report for ${reportOption.title}`)}
|
||||||
|
onPress={() => props.onSelectReportOption(reportOption)}>
|
||||||
|
<ReportOptionButton
|
||||||
|
title={reportOption.title}
|
||||||
|
description={reportOption.description}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{(props.params.type === 'post' || props.params.type === 'account') && (
|
||||||
|
<View style={[a.pt_md, a.px_md]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.gap_lg,
|
||||||
|
a.p_md,
|
||||||
|
a.pl_lg,
|
||||||
|
a.rounded_md,
|
||||||
|
t.atoms.bg_contrast_900,
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
t.atoms.text_inverted,
|
||||||
|
a.italic,
|
||||||
|
a.leading_snug,
|
||||||
|
]}>
|
||||||
|
<Trans>Need to report a copyright violation?</Trans>
|
||||||
|
</Text>
|
||||||
|
<Link
|
||||||
|
to={DMCA_LINK}
|
||||||
|
label={_(msg`View details for reporting a copyright violation`)}
|
||||||
|
size="small"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary">
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>View details</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon position="right" icon={SquareArrowTopRight} />
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportOptionButton({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {hovered, pressed} = useButtonContext()
|
||||||
|
const interacted = hovered || pressed
|
||||||
|
|
||||||
|
const styles = React.useMemo(() => {
|
||||||
|
return {
|
||||||
|
interacted: {
|
||||||
|
backgroundColor: t.palette.contrast_50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.p_md,
|
||||||
|
a.rounded_md,
|
||||||
|
{paddingRight: 70},
|
||||||
|
interacted && styles.interacted,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
a.justify_center,
|
||||||
|
a.pr_md,
|
||||||
|
{left: 'auto'},
|
||||||
|
]}>
|
||||||
|
<ChevronRight
|
||||||
|
size="md"
|
||||||
|
fill={
|
||||||
|
hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,262 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||||
|
|
||||||
|
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||||
|
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme, native} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||||
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
|
||||||
|
import {ReportDialogProps} from './types'
|
||||||
|
import {getAgent} from '#/state/session'
|
||||||
|
|
||||||
|
export function SubmitView({
|
||||||
|
params,
|
||||||
|
labelers,
|
||||||
|
selectedReportOption,
|
||||||
|
goBack,
|
||||||
|
onSubmitComplete,
|
||||||
|
}: ReportDialogProps & {
|
||||||
|
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||||
|
selectedReportOption: ReportOption
|
||||||
|
goBack: () => void
|
||||||
|
onSubmitComplete: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const [details, setDetails] = React.useState<string>('')
|
||||||
|
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
||||||
|
const [selectedServices, setSelectedServices] = React.useState<string[]>(
|
||||||
|
labelers?.map(labeler => labeler.creator.did) || [],
|
||||||
|
)
|
||||||
|
const [error, setError] = React.useState('')
|
||||||
|
|
||||||
|
const submit = React.useCallback(async () => {
|
||||||
|
setSubmitting(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const $type =
|
||||||
|
params.type === 'account'
|
||||||
|
? 'com.atproto.admin.defs#repoRef'
|
||||||
|
: 'com.atproto.repo.strongRef'
|
||||||
|
const report = {
|
||||||
|
reasonType: selectedReportOption.reason,
|
||||||
|
subject: {
|
||||||
|
$type,
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
reason: details,
|
||||||
|
}
|
||||||
|
const results = await Promise.all(
|
||||||
|
selectedServices.map(did =>
|
||||||
|
getAgent()
|
||||||
|
.withProxy('atproto_labeler', did)
|
||||||
|
.createModerationReport(report)
|
||||||
|
.then(
|
||||||
|
_ => true,
|
||||||
|
_ => false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
setSubmitting(false)
|
||||||
|
|
||||||
|
if (results.includes(true)) {
|
||||||
|
Toast.show(_(msg`Thank you. Your report has been sent.`))
|
||||||
|
onSubmitComplete()
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
_(
|
||||||
|
msg`There was an issue sending your report. Please check your internet connection.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
_,
|
||||||
|
params,
|
||||||
|
details,
|
||||||
|
selectedReportOption,
|
||||||
|
selectedServices,
|
||||||
|
onSubmitComplete,
|
||||||
|
setError,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.gap_2xl]}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
label={_(msg`Go back to previous step`)}
|
||||||
|
onPress={goBack}>
|
||||||
|
<ButtonIcon icon={ChevronLeft} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.gap_lg,
|
||||||
|
a.p_md,
|
||||||
|
a.rounded_md,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
<Text style={[a.text_md, a.font_bold]}>
|
||||||
|
{selectedReportOption.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.leading_tight, {maxWidth: 400}]}>
|
||||||
|
{selectedReportOption.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.gap_md]}>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Select the moderation service(s) to report to</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Toggle.Group
|
||||||
|
label="Select mod services"
|
||||||
|
values={selectedServices}
|
||||||
|
onChange={setSelectedServices}>
|
||||||
|
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
|
||||||
|
{labelers.map(labeler => {
|
||||||
|
const title = getLabelingServiceTitle({
|
||||||
|
displayName: labeler.creator.displayName,
|
||||||
|
handle: labeler.creator.handle,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Toggle.Item
|
||||||
|
key={labeler.creator.did}
|
||||||
|
name={labeler.creator.did}
|
||||||
|
label={title}>
|
||||||
|
<LabelerToggle title={title} />
|
||||||
|
</Toggle.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</Toggle.Group>
|
||||||
|
</View>
|
||||||
|
<View style={[a.gap_md]}>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Optionally provide additional information below:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.relative, a.w_full]}>
|
||||||
|
<Dialog.Input
|
||||||
|
multiline
|
||||||
|
value={details}
|
||||||
|
onChangeText={setDetails}
|
||||||
|
label="Text field"
|
||||||
|
style={{paddingRight: 60}}
|
||||||
|
numberOfLines={6}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.pr_md,
|
||||||
|
a.pb_sm,
|
||||||
|
{
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<CharProgress count={details?.length || 0} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}>
|
||||||
|
{!selectedServices.length ||
|
||||||
|
(error && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.italic,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{error ? (
|
||||||
|
error
|
||||||
|
) : (
|
||||||
|
<Trans>You must select at least one labeler for a report</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="solid"
|
||||||
|
color="negative"
|
||||||
|
label={_(msg`Send report`)}
|
||||||
|
onPress={submit}
|
||||||
|
disabled={!selectedServices.length}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Send report</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{submitting && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LabelerToggle({title}: {title: string}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const ctx = Toggle.useItemContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_md,
|
||||||
|
a.p_md,
|
||||||
|
a.pr_lg,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.overflow_hidden,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
ctx.selected && [t.atoms.bg_contrast_50],
|
||||||
|
]}>
|
||||||
|
<Toggle.Checkbox />
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.gap_lg,
|
||||||
|
a.z_10,
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
native({marginTop: 2}),
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
ctx.selected && t.atoms.text,
|
||||||
|
]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View, Pressable} from 'react-native'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
||||||
|
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||||
|
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
import {ReportDialogProps} from './types'
|
||||||
|
import {SelectReportOptionView} from './SelectReportOptionView'
|
||||||
|
import {SubmitView} from './SubmitView'
|
||||||
|
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||||
|
|
||||||
|
export function ReportDialog(props: ReportDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={props.control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
|
||||||
|
<ReportDialogInner {...props} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportDialogInner(props: ReportDialogProps) {
|
||||||
|
const {
|
||||||
|
isLoading: isLabelerLoading,
|
||||||
|
data: labelers,
|
||||||
|
error,
|
||||||
|
} = useMyLabelersQuery()
|
||||||
|
const isLoading = useDelayedLoading(500, isLabelerLoading)
|
||||||
|
const [selectedReportOption, setSelectedReportOption] = React.useState<
|
||||||
|
ReportOption | undefined
|
||||||
|
>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner label="Report Dialog">
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={[a.align_center, {height: 100}]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
{/* Here to capture focus for a hot sec to prevent flash */}
|
||||||
|
<Pressable accessible={false} />
|
||||||
|
</View>
|
||||||
|
) : error || !labelers ? (
|
||||||
|
<View>
|
||||||
|
<Text style={[a.text_md]}>
|
||||||
|
<Trans>Something went wrong, please try again.</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : selectedReportOption ? (
|
||||||
|
<SubmitView
|
||||||
|
{...props}
|
||||||
|
labelers={labelers}
|
||||||
|
selectedReportOption={selectedReportOption}
|
||||||
|
goBack={() => setSelectedReportOption(undefined)}
|
||||||
|
onSubmitComplete={() => props.control.close()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SelectReportOptionView
|
||||||
|
{...props}
|
||||||
|
labelers={labelers}
|
||||||
|
onSelectReportOption={setSelectedReportOption}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
|
export type ReportDialogProps = {
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
params:
|
||||||
|
| {
|
||||||
|
type: 'post' | 'list' | 'feedgen' | 'other'
|
||||||
|
uri: string
|
||||||
|
cid: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'account'
|
||||||
|
did: string
|
||||||
|
}
|
||||||
|
}
|
|
@ -59,7 +59,7 @@ export function TagMenu({
|
||||||
const displayTag = '#' + tag
|
const displayTag = '#' + tag
|
||||||
|
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
(preferences?.mutedWords?.find(
|
(preferences?.moderationPrefs.mutedWords?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
) ??
|
) ??
|
||||||
optimisticUpsert?.find(
|
optimisticUpsert?.find(
|
||||||
|
|
|
@ -50,7 +50,7 @@ export function TagMenu({
|
||||||
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
||||||
useRemoveMutedWordMutation()
|
useRemoveMutedWordMutation()
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
(preferences?.mutedWords?.find(
|
(preferences?.moderationPrefs.mutedWords?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
) ??
|
) ??
|
||||||
optimisticUpsert?.find(
|
optimisticUpsert?.find(
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
|
import {
|
||||||
|
Text as RNText,
|
||||||
|
StyleProp,
|
||||||
|
TextStyle,
|
||||||
|
TextProps as RNTextProps,
|
||||||
|
} from 'react-native'
|
||||||
import {UITextView} from 'react-native-ui-text-view'
|
import {UITextView} from 'react-native-ui-text-view'
|
||||||
|
|
||||||
import {useTheme, atoms, web, flatten} from '#/alf'
|
import {useTheme, atoms, web, flatten} from '#/alf'
|
||||||
|
@ -34,7 +39,7 @@ export function leading<
|
||||||
* If the `lineHeight` value is > 2, we assume it's an absolute value and
|
* If the `lineHeight` value is > 2, we assume it's an absolute value and
|
||||||
* returns it as-is.
|
* returns it as-is.
|
||||||
*/
|
*/
|
||||||
function normalizeTextStyles(styles: TextStyle[]) {
|
export function normalizeTextStyles(styles: StyleProp<TextStyle>) {
|
||||||
const s = flatten(styles)
|
const s = flatten(styles)
|
||||||
// should always be defined on these components
|
// should always be defined on these components
|
||||||
const fontSize = s.fontSize || atoms.text_md.fontSize
|
const fontSize = s.fontSize || atoms.text_md.fontSize
|
||||||
|
|
|
@ -18,7 +18,7 @@ export function useGlobalDialogsControlContext() {
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const mutedWordsDialogControl = Dialog.useDialogControl()
|
const mutedWordsDialogControl = Dialog.useDialogControl()
|
||||||
const ctx = React.useMemo(
|
const ctx = React.useMemo<ControlsContext>(
|
||||||
() => ({mutedWordsDialogControl}),
|
() => ({mutedWordsDialogControl}),
|
||||||
[mutedWordsDialogControl],
|
[mutedWordsDialogControl],
|
||||||
)
|
)
|
||||||
|
|
|
@ -233,8 +233,8 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : preferences.mutedWords.length ? (
|
) : preferences.moderationPrefs.mutedWords.length ? (
|
||||||
[...preferences.mutedWords]
|
[...preferences.moderationPrefs.mutedWords]
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((word, i) => (
|
.map((word, i) => (
|
||||||
<MutedWordRow
|
<MutedWordRow
|
||||||
|
|
|
@ -2,7 +2,14 @@ import React from 'react'
|
||||||
import {Pressable, View, ViewStyle} from 'react-native'
|
import {Pressable, View, ViewStyle} from 'react-native'
|
||||||
|
|
||||||
import {HITSLOP_10} from 'lib/constants'
|
import {HITSLOP_10} from 'lib/constants'
|
||||||
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
|
import {
|
||||||
|
useTheme,
|
||||||
|
atoms as a,
|
||||||
|
native,
|
||||||
|
flatten,
|
||||||
|
ViewStyleProp,
|
||||||
|
TextStyleProp,
|
||||||
|
} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
|
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
|
||||||
|
@ -220,20 +227,17 @@ export function Item({
|
||||||
onPressOut={onPressOut}
|
onPressOut={onPressOut}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
style={[
|
style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}>
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.gap_sm,
|
|
||||||
focused ? web({outline: 'none'}) : {},
|
|
||||||
flatten(style),
|
|
||||||
]}>
|
|
||||||
{typeof children === 'function' ? children(state) : children}
|
{typeof children === 'function' ? children(state) : children}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ItemContext.Provider>
|
</ItemContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Label({children}: React.PropsWithChildren<{}>) {
|
export function Label({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: React.PropsWithChildren<TextStyleProp>) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {disabled} = useItemContext()
|
const {disabled} = useItemContext()
|
||||||
return (
|
return (
|
||||||
|
@ -242,11 +246,14 @@ export function Label({children}: React.PropsWithChildren<{}>) {
|
||||||
a.font_bold,
|
a.font_bold,
|
||||||
{
|
{
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
color: disabled ? t.palette.contrast_400 : t.palette.contrast_600,
|
color: disabled
|
||||||
|
? t.atoms.text_contrast_low.color
|
||||||
|
: t.atoms.text_contrast_high.color,
|
||||||
},
|
},
|
||||||
native({
|
native({
|
||||||
paddingTop: 3,
|
paddingTop: 3,
|
||||||
}),
|
}),
|
||||||
|
flatten(style),
|
||||||
]}>
|
]}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -257,7 +264,6 @@ export function Label({children}: React.PropsWithChildren<{}>) {
|
||||||
export function createSharedToggleStyles({
|
export function createSharedToggleStyles({
|
||||||
theme: t,
|
theme: t,
|
||||||
hovered,
|
hovered,
|
||||||
focused,
|
|
||||||
selected,
|
selected,
|
||||||
disabled,
|
disabled,
|
||||||
isInvalid,
|
isInvalid,
|
||||||
|
@ -280,7 +286,7 @@ export function createSharedToggleStyles({
|
||||||
borderColor: t.palette.primary_500,
|
borderColor: t.palette.primary_500,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (hovered || focused) {
|
if (hovered) {
|
||||||
baseHover.push({
|
baseHover.push({
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
|
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
|
||||||
|
@ -289,7 +295,7 @@ export function createSharedToggleStyles({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (hovered || focused) {
|
if (hovered) {
|
||||||
baseHover.push({
|
baseHover.push({
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
|
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
|
||||||
|
@ -306,7 +312,7 @@ export function createSharedToggleStyles({
|
||||||
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
|
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (hovered || focused) {
|
if (hovered) {
|
||||||
baseHover.push({
|
baseHover.push({
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||||
|
@ -353,7 +359,7 @@ export function Checkbox() {
|
||||||
width: 20,
|
width: 20,
|
||||||
},
|
},
|
||||||
baseStyles,
|
baseStyles,
|
||||||
hovered || focused ? baseHoverStyles : {},
|
hovered ? baseHoverStyles : {},
|
||||||
]}>
|
]}>
|
||||||
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
|
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
|
||||||
</View>
|
</View>
|
||||||
|
@ -385,7 +391,7 @@ export function Switch() {
|
||||||
width: 30,
|
width: 30,
|
||||||
},
|
},
|
||||||
baseStyles,
|
baseStyles,
|
||||||
hovered || focused ? baseHoverStyles : {},
|
hovered ? baseHoverStyles : {},
|
||||||
]}>
|
]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
@ -437,7 +443,7 @@ export function Radio() {
|
||||||
width: 20,
|
width: 20,
|
||||||
},
|
},
|
||||||
baseStyles,
|
baseStyles,
|
||||||
hovered || focused ? baseHoverStyles : {},
|
hovered ? baseHoverStyles : {},
|
||||||
]}>
|
]}>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<View
|
<View
|
||||||
|
|
|
@ -8,7 +8,9 @@ import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
|
||||||
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
|
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
|
||||||
AccessibilityProps &
|
AccessibilityProps &
|
||||||
React.PropsWithChildren<{testID?: string}>
|
React.PropsWithChildren<{
|
||||||
|
testID?: string
|
||||||
|
}>
|
||||||
|
|
||||||
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
|
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
|
||||||
multiple?: boolean
|
multiple?: boolean
|
||||||
|
@ -101,12 +103,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
|
||||||
native({
|
native({
|
||||||
paddingBottom: 10,
|
paddingBottom: 10,
|
||||||
}),
|
}),
|
||||||
a.px_sm,
|
a.px_md,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
baseStyles,
|
baseStyles,
|
||||||
activeStyles,
|
activeStyles,
|
||||||
(state.hovered || state.focused || state.pressed) && hoverStyles,
|
(state.hovered || state.pressed) && hoverStyles,
|
||||||
]}>
|
]}>
|
||||||
{typeof children === 'string' ? (
|
{typeof children === 'string' ? (
|
||||||
<Text
|
<Text
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export function useDelayedLoading(delay: number, initialState: boolean = true) {
|
||||||
|
const [isLoading, setIsLoading] = React.useState(initialState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let timeout: NodeJS.Timeout
|
||||||
|
// on initial load, show a loading spinner for a hot sec to prevent flash
|
||||||
|
if (isLoading) timeout = setTimeout(() => setIsLoading(false), delay)
|
||||||
|
|
||||||
|
return () => timeout && clearTimeout(timeout)
|
||||||
|
}, [isLoading, delay])
|
||||||
|
|
||||||
|
return isLoading
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const ArrowTriangleBottom_Stroke2_Corner1_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4.213 6.886c-.673-1.35.334-2.889 1.806-2.889H17.98c1.472 0 2.479 1.539 1.806 2.89l-5.982 11.997c-.74 1.484-2.87 1.484-3.61 0L4.213 6.886Z',
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Bars3_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M3 5a1 1 0 0 0 0 2h18a1 1 0 1 0 0-2H3Zm-1 7a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z',
|
||||||
|
})
|
|
@ -7,3 +7,11 @@ export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z',
|
path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const ChevronTop_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M12 6a1 1 0 0 1 .707.293l8 8a1 1 0 0 1-1.414 1.414L12 8.414l-7.293 7.293a1 1 0 0 1-1.414-1.414l8-8A1 1 0 0 1 12 6Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z',
|
||||||
|
})
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const CircleBanSign_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M12 4a8 8 0 0 0-6.32 12.906L16.906 5.68A7.962 7.962 0 0 0 12 4Zm6.32 3.094L7.094 18.32A8 8 0 0 0 18.32 7.094ZM2 12C2 6.477 6.477 2 12 2a9.972 9.972 0 0 1 7.071 2.929A9.972 9.972 0 0 1 22 12c0 5.523-4.477 10-10 10a9.972 9.972 0 0 1-7.071-2.929A9.972 9.972 0 0 1 2 12Z',
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const RaisingHande4Finger_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M10.25 4a.75.75 0 0 0-.75.75V11a1 1 0 1 1-2 0V6.75a.75.75 0 0 0-1.5 0V14a6 6 0 0 0 12 0V9a2 2 0 0 0-2 2v1.5a1 1 0 0 1-.684.949l-.628.21A2.469 2.469 0 0 0 13 16a1 1 0 1 1-2 0 4.469 4.469 0 0 1 3-4.22V11c0-.703.181-1.364.5-1.938V5.75a.75.75 0 0 0-1.5 0V9a1 1 0 1 1-2 0V4.75a.75.75 0 0 0-.75-.75Zm2.316-.733A2.75 2.75 0 0 1 16.5 5.75v1.54c.463-.187.97-.29 1.5-.29h1a1 1 0 0 1 1 1v6a8 8 0 1 1-16 0V6.75a2.75 2.75 0 0 1 3.571-2.625 2.751 2.751 0 0 1 4.995-.858Z',
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Shield_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z',
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const SquareArrowTopRight_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-7.293 7.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM3 6a1 1 0 0 1 1-1h5a1 1 0 0 1 0 2H5v12h12v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6Z',
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
|
||||||
|
})
|
|
@ -0,0 +1,182 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||||
|
import {ModerationUI} from '@atproto/api'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||||
|
import {isJustAMute} from '#/lib/moderation'
|
||||||
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme, useBreakpoints, web} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {
|
||||||
|
ModerationDetailsDialog,
|
||||||
|
useModerationDetailsDialogControl,
|
||||||
|
} from '#/components/moderation/ModerationDetailsDialog'
|
||||||
|
|
||||||
|
export function ContentHider({
|
||||||
|
testID,
|
||||||
|
modui,
|
||||||
|
ignoreMute,
|
||||||
|
style,
|
||||||
|
childContainerStyle,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
testID?: string
|
||||||
|
modui: ModerationUI | undefined
|
||||||
|
ignoreMute?: boolean
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
childContainerStyle?: StyleProp<ViewStyle>
|
||||||
|
}>) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const [override, setOverride] = React.useState(false)
|
||||||
|
const control = useModerationDetailsDialogControl()
|
||||||
|
|
||||||
|
const blur = modui?.blurs[0]
|
||||||
|
const desc = useModerationCauseDescription(blur)
|
||||||
|
|
||||||
|
if (!blur || (ignoreMute && isJustAMute(modui))) {
|
||||||
|
return (
|
||||||
|
<View testID={testID} style={[styles.outer, style]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View testID={testID} style={[a.overflow_hidden, style]}>
|
||||||
|
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
if (!modui.noOverride) {
|
||||||
|
setOverride(v => !v)
|
||||||
|
} else {
|
||||||
|
control.open()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={desc.name}
|
||||||
|
accessibilityHint={
|
||||||
|
modui.noOverride
|
||||||
|
? _(msg`Learn more about the moderation applied to this content.`)
|
||||||
|
: override
|
||||||
|
? _(msg`Hide the content`)
|
||||||
|
: _(msg`Show the content`)
|
||||||
|
}>
|
||||||
|
{state => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.w_full,
|
||||||
|
a.justify_start,
|
||||||
|
a.align_center,
|
||||||
|
a.py_md,
|
||||||
|
a.px_lg,
|
||||||
|
a.gap_xs,
|
||||||
|
a.rounded_sm,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
gtMobile && [a.gap_sm, a.py_lg, a.mt_xs, a.px_xl],
|
||||||
|
(state.hovered || state.pressed) && t.atoms.bg_contrast_50,
|
||||||
|
]}>
|
||||||
|
<desc.icon
|
||||||
|
size="md"
|
||||||
|
fill={t.atoms.text_contrast_medium.color}
|
||||||
|
style={{marginLeft: -2}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_left,
|
||||||
|
a.font_bold,
|
||||||
|
a.leading_snug,
|
||||||
|
gtMobile && [a.font_semibold],
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
web({
|
||||||
|
marginBottom: 1,
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
{desc.name}
|
||||||
|
</Text>
|
||||||
|
{!modui.noOverride && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
a.leading_snug,
|
||||||
|
gtMobile && [a.font_semibold],
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
web({
|
||||||
|
marginBottom: 1,
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
{override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{desc.source && blur.type === 'label' && !override && (
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
control.open()
|
||||||
|
}}
|
||||||
|
label={_(
|
||||||
|
msg`Learn more about the moderation applied to this content.`,
|
||||||
|
)}
|
||||||
|
style={[a.pt_sm]}>
|
||||||
|
{state => (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_normal,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.text_left,
|
||||||
|
]}>
|
||||||
|
{desc.sourceType === 'user' ? (
|
||||||
|
<Trans>Labeled by the author.</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Labeled by {sanitizeDisplayName(desc.source!)}.</Trans>
|
||||||
|
)}{' '}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
{color: t.palette.primary_500},
|
||||||
|
a.text_sm,
|
||||||
|
state.hovered && [web({textDecoration: 'underline'})],
|
||||||
|
]}>
|
||||||
|
<Trans>Learn more.</Trans>
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{override && <View style={childContainerStyle}>{children}</View>}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
outer: {
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
cover: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingLeft: 14,
|
||||||
|
paddingRight: 18,
|
||||||
|
},
|
||||||
|
showBtn: {
|
||||||
|
marginLeft: 'auto',
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
})
|
|
@ -0,0 +1,93 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||||
|
import {
|
||||||
|
usePreferencesQuery,
|
||||||
|
usePreferencesSetContentLabelMutation,
|
||||||
|
} from '#/state/queries/preferences'
|
||||||
|
|
||||||
|
import {useTheme, atoms as a} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||||
|
|
||||||
|
export function GlobalModerationLabelPref({
|
||||||
|
labelValueDefinition,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
labelValueDefinition: InterpretedLabelValueDefinition
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const {identifier} = labelValueDefinition
|
||||||
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||||
|
const savedPref = preferences?.moderationPrefs.labels[identifier]
|
||||||
|
const pref = variables?.visibility ?? savedPref ?? 'warn'
|
||||||
|
|
||||||
|
const allLabelStrings = useGlobalLabelStrings()
|
||||||
|
const labelStrings =
|
||||||
|
labelValueDefinition.identifier in allLabelStrings
|
||||||
|
? allLabelStrings[labelValueDefinition.identifier]
|
||||||
|
: {
|
||||||
|
name: labelValueDefinition.identifier,
|
||||||
|
description: `Labeled "${labelValueDefinition.identifier}"`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelOptions = {
|
||||||
|
hide: _(msg`Hide`),
|
||||||
|
warn: _(msg`Warn`),
|
||||||
|
ignore: _(msg`Show`),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_between,
|
||||||
|
a.gap_sm,
|
||||||
|
a.py_md,
|
||||||
|
a.pl_lg,
|
||||||
|
a.pr_md,
|
||||||
|
a.align_center,
|
||||||
|
]}>
|
||||||
|
<View style={[a.gap_xs, a.flex_1]}>
|
||||||
|
<Text style={[a.font_bold]}>{labelStrings.name}</Text>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||||
|
{labelStrings.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[a.justify_center, {minHeight: 35}]}>
|
||||||
|
{!disabled && (
|
||||||
|
<ToggleButton.Group
|
||||||
|
label={_(
|
||||||
|
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
|
||||||
|
)}
|
||||||
|
values={[pref]}
|
||||||
|
onChange={newPref =>
|
||||||
|
mutate({
|
||||||
|
label: identifier,
|
||||||
|
visibility: newPref[0] as LabelPreference,
|
||||||
|
labelerDid: undefined,
|
||||||
|
})
|
||||||
|
}>
|
||||||
|
<ToggleButton.Button name="ignore" label={labelOptions.ignore}>
|
||||||
|
{labelOptions.ignore}
|
||||||
|
</ToggleButton.Button>
|
||||||
|
<ToggleButton.Button name="warn" label={labelOptions.warn}>
|
||||||
|
{labelOptions.warn}
|
||||||
|
</ToggleButton.Button>
|
||||||
|
<ToggleButton.Button name="hide" label={labelOptions.hide}>
|
||||||
|
{labelOptions.hide}
|
||||||
|
</ToggleButton.Button>
|
||||||
|
</ToggleButton.Group>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||||
|
import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button'
|
||||||
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
|
import {
|
||||||
|
LabelsOnMeDialog,
|
||||||
|
useLabelsOnMeDialogControl,
|
||||||
|
} from '#/components/moderation/LabelsOnMeDialog'
|
||||||
|
|
||||||
|
export function LabelsOnMe({
|
||||||
|
details,
|
||||||
|
labels,
|
||||||
|
size,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
details: {did: string} | {uri: string; cid: string}
|
||||||
|
labels: ComAtprotoLabelDefs.Label[] | undefined
|
||||||
|
size?: ButtonSize
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const isAccount = 'did' in details
|
||||||
|
const control = useLabelsOnMeDialogControl()
|
||||||
|
|
||||||
|
if (!labels || !currentAccount) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
labels = labels.filter(
|
||||||
|
l => !l.val.startsWith('!') && l.src !== currentAccount.did,
|
||||||
|
)
|
||||||
|
if (!labels.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelTarget = isAccount ? _(msg`account`) : _(msg`content`)
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_row, style]}>
|
||||||
|
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size={size || 'small'}
|
||||||
|
label={_(msg`View information about these labels`)}
|
||||||
|
onPress={() => {
|
||||||
|
control.open()
|
||||||
|
}}>
|
||||||
|
<ButtonIcon position="left" icon={CircleInfo} />
|
||||||
|
<ButtonText style={[a.leading_snug]}>
|
||||||
|
{labels.length}{' '}
|
||||||
|
{labels.length === 1 ? (
|
||||||
|
<Trans>label has been placed on this {labelTarget}</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>labels have been placed on this {labelTarget}</Trans>
|
||||||
|
)}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabelsOnMyPost({
|
||||||
|
post,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
post: AppBskyFeedDefs.PostView
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
if (post.author.did !== currentAccount?.did) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} />
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,262 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api'
|
||||||
|
|
||||||
|
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||||
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {getAgent} from '#/state/session'
|
||||||
|
|
||||||
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {InlineLink} from '#/components/Link'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {Divider} from '../Divider'
|
||||||
|
|
||||||
|
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
||||||
|
|
||||||
|
type Subject =
|
||||||
|
| {
|
||||||
|
uri: string
|
||||||
|
cid: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
did: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabelsOnMeDialogProps {
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
subject: Subject
|
||||||
|
labels: ComAtprotoLabelDefs.Label[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const [appealingLabel, setAppealingLabel] = React.useState<
|
||||||
|
ComAtprotoLabelDefs.Label | undefined
|
||||||
|
>(undefined)
|
||||||
|
const {subject, labels} = props
|
||||||
|
const isAccount = 'did' in subject
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={
|
||||||
|
isAccount
|
||||||
|
? _(msg`The following labels were applied to your account.`)
|
||||||
|
: _(msg`The following labels were applied to your content.`)
|
||||||
|
}>
|
||||||
|
{appealingLabel ? (
|
||||||
|
<AppealForm
|
||||||
|
label={appealingLabel}
|
||||||
|
subject={subject}
|
||||||
|
control={props.control}
|
||||||
|
onPressBack={() => setAppealingLabel(undefined)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
|
||||||
|
{isAccount ? (
|
||||||
|
<Trans>Labels on your account</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Labels on your content</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_snug]}>
|
||||||
|
<Trans>
|
||||||
|
You may appeal these labels if you feel they were placed in error.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.py_lg, a.gap_md]}>
|
||||||
|
{labels.map(label => (
|
||||||
|
<Label
|
||||||
|
key={`${label.val}-${label.src}`}
|
||||||
|
label={label}
|
||||||
|
control={props.control}
|
||||||
|
onPressAppeal={label => setAppealingLabel(label)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={props.control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
|
||||||
|
<LabelsOnMeDialogInner {...props} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
label,
|
||||||
|
control,
|
||||||
|
onPressAppeal,
|
||||||
|
}: {
|
||||||
|
label: ComAtprotoLabelDefs.Label
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {labeler, strings} = useLabelInfo(label)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.overflow_hidden,
|
||||||
|
]}>
|
||||||
|
<View style={[a.p_md, a.gap_sm, a.flex_row]}>
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
<Text style={[a.font_bold, a.text_md]}>{strings.name}</Text>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||||
|
{strings.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
label={_(msg`Appeal`)}
|
||||||
|
onPress={() => onPressAppeal(label)}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Appeal</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Source:</Trans>{' '}
|
||||||
|
<InlineLink
|
||||||
|
to={makeProfileLink(
|
||||||
|
labeler ? labeler.creator : {did: label.src, handle: ''},
|
||||||
|
)}
|
||||||
|
onPress={() => control.close()}>
|
||||||
|
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
|
||||||
|
</InlineLink>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppealForm({
|
||||||
|
label,
|
||||||
|
subject,
|
||||||
|
control,
|
||||||
|
onPressBack,
|
||||||
|
}: {
|
||||||
|
label: ComAtprotoLabelDefs.Label
|
||||||
|
subject: Subject
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
onPressBack: () => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {labeler, strings} = useLabelInfo(label)
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const [details, setDetails] = React.useState('')
|
||||||
|
const isAccountReport = 'did' in subject
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const $type = !isAccountReport
|
||||||
|
? 'com.atproto.repo.strongRef'
|
||||||
|
: 'com.atproto.admin.defs#repoRef'
|
||||||
|
await getAgent()
|
||||||
|
.withProxy('atproto_labeler', label.src)
|
||||||
|
.createModerationReport({
|
||||||
|
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||||
|
subject: {
|
||||||
|
$type,
|
||||||
|
...subject,
|
||||||
|
},
|
||||||
|
reason: details,
|
||||||
|
})
|
||||||
|
Toast.show(_(msg`Appeal submitted.`))
|
||||||
|
} finally {
|
||||||
|
control.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
|
||||||
|
<Trans>Appeal "{strings.name}" label</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_snug]}>
|
||||||
|
<Trans>
|
||||||
|
This appeal will be sent to{' '}
|
||||||
|
<InlineLink
|
||||||
|
to={makeProfileLink(
|
||||||
|
labeler ? labeler.creator : {did: label.src, handle: ''},
|
||||||
|
)}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
style={[a.text_md, a.leading_snug]}>
|
||||||
|
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
|
||||||
|
</InlineLink>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.my_md]}>
|
||||||
|
<Dialog.Input
|
||||||
|
label={_(msg`Text input field`)}
|
||||||
|
placeholder={_(
|
||||||
|
msg`Please explain why you think this label was incorrectly applied by ${
|
||||||
|
labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src
|
||||||
|
}`,
|
||||||
|
)}
|
||||||
|
value={details}
|
||||||
|
onChangeText={setDetails}
|
||||||
|
autoFocus={true}
|
||||||
|
numberOfLines={3}
|
||||||
|
multiline
|
||||||
|
maxLength={300}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={
|
||||||
|
gtMobile
|
||||||
|
? [a.flex_row, a.justify_between]
|
||||||
|
: [{flexDirection: 'column-reverse'}, a.gap_sm]
|
||||||
|
}>
|
||||||
|
<Button
|
||||||
|
testID="backBtn"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onPressBack}
|
||||||
|
label={_(msg`Back`)}>
|
||||||
|
{_(msg`Back`)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
testID="submitBtn"
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onSubmit}
|
||||||
|
label={_(msg`Submit`)}>
|
||||||
|
{_(msg`Submit`)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,148 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {ModerationCause} from '@atproto/api'
|
||||||
|
|
||||||
|
import {listUriToHref} from '#/lib/strings/url-helpers'
|
||||||
|
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||||
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
|
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {useTheme, atoms as a} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {InlineLink} from '#/components/Link'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
|
||||||
|
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
|
||||||
|
|
||||||
|
export interface ModerationDetailsDialogProps {
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
modcause: ModerationCause
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={props.control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<ModerationDetailsDialogInner {...props} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModerationDetailsDialogInner({
|
||||||
|
modcause,
|
||||||
|
control,
|
||||||
|
}: ModerationDetailsDialogProps & {
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const desc = useModerationCauseDescription(modcause)
|
||||||
|
|
||||||
|
let name
|
||||||
|
let description
|
||||||
|
if (!modcause) {
|
||||||
|
name = _(msg`Content Warning`)
|
||||||
|
description = _(
|
||||||
|
msg`Moderator has chosen to set a general warning on the content.`,
|
||||||
|
)
|
||||||
|
} else if (modcause.type === 'blocking') {
|
||||||
|
if (modcause.source.type === 'list') {
|
||||||
|
const list = modcause.source.list
|
||||||
|
name = _(msg`User Blocked by List`)
|
||||||
|
description = (
|
||||||
|
<Trans>
|
||||||
|
This user is included in the{' '}
|
||||||
|
<InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}>
|
||||||
|
{list.name}
|
||||||
|
</InlineLink>{' '}
|
||||||
|
list which you have blocked.
|
||||||
|
</Trans>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
name = _(msg`User Blocked`)
|
||||||
|
description = _(
|
||||||
|
msg`You have blocked this user. You cannot view their content.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (modcause.type === 'blocked-by') {
|
||||||
|
name = _(msg`User Blocks You`)
|
||||||
|
description = _(
|
||||||
|
msg`This user has blocked you. You cannot view their content.`,
|
||||||
|
)
|
||||||
|
} else if (modcause.type === 'block-other') {
|
||||||
|
name = _(msg`Content Not Available`)
|
||||||
|
description = _(
|
||||||
|
msg`This content is not available because one of the users involved has blocked the other.`,
|
||||||
|
)
|
||||||
|
} else if (modcause.type === 'muted') {
|
||||||
|
if (modcause.source.type === 'list') {
|
||||||
|
const list = modcause.source.list
|
||||||
|
name = _(msg`Account Muted by List`)
|
||||||
|
description = (
|
||||||
|
<Trans>
|
||||||
|
This user is included in the{' '}
|
||||||
|
<InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}>
|
||||||
|
{list.name}
|
||||||
|
</InlineLink>{' '}
|
||||||
|
list which you have muted.
|
||||||
|
</Trans>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
name = _(msg`Account Muted`)
|
||||||
|
description = _(msg`You have muted this account.`)
|
||||||
|
}
|
||||||
|
} else if (modcause.type === 'mute-word') {
|
||||||
|
name = _(msg`Post Hidden by Muted Word`)
|
||||||
|
description = _(msg`You've chosen to hide a word or tag within this post.`)
|
||||||
|
} else if (modcause.type === 'hidden') {
|
||||||
|
name = _(msg`Post Hidden by You`)
|
||||||
|
description = _(msg`You have hidden this post.`)
|
||||||
|
} else if (modcause.type === 'label') {
|
||||||
|
name = desc.name
|
||||||
|
description = desc.description
|
||||||
|
} else {
|
||||||
|
// should never happen
|
||||||
|
name = ''
|
||||||
|
description = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner label={_(msg`Moderation details`)}>
|
||||||
|
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{modcause.type === 'label' && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
|
||||||
|
<Trans>
|
||||||
|
This label was applied by{' '}
|
||||||
|
{modcause.source.type === 'user' ? (
|
||||||
|
<Trans>the author</Trans>
|
||||||
|
) : (
|
||||||
|
<InlineLink
|
||||||
|
to={makeProfileLink({did: modcause.label.src, handle: ''})}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
style={a.text_md}>
|
||||||
|
{desc.source}
|
||||||
|
</InlineLink>
|
||||||
|
)}
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isNative && <View style={{height: 40}} />}
|
||||||
|
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,154 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||||
|
import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
|
||||||
|
import {
|
||||||
|
usePreferencesQuery,
|
||||||
|
usePreferencesSetContentLabelMutation,
|
||||||
|
} from '#/state/queries/preferences'
|
||||||
|
import {getLabelStrings} from '#/lib/moderation/useLabelInfo'
|
||||||
|
|
||||||
|
import {useTheme, atoms as a} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {InlineLink} from '#/components/Link'
|
||||||
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo'
|
||||||
|
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||||
|
|
||||||
|
export function ModerationLabelPref({
|
||||||
|
labelValueDefinition,
|
||||||
|
labelerDid,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
labelValueDefinition: InterpretedLabelValueDefinition
|
||||||
|
labelerDid: string | undefined
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const {_, i18n} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const isGlobalLabel = !labelValueDefinition.definedBy
|
||||||
|
const {identifier} = labelValueDefinition
|
||||||
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||||
|
const savedPref =
|
||||||
|
labelerDid && !isGlobalLabel
|
||||||
|
? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid)
|
||||||
|
?.labels[identifier]
|
||||||
|
: preferences?.moderationPrefs.labels[identifier]
|
||||||
|
const pref =
|
||||||
|
variables?.visibility ??
|
||||||
|
savedPref ??
|
||||||
|
labelValueDefinition.defaultSetting ??
|
||||||
|
'warn'
|
||||||
|
|
||||||
|
// does the 'warn' setting make sense for this label?
|
||||||
|
const canWarn = !(
|
||||||
|
labelValueDefinition.blurs === 'none' &&
|
||||||
|
labelValueDefinition.severity === 'none'
|
||||||
|
)
|
||||||
|
// is this label adult only?
|
||||||
|
const adultOnly = labelValueDefinition.flags.includes('adult')
|
||||||
|
// is this label disabled because it's adult only?
|
||||||
|
const adultDisabled =
|
||||||
|
adultOnly && !preferences?.moderationPrefs.adultContentEnabled
|
||||||
|
// are there any reasons we cant configure this label here?
|
||||||
|
const cantConfigure = isGlobalLabel || adultDisabled
|
||||||
|
|
||||||
|
// adjust the pref based on whether warn is available
|
||||||
|
let prefAdjusted = pref
|
||||||
|
if (adultDisabled) {
|
||||||
|
prefAdjusted = 'hide'
|
||||||
|
} else if (!canWarn && pref === 'warn') {
|
||||||
|
prefAdjusted = 'ignore'
|
||||||
|
}
|
||||||
|
|
||||||
|
// grab localized descriptions of the label and its settings
|
||||||
|
const currentPrefLabel = useLabelBehaviorDescription(
|
||||||
|
labelValueDefinition,
|
||||||
|
prefAdjusted,
|
||||||
|
)
|
||||||
|
const hideLabel = useLabelBehaviorDescription(labelValueDefinition, 'hide')
|
||||||
|
const warnLabel = useLabelBehaviorDescription(labelValueDefinition, 'warn')
|
||||||
|
const ignoreLabel = useLabelBehaviorDescription(
|
||||||
|
labelValueDefinition,
|
||||||
|
'ignore',
|
||||||
|
)
|
||||||
|
const globalLabelStrings = useGlobalLabelStrings()
|
||||||
|
const labelStrings = getLabelStrings(
|
||||||
|
i18n.locale,
|
||||||
|
globalLabelStrings,
|
||||||
|
labelValueDefinition,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_row, a.gap_sm, a.px_lg, a.py_lg, a.justify_between]}>
|
||||||
|
<View style={[a.gap_xs, a.flex_1]}>
|
||||||
|
<Text style={[a.font_bold]}>{labelStrings.name}</Text>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||||
|
{labelStrings.description}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{cantConfigure && (
|
||||||
|
<View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}>
|
||||||
|
<CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} />
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}>
|
||||||
|
{adultDisabled ? (
|
||||||
|
<Trans>Adult content is disabled.</Trans>
|
||||||
|
) : isGlobalLabel ? (
|
||||||
|
<Trans>
|
||||||
|
Configured in{' '}
|
||||||
|
<InlineLink to="/moderation" style={a.text_sm}>
|
||||||
|
moderation settings
|
||||||
|
</InlineLink>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{disabled ? (
|
||||||
|
<></>
|
||||||
|
) : cantConfigure ? (
|
||||||
|
<View style={[{minHeight: 35}, a.px_sm, a.py_md]}>
|
||||||
|
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
{currentPrefLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[{minHeight: 35}]}>
|
||||||
|
<ToggleButton.Group
|
||||||
|
label={_(
|
||||||
|
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
|
||||||
|
)}
|
||||||
|
values={[prefAdjusted]}
|
||||||
|
onChange={newPref =>
|
||||||
|
mutate({
|
||||||
|
label: identifier,
|
||||||
|
visibility: newPref[0] as LabelPreference,
|
||||||
|
labelerDid,
|
||||||
|
})
|
||||||
|
}>
|
||||||
|
<ToggleButton.Button name="ignore" label={ignoreLabel}>
|
||||||
|
{ignoreLabel}
|
||||||
|
</ToggleButton.Button>
|
||||||
|
{canWarn && (
|
||||||
|
<ToggleButton.Button name="warn" label={warnLabel}>
|
||||||
|
{warnLabel}
|
||||||
|
</ToggleButton.Button>
|
||||||
|
)}
|
||||||
|
<ToggleButton.Button name="hide" label={hideLabel}>
|
||||||
|
{hideLabel}
|
||||||
|
</ToggleButton.Button>
|
||||||
|
</ToggleButton.Group>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||||
|
import {ModerationUI, ModerationCause} from '@atproto/api'
|
||||||
|
|
||||||
|
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||||
|
import {getModerationCauseKey} from '#/lib/moderation'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||||
|
import {
|
||||||
|
ModerationDetailsDialog,
|
||||||
|
useModerationDetailsDialogControl,
|
||||||
|
} from '#/components/moderation/ModerationDetailsDialog'
|
||||||
|
|
||||||
|
export function PostAlerts({
|
||||||
|
modui,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
modui: ModerationUI
|
||||||
|
includeMute?: boolean
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
if (!modui.alert && !modui.inform) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_col, a.gap_xs, style]}>
|
||||||
|
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
|
||||||
|
{modui.alerts.map(cause => (
|
||||||
|
<PostLabel key={getModerationCauseKey(cause)} cause={cause} />
|
||||||
|
))}
|
||||||
|
{modui.informs.map(cause => (
|
||||||
|
<PostLabel key={getModerationCauseKey(cause)} cause={cause} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostLabel({cause}: {cause: ModerationCause}) {
|
||||||
|
const control = useModerationDetailsDialogControl()
|
||||||
|
const desc = useModerationCauseDescription(cause)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
label={desc.name}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
shape="default"
|
||||||
|
onPress={() => {
|
||||||
|
control.open()
|
||||||
|
}}
|
||||||
|
style={[a.px_sm, a.py_xs, a.gap_xs]}>
|
||||||
|
<ButtonIcon icon={desc.icon} position="left" />
|
||||||
|
<ButtonText style={[a.text_left, a.leading_snug]}>
|
||||||
|
{desc.name}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,45 +1,50 @@
|
||||||
import React, {ComponentProps} from 'react'
|
import React, {ComponentProps} from 'react'
|
||||||
import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
|
import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
|
||||||
import {ModerationUI} from '@atproto/api'
|
import {ModerationUI} from '@atproto/api'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
|
||||||
import {Link} from '../Link'
|
|
||||||
import {Text} from '../text/Text'
|
|
||||||
import {addStyle} from 'lib/styles'
|
|
||||||
import {describeModerationCause} from 'lib/moderation'
|
|
||||||
import {ShieldExclamation} from 'lib/icons'
|
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Trans, msg} from '@lingui/macro'
|
import {Trans, msg} from '@lingui/macro'
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
|
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||||
|
import {addStyle} from 'lib/styles'
|
||||||
|
|
||||||
|
import {useTheme, atoms as a} from '#/alf'
|
||||||
|
import {
|
||||||
|
ModerationDetailsDialog,
|
||||||
|
useModerationDetailsDialogControl,
|
||||||
|
} from '#/components/moderation/ModerationDetailsDialog'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
|
||||||
|
import {Link} from '#/view/com/util/Link'
|
||||||
|
|
||||||
interface Props extends ComponentProps<typeof Link> {
|
interface Props extends ComponentProps<typeof Link> {
|
||||||
iconSize: number
|
iconSize: number
|
||||||
iconStyles: StyleProp<ViewStyle>
|
iconStyles: StyleProp<ViewStyle>
|
||||||
moderation: ModerationUI
|
modui: ModerationUI
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostHider({
|
export function PostHider({
|
||||||
testID,
|
testID,
|
||||||
href,
|
href,
|
||||||
moderation,
|
modui,
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
iconSize,
|
iconSize,
|
||||||
iconStyles,
|
iconStyles,
|
||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const [override, setOverride] = React.useState(false)
|
const [override, setOverride] = React.useState(false)
|
||||||
const {openModal} = useModalControls()
|
const control = useModerationDetailsDialogControl()
|
||||||
|
const blur = modui.blurs[0]
|
||||||
|
const desc = useModerationCauseDescription(blur)
|
||||||
|
|
||||||
if (!moderation.blur) {
|
if (!blur) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
testID={testID}
|
testID={testID}
|
||||||
style={style}
|
style={style}
|
||||||
href={href}
|
href={href}
|
||||||
noFeedback
|
|
||||||
accessible={false}
|
accessible={false}
|
||||||
{...props}>
|
{...props}>
|
||||||
{children}
|
{children}
|
||||||
|
@ -47,12 +52,10 @@ export function PostHider({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '')
|
|
||||||
const desc = describeModerationCause(moderation.cause, 'content')
|
|
||||||
return !override ? (
|
return !override ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!moderation.noOverride) {
|
if (!modui.noOverride) {
|
||||||
setOverride(v => !v)
|
setOverride(v => !v)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -62,49 +65,45 @@ export function PostHider({
|
||||||
}
|
}
|
||||||
accessibilityLabel=""
|
accessibilityLabel=""
|
||||||
style={[
|
style={[
|
||||||
styles.description,
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
a.py_md,
|
||||||
|
{
|
||||||
|
paddingLeft: 6,
|
||||||
|
paddingRight: 18,
|
||||||
|
},
|
||||||
override ? {paddingBottom: 0} : undefined,
|
override ? {paddingBottom: 0} : undefined,
|
||||||
pal.view,
|
t.atoms.bg,
|
||||||
]}>
|
]}>
|
||||||
|
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
openModal({
|
control.open()
|
||||||
name: 'moderation-details',
|
|
||||||
context: 'content',
|
|
||||||
moderation,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={_(msg`Learn more about this warning`)}
|
accessibilityLabel={_(msg`Learn more about this warning`)}
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
pal.viewLight,
|
t.atoms.bg_contrast_25,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
{
|
{
|
||||||
width: iconSize,
|
width: iconSize,
|
||||||
height: iconSize,
|
height: iconSize,
|
||||||
borderRadius: iconSize,
|
borderRadius: iconSize,
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
},
|
||||||
iconStyles,
|
iconStyles,
|
||||||
]}>
|
]}>
|
||||||
{isMute ? (
|
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={['far', 'eye-slash']}
|
|
||||||
size={14}
|
|
||||||
color={pal.colors.textLight}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ShieldExclamation size={14} style={pal.textLight} />
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text type="sm" style={[{flex: 1}, pal.textLight]} numberOfLines={1}>
|
<Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}>
|
||||||
{desc.name}
|
{desc.name}
|
||||||
</Text>
|
</Text>
|
||||||
{!moderation.noOverride && (
|
{!modui.noOverride && (
|
||||||
<Text type="sm" style={[styles.showBtn, pal.link]}>
|
<Text style={[{color: t.palette.primary_500}]}>
|
||||||
{override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
|
{override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
@ -114,26 +113,14 @@ export function PostHider({
|
||||||
testID={testID}
|
testID={testID}
|
||||||
style={addStyle(style, styles.child)}
|
style={addStyle(style, styles.child)}
|
||||||
href={href}
|
href={href}
|
||||||
noFeedback>
|
accessible={false}
|
||||||
|
{...props}>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
description: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingLeft: 6,
|
|
||||||
paddingRight: 18,
|
|
||||||
marginTop: 1,
|
|
||||||
},
|
|
||||||
showBtn: {
|
|
||||||
marginLeft: 'auto',
|
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
|
||||||
child: {
|
child: {
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
borderTopWidth: 0,
|
borderTopWidth: 0,
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||||
|
import {ModerationCause, ModerationDecision} from '@atproto/api'
|
||||||
|
|
||||||
|
import {getModerationCauseKey} from 'lib/moderation'
|
||||||
|
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||||
|
import {
|
||||||
|
ModerationDetailsDialog,
|
||||||
|
useModerationDetailsDialogControl,
|
||||||
|
} from '#/components/moderation/ModerationDetailsDialog'
|
||||||
|
|
||||||
|
export function ProfileHeaderAlerts({
|
||||||
|
moderation,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
moderation: ModerationDecision
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
const modui = moderation.ui('profileView')
|
||||||
|
if (!modui.alert && !modui.inform) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_col, a.gap_xs, style]}>
|
||||||
|
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
|
||||||
|
{modui.alerts.map(cause => (
|
||||||
|
<ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
|
||||||
|
))}
|
||||||
|
{modui.informs.map(cause => (
|
||||||
|
<ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileLabel({cause}: {cause: ModerationCause}) {
|
||||||
|
const control = useModerationDetailsDialogControl()
|
||||||
|
const desc = useModerationCauseDescription(cause)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
label={desc.name}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
shape="default"
|
||||||
|
onPress={() => {
|
||||||
|
control.open()
|
||||||
|
}}
|
||||||
|
style={[a.px_sm, a.py_xs, a.gap_xs]}>
|
||||||
|
<ButtonIcon icon={desc.icon} position="left" />
|
||||||
|
<ButtonText style={[a.text_left, a.leading_snug]}>
|
||||||
|
{desc.name}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,171 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
TouchableWithoutFeedback,
|
||||||
|
StyleProp,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {ModerationUI} from '@atproto/api'
|
||||||
|
import {Trans, msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||||
|
|
||||||
|
import {useTheme, atoms as a} from '#/alf'
|
||||||
|
import {CenteredView} from '#/view/com/util/Views'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {
|
||||||
|
ModerationDetailsDialog,
|
||||||
|
useModerationDetailsDialogControl,
|
||||||
|
} from '#/components/moderation/ModerationDetailsDialog'
|
||||||
|
|
||||||
|
export function ScreenHider({
|
||||||
|
testID,
|
||||||
|
screenDescription,
|
||||||
|
modui,
|
||||||
|
style,
|
||||||
|
containerStyle,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
testID?: string
|
||||||
|
screenDescription: string
|
||||||
|
modui: ModerationUI
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
containerStyle?: StyleProp<ViewStyle>
|
||||||
|
}>) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const [override, setOverride] = React.useState(false)
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
const control = useModerationDetailsDialogControl()
|
||||||
|
const blur = modui.blurs[0]
|
||||||
|
const desc = useModerationCauseDescription(blur)
|
||||||
|
|
||||||
|
if (!blur || override) {
|
||||||
|
return (
|
||||||
|
<View testID={testID} style={style}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNoPwi = !!modui.blurs.find(
|
||||||
|
cause =>
|
||||||
|
cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated',
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<CenteredView
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
{
|
||||||
|
paddingTop: 100,
|
||||||
|
paddingBottom: 150,
|
||||||
|
},
|
||||||
|
t.atoms.bg,
|
||||||
|
containerStyle,
|
||||||
|
]}
|
||||||
|
sideBorders>
|
||||||
|
<View style={[a.align_center, a.mb_md]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.bg_contrast_975,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
|
borderRadius: 25,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<desc.icon width={24} fill={t.atoms.bg.backgroundColor} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_4xl,
|
||||||
|
a.font_semibold,
|
||||||
|
a.text_center,
|
||||||
|
a.mb_md,
|
||||||
|
t.atoms.text,
|
||||||
|
]}>
|
||||||
|
{isNoPwi ? (
|
||||||
|
<Trans>Sign-in Required</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Content Warning</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_lg,
|
||||||
|
a.mb_md,
|
||||||
|
a.px_lg,
|
||||||
|
a.text_center,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{isNoPwi ? (
|
||||||
|
<Trans>
|
||||||
|
This account has requested that users sign in to view their profile.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trans>This {screenDescription} has been flagged:</Trans>
|
||||||
|
<Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}>
|
||||||
|
{desc.name}.{' '}
|
||||||
|
</Text>
|
||||||
|
<TouchableWithoutFeedback
|
||||||
|
onPress={() => {
|
||||||
|
control.open()
|
||||||
|
}}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Learn more about this warning`)}
|
||||||
|
accessibilityHint="">
|
||||||
|
<Text style={[a.text_lg, {color: t.palette.primary_500}]}>
|
||||||
|
<Trans>Learn More</Trans>
|
||||||
|
</Text>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
|
||||||
|
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||||
|
</>
|
||||||
|
)}{' '}
|
||||||
|
</Text>
|
||||||
|
{isMobile && <View style={a.flex_1} />}
|
||||||
|
<View style={[a.flex_row, a.justify_center, a.my_md, a.gap_md]}>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
style={[a.rounded_full]}
|
||||||
|
label={_(msg`Go back`)}
|
||||||
|
onPress={() => {
|
||||||
|
if (navigation.canGoBack()) {
|
||||||
|
navigation.goBack()
|
||||||
|
} else {
|
||||||
|
navigation.navigate('Home')
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Go back</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
{!modui.noOverride && (
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="large"
|
||||||
|
style={[a.rounded_full]}
|
||||||
|
label={_(msg`Show anyway`)}
|
||||||
|
onPress={() => setOverride(v => !v)}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Show anyway</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,692 +0,0 @@
|
||||||
import {describe, it, expect} from '@jest/globals'
|
|
||||||
import {RichText} from '@atproto/api'
|
|
||||||
|
|
||||||
import {hasMutedWord} from '../moderatePost_wrapped'
|
|
||||||
|
|
||||||
describe(`hasMutedWord`, () => {
|
|
||||||
describe(`tags`, () => {
|
|
||||||
it(`match: outline tag`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `This is a post #inlineTag`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'outlineTag', targets: ['tag']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: ['outlineTag'],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: inline tag`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `This is a post #inlineTag`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: ['outlineTag'],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: content target matches inline tag`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `This is a post #inlineTag`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'inlineTag', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: ['outlineTag'],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`no match: only tag targets`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `This is a post`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`early exits`, () => {
|
|
||||||
it(`match: single character 希`, () => {
|
|
||||||
/**
|
|
||||||
* @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
|
|
||||||
*/
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `改善希望です`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: '希', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`no match: long muted word, short post`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `hey`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'politics', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: exact text`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `javascript`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'javascript', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`general content`, () => {
|
|
||||||
it(`match: word within post`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `This is a post about javascript`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'javascript', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`no match: partial word`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `Use your brain, Eric`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'ai', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: multiline`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `Use your\n\tbrain, Eric`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'brain', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: :)`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `So happy :)`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `:)`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`punctuation semi-fuzzy`, () => {
|
|
||||||
describe(`yay!`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `We're federating, yay!`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
it(`match: yay!`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'yay!', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: yay`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'yay', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`y!ppee!!`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `We're federating, y!ppee!!`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
it(`match: y!ppee`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'y!ppee', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
// single exclamation point, source has double
|
|
||||||
it(`no match: y!ppee!`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'y!ppee!', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`Why so S@assy?`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `Why so S@assy?`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
it(`match: S@assy`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'S@assy', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: s@assy`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 's@assy', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`New York Times`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `New York Times`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
// case insensitive
|
|
||||||
it(`match: new york times`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'new york times', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`!command`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `Idk maybe a bot !command`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
it(`match: !command`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `!command`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: command`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `command`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`no match: !command`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `Idk maybe a bot command`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `!command`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`e/acc`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `I'm e/acc pilled`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
it(`match: e/acc`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `e/acc`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: acc`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `acc`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`super-bad`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `I'm super-bad`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
it(`match: super-bad`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `super-bad`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: super`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `super`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: super bad`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `super bad`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: superbad`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `superbad`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`idk_what_this_would_be`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `Weird post with idk_what_this_would_be`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
it(`match: idk what this would be`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `idk what this would be`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`no match: idk what this would be for`, () => {
|
|
||||||
// extra word
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [
|
|
||||||
{value: `idk what this would be for`, targets: ['content']},
|
|
||||||
],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: idk`, () => {
|
|
||||||
// extra word
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `idk`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: idkwhatthiswouldbe`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`parentheses`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `Post with context(iykyk)`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
it(`match: context(iykyk)`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `context(iykyk)`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: context`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `context`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: iykyk`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `iykyk`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: (iykyk)`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `(iykyk)`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`🦋`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `Post with 🦋`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
it(`match: 🦋`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: `🦋`, targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`phrases`, () => {
|
|
||||||
describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `I like turtles, or how I learned to stop worrying and love the internet.`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
it(`match: stop worrying`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'stop worrying', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`match: turtles, or how`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'turtles, or how', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`languages without spaces`, () => {
|
|
||||||
// I love turtles, or how I learned to stop worrying and love the internet
|
|
||||||
describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`,
|
|
||||||
})
|
|
||||||
rt.detectFacetsWithoutResolution()
|
|
||||||
|
|
||||||
// internet
|
|
||||||
it(`match: インターネット`, () => {
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'インターネット', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
languages: ['ja'],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`doesn't mute own post`, () => {
|
|
||||||
it(`does mute if it isn't own post`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `Mute words!`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'words', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`doesn't mute own post when muted word is in text`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `Mute words!`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'words', targets: ['content']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`doesn't mute own post when muted word is in tags`, () => {
|
|
||||||
const rt = new RichText({
|
|
||||||
text: `Mute #words!`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const match = hasMutedWord({
|
|
||||||
mutedWords: [{value: 'words', targets: ['tags']}],
|
|
||||||
text: rt.text,
|
|
||||||
facets: rt.facets,
|
|
||||||
outlineTags: [],
|
|
||||||
isOwnPost: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(match).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -35,6 +35,10 @@ export const MAX_GRAPHEME_LENGTH = 300
|
||||||
// but increasing limit per user feedback
|
// but increasing limit per user feedback
|
||||||
export const MAX_ALT_TEXT = 1000
|
export const MAX_ALT_TEXT = 1000
|
||||||
|
|
||||||
|
export function IS_TEST_USER(handle?: string) {
|
||||||
|
return handle && handle?.endsWith('.test')
|
||||||
|
}
|
||||||
|
|
||||||
export function IS_PROD_SERVICE(url?: string) {
|
export function IS_PROD_SERVICE(url?: string) {
|
||||||
return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
|
return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,380 +1,30 @@
|
||||||
import {
|
import {moderatePost, BSKY_LABELER_DID} from '@atproto/api'
|
||||||
AppBskyEmbedRecord,
|
|
||||||
AppBskyEmbedRecordWithMedia,
|
|
||||||
moderatePost,
|
|
||||||
AppBskyActorDefs,
|
|
||||||
AppBskyFeedPost,
|
|
||||||
AppBskyRichtextFacet,
|
|
||||||
AppBskyEmbedImages,
|
|
||||||
AppBskyEmbedExternal,
|
|
||||||
} from '@atproto/api'
|
|
||||||
|
|
||||||
type ModeratePost = typeof moderatePost
|
type ModeratePost = typeof moderatePost
|
||||||
type Options = Parameters<ModeratePost>[1] & {
|
type Options = Parameters<ModeratePost>[1]
|
||||||
hiddenPosts?: string[]
|
|
||||||
mutedWords?: AppBskyActorDefs.MutedWord[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const REGEX = {
|
|
||||||
LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
|
|
||||||
ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
|
|
||||||
SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g,
|
|
||||||
WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List of 2-letter lang codes for languages that either don't use spaces, or
|
|
||||||
* don't use spaces in a way conducive to word-based filtering.
|
|
||||||
*
|
|
||||||
* For these, we use a simple `String.includes` to check for a match.
|
|
||||||
*/
|
|
||||||
const LANGUAGE_EXCEPTIONS = [
|
|
||||||
'ja', // Japanese
|
|
||||||
'zh', // Chinese
|
|
||||||
'ko', // Korean
|
|
||||||
'th', // Thai
|
|
||||||
'vi', // Vietnamese
|
|
||||||
]
|
|
||||||
|
|
||||||
export function hasMutedWord({
|
|
||||||
mutedWords,
|
|
||||||
text,
|
|
||||||
facets,
|
|
||||||
outlineTags,
|
|
||||||
languages,
|
|
||||||
isOwnPost,
|
|
||||||
}: {
|
|
||||||
mutedWords: AppBskyActorDefs.MutedWord[]
|
|
||||||
text: string
|
|
||||||
facets?: AppBskyRichtextFacet.Main[]
|
|
||||||
outlineTags?: string[]
|
|
||||||
languages?: string[]
|
|
||||||
isOwnPost: boolean
|
|
||||||
}) {
|
|
||||||
if (isOwnPost) return false
|
|
||||||
|
|
||||||
const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
|
|
||||||
const tags = ([] as string[])
|
|
||||||
.concat(outlineTags || [])
|
|
||||||
.concat(
|
|
||||||
facets
|
|
||||||
?.filter(facet => {
|
|
||||||
return facet.features.find(feature =>
|
|
||||||
AppBskyRichtextFacet.isTag(feature),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.map(t => t.features[0].tag as string) || [],
|
|
||||||
)
|
|
||||||
.map(t => t.toLowerCase())
|
|
||||||
|
|
||||||
for (const mute of mutedWords) {
|
|
||||||
const mutedWord = mute.value.toLowerCase()
|
|
||||||
const postText = text.toLowerCase()
|
|
||||||
|
|
||||||
// `content` applies to tags as well
|
|
||||||
if (tags.includes(mutedWord)) return true
|
|
||||||
// rest of the checks are for `content` only
|
|
||||||
if (!mute.targets.includes('content')) continue
|
|
||||||
// single character or other exception, has to use includes
|
|
||||||
if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord))
|
|
||||||
return true
|
|
||||||
// too long
|
|
||||||
if (mutedWord.length > postText.length) continue
|
|
||||||
// exact match
|
|
||||||
if (mutedWord === postText) return true
|
|
||||||
// any muted phrase with space or punctuation
|
|
||||||
if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
|
|
||||||
return true
|
|
||||||
|
|
||||||
// check individual character groups
|
|
||||||
const words = postText.split(REGEX.WORD_BOUNDARY)
|
|
||||||
for (const word of words) {
|
|
||||||
if (word === mutedWord) return true
|
|
||||||
|
|
||||||
// compare word without leading/trailing punctuation, but allow internal
|
|
||||||
// punctuation (such as `s@ssy`)
|
|
||||||
const wordTrimmedPunctuation = word.replace(
|
|
||||||
REGEX.LEADING_TRAILING_PUNCTUATION,
|
|
||||||
'',
|
|
||||||
)
|
|
||||||
|
|
||||||
if (mutedWord === wordTrimmedPunctuation) return true
|
|
||||||
if (mutedWord.length > wordTrimmedPunctuation.length) continue
|
|
||||||
|
|
||||||
// handle hyphenated, slash separated words, etc
|
|
||||||
if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
|
|
||||||
// check against full normalized phrase
|
|
||||||
const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
|
|
||||||
REGEX.SEPARATORS,
|
|
||||||
' ',
|
|
||||||
)
|
|
||||||
const mutedWordNormalizedSeparators = mutedWord.replace(
|
|
||||||
REGEX.SEPARATORS,
|
|
||||||
' ',
|
|
||||||
)
|
|
||||||
// hyphenated (or other sep) to spaced words
|
|
||||||
if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
|
|
||||||
return true
|
|
||||||
|
|
||||||
/* Disabled for now e.g. `super-cool` to `supercool`
|
|
||||||
const wordNormalizedCompressed = wordNormalizedSeparators.replace(
|
|
||||||
REGEX.WORD_BOUNDARY,
|
|
||||||
'',
|
|
||||||
)
|
|
||||||
const mutedWordNormalizedCompressed =
|
|
||||||
mutedWordNormalizedSeparators.replace(/\s+?/g, '')
|
|
||||||
// hyphenated (or other sep) to non-hyphenated contiguous word
|
|
||||||
if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
|
|
||||||
return true
|
|
||||||
*/
|
|
||||||
|
|
||||||
// then individual parts of separated phrases/words
|
|
||||||
const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
|
|
||||||
for (const wp of wordParts) {
|
|
||||||
// still retain internal punctuation
|
|
||||||
if (wp === mutedWord) return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moderatePost_wrapped(
|
export function moderatePost_wrapped(
|
||||||
subject: Parameters<ModeratePost>[0],
|
subject: Parameters<ModeratePost>[0],
|
||||||
opts: Options,
|
opts: Options,
|
||||||
) {
|
) {
|
||||||
const {hiddenPosts = [], mutedWords = [], ...options} = opts
|
// HACK
|
||||||
const moderations = moderatePost(subject, options)
|
// temporarily translate 'gore' into 'graphic-media' during the transition period
|
||||||
const isOwnPost = subject.author.did === opts.userDid
|
// can remove this in a few months
|
||||||
|
// -prf
|
||||||
|
translateOldLabels(subject)
|
||||||
|
|
||||||
if (hiddenPosts.includes(subject.uri)) {
|
return moderatePost(subject, opts)
|
||||||
moderations.content.filter = true
|
|
||||||
moderations.content.blur = true
|
|
||||||
if (!moderations.content.cause) {
|
|
||||||
moderations.content.cause = {
|
|
||||||
// @ts-ignore Temporary extension to the moderation system -prf
|
|
||||||
type: 'post-hidden',
|
|
||||||
source: {type: 'user'},
|
|
||||||
priority: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (AppBskyFeedPost.isRecord(subject.record)) {
|
function translateOldLabels(subject: Parameters<ModeratePost>[0]) {
|
||||||
let muted = hasMutedWord({
|
if (subject.labels) {
|
||||||
mutedWords,
|
for (const label of subject.labels) {
|
||||||
text: subject.record.text,
|
|
||||||
facets: subject.record.facets || [],
|
|
||||||
outlineTags: subject.record.tags || [],
|
|
||||||
languages: subject.record.langs,
|
|
||||||
isOwnPost,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
subject.record.embed &&
|
label.val === 'gore' &&
|
||||||
AppBskyEmbedImages.isMain(subject.record.embed)
|
(!label.src || label.src === BSKY_LABELER_DID)
|
||||||
) {
|
) {
|
||||||
for (const image of subject.record.embed.images) {
|
label.val = 'graphic-media'
|
||||||
muted =
|
|
||||||
muted ||
|
|
||||||
hasMutedWord({
|
|
||||||
mutedWords,
|
|
||||||
text: image.alt,
|
|
||||||
facets: [],
|
|
||||||
outlineTags: [],
|
|
||||||
languages: subject.record.langs,
|
|
||||||
isOwnPost,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (muted) {
|
|
||||||
moderations.content.filter = true
|
|
||||||
moderations.content.blur = true
|
|
||||||
if (!moderations.content.cause) {
|
|
||||||
moderations.content.cause = {
|
|
||||||
// @ts-ignore Temporary extension to the moderation system -prf
|
|
||||||
type: 'muted-word',
|
|
||||||
source: {type: 'user'},
|
|
||||||
priority: 1,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subject.embed) {
|
|
||||||
let embedHidden = false
|
|
||||||
let embedMuted = false
|
|
||||||
let externalMuted = false
|
|
||||||
|
|
||||||
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
|
||||||
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
|
||||||
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
|
||||||
) {
|
|
||||||
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
|
||||||
if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
|
|
||||||
const embeddedPost = subject.embed.record.value
|
|
||||||
|
|
||||||
embedMuted =
|
|
||||||
embedMuted ||
|
|
||||||
hasMutedWord({
|
|
||||||
mutedWords,
|
|
||||||
text: embeddedPost.text,
|
|
||||||
facets: embeddedPost.facets,
|
|
||||||
outlineTags: embeddedPost.tags,
|
|
||||||
languages: embeddedPost.langs,
|
|
||||||
isOwnPost,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (AppBskyEmbedImages.isMain(embeddedPost.embed)) {
|
|
||||||
for (const image of embeddedPost.embed.images) {
|
|
||||||
embedMuted =
|
|
||||||
embedMuted ||
|
|
||||||
hasMutedWord({
|
|
||||||
mutedWords,
|
|
||||||
text: image.alt,
|
|
||||||
facets: [],
|
|
||||||
outlineTags: [],
|
|
||||||
languages: embeddedPost.langs,
|
|
||||||
isOwnPost,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) {
|
|
||||||
const {external} = embeddedPost.embed
|
|
||||||
|
|
||||||
embedMuted =
|
|
||||||
embedMuted ||
|
|
||||||
hasMutedWord({
|
|
||||||
mutedWords,
|
|
||||||
text: external.title + ' ' + external.description,
|
|
||||||
facets: [],
|
|
||||||
outlineTags: [],
|
|
||||||
languages: [],
|
|
||||||
isOwnPost,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) {
|
|
||||||
if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) {
|
|
||||||
const {external} = embeddedPost.embed.media
|
|
||||||
|
|
||||||
embedMuted =
|
|
||||||
embedMuted ||
|
|
||||||
hasMutedWord({
|
|
||||||
mutedWords,
|
|
||||||
text: external.title + ' ' + external.description,
|
|
||||||
facets: [],
|
|
||||||
outlineTags: [],
|
|
||||||
languages: [],
|
|
||||||
isOwnPost,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) {
|
|
||||||
for (const image of embeddedPost.embed.media.images) {
|
|
||||||
embedMuted =
|
|
||||||
embedMuted ||
|
|
||||||
hasMutedWord({
|
|
||||||
mutedWords,
|
|
||||||
text: image.alt,
|
|
||||||
facets: [],
|
|
||||||
outlineTags: [],
|
|
||||||
languages: AppBskyFeedPost.isRecord(embeddedPost.record)
|
|
||||||
? embeddedPost.langs
|
|
||||||
: [],
|
|
||||||
isOwnPost,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (AppBskyEmbedExternal.isView(subject.embed)) {
|
|
||||||
const {external} = subject.embed
|
|
||||||
|
|
||||||
externalMuted =
|
|
||||||
externalMuted ||
|
|
||||||
hasMutedWord({
|
|
||||||
mutedWords,
|
|
||||||
text: external.title + ' ' + external.description,
|
|
||||||
facets: [],
|
|
||||||
outlineTags: [],
|
|
||||||
languages: [],
|
|
||||||
isOwnPost,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
|
||||||
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
|
||||||
) {
|
|
||||||
if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
|
|
||||||
const post = subject.embed.record.record.value
|
|
||||||
embedMuted =
|
|
||||||
embedMuted ||
|
|
||||||
hasMutedWord({
|
|
||||||
mutedWords,
|
|
||||||
text: post.text,
|
|
||||||
facets: post.facets,
|
|
||||||
outlineTags: post.tags,
|
|
||||||
languages: post.langs,
|
|
||||||
isOwnPost,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (AppBskyEmbedImages.isView(subject.embed.media)) {
|
|
||||||
for (const image of subject.embed.media.images) {
|
|
||||||
embedMuted =
|
|
||||||
embedMuted ||
|
|
||||||
hasMutedWord({
|
|
||||||
mutedWords,
|
|
||||||
text: image.alt,
|
|
||||||
facets: [],
|
|
||||||
outlineTags: [],
|
|
||||||
languages: AppBskyFeedPost.isRecord(subject.record)
|
|
||||||
? subject.record.langs
|
|
||||||
: [],
|
|
||||||
isOwnPost,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (embedHidden) {
|
|
||||||
moderations.embed.filter = true
|
|
||||||
moderations.embed.blur = true
|
|
||||||
if (!moderations.embed.cause) {
|
|
||||||
moderations.embed.cause = {
|
|
||||||
// @ts-ignore Temporary extension to the moderation system -prf
|
|
||||||
type: 'post-hidden',
|
|
||||||
source: {type: 'user'},
|
|
||||||
priority: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (externalMuted || embedMuted) {
|
|
||||||
moderations.content.filter = true
|
|
||||||
moderations.content.blur = true
|
|
||||||
if (!moderations.content.cause) {
|
|
||||||
moderations.content.cause = {
|
|
||||||
// @ts-ignore Temporary extension to the moderation system -prf
|
|
||||||
type: 'muted-word',
|
|
||||||
source: {type: 'user'},
|
|
||||||
priority: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return moderations
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,144 +1,20 @@
|
||||||
import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api'
|
import {
|
||||||
|
ModerationCause,
|
||||||
|
ModerationUI,
|
||||||
|
InterpretedLabelValueDefinition,
|
||||||
|
LABELS,
|
||||||
|
AppBskyLabelerDefs,
|
||||||
|
BskyAgent,
|
||||||
|
ModerationOpts,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
export interface ModerationCauseDescription {
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
name: string
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function describeModerationCause(
|
|
||||||
cause: ModerationCause | undefined,
|
|
||||||
context: 'account' | 'content',
|
|
||||||
): ModerationCauseDescription {
|
|
||||||
if (!cause) {
|
|
||||||
return {
|
|
||||||
name: 'Content Warning',
|
|
||||||
description:
|
|
||||||
'Moderator has chosen to set a general warning on the content.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cause.type === 'blocking') {
|
|
||||||
if (cause.source.type === 'list') {
|
|
||||||
return {
|
|
||||||
name: `User Blocked by "${cause.source.list.name}"`,
|
|
||||||
description:
|
|
||||||
'You have blocked this user. You cannot view their content.',
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: 'User Blocked',
|
|
||||||
description:
|
|
||||||
'You have blocked this user. You cannot view their content.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cause.type === 'blocked-by') {
|
|
||||||
return {
|
|
||||||
name: 'User Blocking You',
|
|
||||||
description: 'This user has blocked you. You cannot view their content.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cause.type === 'block-other') {
|
|
||||||
return {
|
|
||||||
name: 'Content Not Available',
|
|
||||||
description:
|
|
||||||
'This content is not available because one of the users involved has blocked the other.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cause.type === 'muted') {
|
|
||||||
if (cause.source.type === 'list') {
|
|
||||||
return {
|
|
||||||
name:
|
|
||||||
context === 'account'
|
|
||||||
? `Muted by "${cause.source.list.name}"`
|
|
||||||
: `Post by muted user ("${cause.source.list.name}")`,
|
|
||||||
description: 'You have muted this user',
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: context === 'account' ? 'Muted User' : 'Post by muted user',
|
|
||||||
description: 'You have muted this user',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @ts-ignore Temporary extension to the moderation system -prf
|
|
||||||
if (cause.type === 'post-hidden') {
|
|
||||||
return {
|
|
||||||
name: 'Post Hidden by You',
|
|
||||||
description: 'You have hidden this post',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @ts-ignore Temporary extension to the moderation system -prf
|
|
||||||
if (cause.type === 'muted-word') {
|
|
||||||
return {
|
|
||||||
name: 'Post hidden by muted word',
|
|
||||||
description: `You've chosen to hide a word or tag within this post.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cause.labelDef.strings[context].en
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProfileModerationCauses(
|
|
||||||
moderation: ProfileModeration,
|
|
||||||
): ModerationCause[] {
|
|
||||||
/*
|
|
||||||
Gather everything on profile and account that blurs or alerts
|
|
||||||
*/
|
|
||||||
return [
|
|
||||||
moderation.decisions.profile.cause,
|
|
||||||
...moderation.decisions.profile.additionalCauses,
|
|
||||||
moderation.decisions.account.cause,
|
|
||||||
...moderation.decisions.account.additionalCauses,
|
|
||||||
].filter(cause => {
|
|
||||||
if (!cause) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (cause?.type === 'label') {
|
|
||||||
if (
|
|
||||||
cause.labelDef.onwarn === 'blur' ||
|
|
||||||
cause.labelDef.onwarn === 'alert'
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}) as ModerationCause[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPostMediaBlurred(
|
|
||||||
decisions: PostModeration['decisions'],
|
|
||||||
): boolean {
|
|
||||||
return decisions.post.blurMedia
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isQuoteBlurred(
|
|
||||||
decisions: PostModeration['decisions'],
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
decisions.quote?.blur ||
|
|
||||||
decisions.quote?.blurMedia ||
|
|
||||||
decisions.quote?.filter ||
|
|
||||||
decisions.quotedAccount?.blur ||
|
|
||||||
decisions.quotedAccount?.filter ||
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCauseALabelOnUri(
|
|
||||||
cause: ModerationCause | undefined,
|
|
||||||
uri: string,
|
|
||||||
): boolean {
|
|
||||||
if (cause?.type !== 'label') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return cause.label.uri === uri
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getModerationCauseKey(cause: ModerationCause): string {
|
export function getModerationCauseKey(cause: ModerationCause): string {
|
||||||
const source =
|
const source =
|
||||||
cause.source.type === 'labeler'
|
cause.source.type === 'labeler'
|
||||||
? cause.source.labeler.did
|
? cause.source.did
|
||||||
: cause.source.type === 'list'
|
: cause.source.type === 'list'
|
||||||
? cause.source.list.uri
|
? cause.source.list.uri
|
||||||
: 'user'
|
: 'user'
|
||||||
|
@ -147,3 +23,59 @@ export function getModerationCauseKey(cause: ModerationCause): string {
|
||||||
}
|
}
|
||||||
return `${cause.type}:${source}`
|
return `${cause.type}:${source}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isJustAMute(modui: ModerationUI): boolean {
|
||||||
|
return modui.filters.length === 1 && modui.filters[0].type === 'muted'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLabelingServiceTitle({
|
||||||
|
displayName,
|
||||||
|
handle,
|
||||||
|
}: {
|
||||||
|
displayName?: string
|
||||||
|
handle: string
|
||||||
|
}) {
|
||||||
|
return displayName
|
||||||
|
? sanitizeDisplayName(displayName)
|
||||||
|
: sanitizeHandle(handle, '@')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookupLabelValueDefinition(
|
||||||
|
labelValue: string,
|
||||||
|
customDefs: InterpretedLabelValueDefinition[] | undefined,
|
||||||
|
): InterpretedLabelValueDefinition | undefined {
|
||||||
|
let def
|
||||||
|
if (!labelValue.startsWith('!') && customDefs) {
|
||||||
|
def = customDefs.find(d => d.identifier === labelValue)
|
||||||
|
}
|
||||||
|
if (!def) {
|
||||||
|
def = LABELS[labelValue as keyof typeof LABELS]
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppLabeler(
|
||||||
|
labeler:
|
||||||
|
| string
|
||||||
|
| AppBskyLabelerDefs.LabelerView
|
||||||
|
| AppBskyLabelerDefs.LabelerViewDetailed,
|
||||||
|
): boolean {
|
||||||
|
if (typeof labeler === 'string') {
|
||||||
|
return BskyAgent.appLabelers.includes(labeler)
|
||||||
|
}
|
||||||
|
return BskyAgent.appLabelers.includes(labeler.creator.did)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLabelerSubscribed(
|
||||||
|
labeler:
|
||||||
|
| string
|
||||||
|
| AppBskyLabelerDefs.LabelerView
|
||||||
|
| AppBskyLabelerDefs.LabelerViewDetailed,
|
||||||
|
modOpts: ModerationOpts,
|
||||||
|
) {
|
||||||
|
labeler = typeof labeler === 'string' ? labeler : labeler.creator.did
|
||||||
|
if (isAppLabeler(labeler)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return modOpts.prefs.labelers.find(l => l.did === labeler)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useMemo} from 'react'
|
||||||
|
|
||||||
|
export type GlobalLabelStrings = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export function useGlobalLabelStrings(): GlobalLabelStrings {
|
||||||
|
const {_} = useLingui()
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
'!hide': {
|
||||||
|
name: _(msg`Content Blocked`),
|
||||||
|
description: _(msg`This content has been hidden by the moderators.`),
|
||||||
|
},
|
||||||
|
'!warn': {
|
||||||
|
name: _(msg`Content Warning`),
|
||||||
|
description: _(
|
||||||
|
msg`This content has received a general warning from moderators.`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
'!no-unauthenticated': {
|
||||||
|
name: _(msg`Sign-in Required`),
|
||||||
|
description: _(
|
||||||
|
msg`This user has requested that their content only be shown to signed-in users.`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
porn: {
|
||||||
|
name: _(msg`Pornography`),
|
||||||
|
description: _(msg`Explicit sexual images.`),
|
||||||
|
},
|
||||||
|
sexual: {
|
||||||
|
name: _(msg`Sexually Suggestive`),
|
||||||
|
description: _(msg`Does not include nudity.`),
|
||||||
|
},
|
||||||
|
nudity: {
|
||||||
|
name: _(msg`Non-sexual Nudity`),
|
||||||
|
description: _(msg`E.g. artistic nudes.`),
|
||||||
|
},
|
||||||
|
'graphic-media': {
|
||||||
|
name: _(msg`Graphic Media`),
|
||||||
|
description: _(msg`Explicit or potentially disturbing media.`),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[_],
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
|
||||||
|
export function useLabelBehaviorDescription(
|
||||||
|
labelValueDef: InterpretedLabelValueDefinition,
|
||||||
|
pref: LabelPreference,
|
||||||
|
) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
if (pref === 'ignore') {
|
||||||
|
return _(msg`Off`)
|
||||||
|
}
|
||||||
|
if (labelValueDef.blurs === 'content' || labelValueDef.blurs === 'media') {
|
||||||
|
if (pref === 'hide') {
|
||||||
|
return _(msg`Hide`)
|
||||||
|
}
|
||||||
|
return _(msg`Warn`)
|
||||||
|
} else if (labelValueDef.severity === 'alert') {
|
||||||
|
if (pref === 'hide') {
|
||||||
|
return _(msg`Hide`)
|
||||||
|
}
|
||||||
|
return _(msg`Warn`)
|
||||||
|
} else if (labelValueDef.severity === 'inform') {
|
||||||
|
if (pref === 'hide') {
|
||||||
|
return _(msg`Hide`)
|
||||||
|
}
|
||||||
|
return _(msg`Show badge`)
|
||||||
|
} else {
|
||||||
|
if (pref === 'hide') {
|
||||||
|
return _(msg`Hide`)
|
||||||
|
}
|
||||||
|
return _(msg`Disabled`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLabelLongBehaviorDescription(
|
||||||
|
labelValueDef: InterpretedLabelValueDefinition,
|
||||||
|
pref: LabelPreference,
|
||||||
|
) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
if (pref === 'ignore') {
|
||||||
|
return _(msg`Disabled`)
|
||||||
|
}
|
||||||
|
if (labelValueDef.blurs === 'content') {
|
||||||
|
if (pref === 'hide') {
|
||||||
|
return _(msg`Warn content and filter from feeds`)
|
||||||
|
}
|
||||||
|
return _(msg`Warn content`)
|
||||||
|
} else if (labelValueDef.blurs === 'media') {
|
||||||
|
if (pref === 'hide') {
|
||||||
|
return _(msg`Blur images and filter from feeds`)
|
||||||
|
}
|
||||||
|
return _(msg`Blur images`)
|
||||||
|
} else if (labelValueDef.severity === 'alert') {
|
||||||
|
if (pref === 'hide') {
|
||||||
|
return _(msg`Show warning and filter from feeds`)
|
||||||
|
}
|
||||||
|
return _(msg`Show warning`)
|
||||||
|
} else if (labelValueDef.severity === 'inform') {
|
||||||
|
if (pref === 'hide') {
|
||||||
|
return _(msg`Show badge and filter from feeds`)
|
||||||
|
}
|
||||||
|
return _(msg`Show badge`)
|
||||||
|
} else {
|
||||||
|
if (pref === 'hide') {
|
||||||
|
return _(msg`Filter from feeds`)
|
||||||
|
}
|
||||||
|
return _(msg`Disabled`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,100 @@
|
||||||
|
import {
|
||||||
|
ComAtprotoLabelDefs,
|
||||||
|
AppBskyLabelerDefs,
|
||||||
|
LABELS,
|
||||||
|
interpretLabelValueDefinition,
|
||||||
|
InterpretedLabelValueDefinition,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import * as bcp47Match from 'bcp-47-match'
|
||||||
|
|
||||||
|
import {
|
||||||
|
GlobalLabelStrings,
|
||||||
|
useGlobalLabelStrings,
|
||||||
|
} from '#/lib/moderation/useGlobalLabelStrings'
|
||||||
|
import {useLabelDefinitions} from '#/state/preferences'
|
||||||
|
|
||||||
|
export interface LabelInfo {
|
||||||
|
label: ComAtprotoLabelDefs.Label
|
||||||
|
def: InterpretedLabelValueDefinition
|
||||||
|
strings: ComAtprotoLabelDefs.LabelValueDefinitionStrings
|
||||||
|
labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLabelInfo(label: ComAtprotoLabelDefs.Label): LabelInfo {
|
||||||
|
const {i18n} = useLingui()
|
||||||
|
const {labelDefs, labelers} = useLabelDefinitions()
|
||||||
|
const globalLabelStrings = useGlobalLabelStrings()
|
||||||
|
const def = getDefinition(labelDefs, label)
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
def,
|
||||||
|
strings: getLabelStrings(i18n.locale, globalLabelStrings, def),
|
||||||
|
labeler: labelers.find(labeler => label.src === labeler.creator.did),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefinition(
|
||||||
|
labelDefs: Record<string, InterpretedLabelValueDefinition[]>,
|
||||||
|
label: ComAtprotoLabelDefs.Label,
|
||||||
|
): InterpretedLabelValueDefinition {
|
||||||
|
// check local definitions
|
||||||
|
const customDef =
|
||||||
|
!label.val.startsWith('!') &&
|
||||||
|
labelDefs[label.src]?.find(
|
||||||
|
def => def.identifier === label.val && def.definedBy === label.src,
|
||||||
|
)
|
||||||
|
if (customDef) {
|
||||||
|
return customDef
|
||||||
|
}
|
||||||
|
|
||||||
|
// check global definitions
|
||||||
|
const globalDef = LABELS[label.val as keyof typeof LABELS]
|
||||||
|
if (globalDef) {
|
||||||
|
return globalDef
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback to a noop definition
|
||||||
|
return interpretLabelValueDefinition(
|
||||||
|
{
|
||||||
|
identifier: label.val,
|
||||||
|
severity: 'none',
|
||||||
|
blurs: 'none',
|
||||||
|
defaultSetting: 'ignore',
|
||||||
|
locales: [],
|
||||||
|
},
|
||||||
|
label.src,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLabelStrings(
|
||||||
|
locale: string,
|
||||||
|
globalLabelStrings: GlobalLabelStrings,
|
||||||
|
def: InterpretedLabelValueDefinition,
|
||||||
|
): ComAtprotoLabelDefs.LabelValueDefinitionStrings {
|
||||||
|
if (!def.definedBy) {
|
||||||
|
// global definition, look up strings
|
||||||
|
if (def.identifier in globalLabelStrings) {
|
||||||
|
return globalLabelStrings[
|
||||||
|
def.identifier
|
||||||
|
] as ComAtprotoLabelDefs.LabelValueDefinitionStrings
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// try to find locale match in the definition's strings
|
||||||
|
const localeMatch = def.locales.find(
|
||||||
|
strings => bcp47Match.basicFilter(locale, strings.lang).length > 0,
|
||||||
|
)
|
||||||
|
if (localeMatch) {
|
||||||
|
return localeMatch
|
||||||
|
}
|
||||||
|
// fall back to the zero item if no match
|
||||||
|
if (def.locales[0]) {
|
||||||
|
return def.locales[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
lang: locale,
|
||||||
|
name: def.identifier,
|
||||||
|
description: `Labeled "${def.identifier}"`,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,146 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
BSKY_LABELER_DID,
|
||||||
|
ModerationCause,
|
||||||
|
ModerationCauseSource,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {getDefinition, getLabelStrings} from './useLabelInfo'
|
||||||
|
import {useLabelDefinitions} from '#/state/preferences'
|
||||||
|
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
|
||||||
|
|
||||||
|
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||||
|
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||||
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
|
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||||
|
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||||
|
|
||||||
|
export interface ModerationCauseDescription {
|
||||||
|
icon: React.ComponentType<SVGIconProps>
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
source?: string
|
||||||
|
sourceType?: ModerationCauseSource['type']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModerationCauseDescription(
|
||||||
|
cause: ModerationCause | undefined,
|
||||||
|
): ModerationCauseDescription {
|
||||||
|
const {_, i18n} = useLingui()
|
||||||
|
const {labelDefs, labelers} = useLabelDefinitions()
|
||||||
|
const globalLabelStrings = useGlobalLabelStrings()
|
||||||
|
|
||||||
|
return React.useMemo(() => {
|
||||||
|
if (!cause) {
|
||||||
|
return {
|
||||||
|
icon: Warning,
|
||||||
|
name: _(msg`Content Warning`),
|
||||||
|
description: _(
|
||||||
|
msg`Moderator has chosen to set a general warning on the content.`,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cause.type === 'blocking') {
|
||||||
|
if (cause.source.type === 'list') {
|
||||||
|
return {
|
||||||
|
icon: CircleBanSign,
|
||||||
|
name: _(msg`User Blocked by "${cause.source.list.name}"`),
|
||||||
|
description: _(
|
||||||
|
msg`You have blocked this user. You cannot view their content.`,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
icon: CircleBanSign,
|
||||||
|
name: _(msg`User Blocked`),
|
||||||
|
description: _(
|
||||||
|
msg`You have blocked this user. You cannot view their content.`,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cause.type === 'blocked-by') {
|
||||||
|
return {
|
||||||
|
icon: CircleBanSign,
|
||||||
|
name: _(msg`User Blocking You`),
|
||||||
|
description: _(
|
||||||
|
msg`This user has blocked you. You cannot view their content.`,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cause.type === 'block-other') {
|
||||||
|
return {
|
||||||
|
icon: CircleBanSign,
|
||||||
|
name: _(msg`Content Not Available`),
|
||||||
|
description: _(
|
||||||
|
msg`This content is not available because one of the users involved has blocked the other.`,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cause.type === 'muted') {
|
||||||
|
if (cause.source.type === 'list') {
|
||||||
|
return {
|
||||||
|
icon: EyeSlash,
|
||||||
|
name: _(msg`Muted by "${cause.source.list.name}"`),
|
||||||
|
description: _(msg`You have muted this user`),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
icon: EyeSlash,
|
||||||
|
name: _(msg`Account Muted`),
|
||||||
|
description: _(msg`You have muted this account.`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cause.type === 'mute-word') {
|
||||||
|
return {
|
||||||
|
icon: EyeSlash,
|
||||||
|
name: _(msg`Post Hidden by Muted Word`),
|
||||||
|
description: _(
|
||||||
|
msg`You've chosen to hide a word or tag within this post.`,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cause.type === 'hidden') {
|
||||||
|
return {
|
||||||
|
icon: EyeSlash,
|
||||||
|
name: _(msg`Post Hidden by You`),
|
||||||
|
description: _(msg`You have hidden this post`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cause.type === 'label') {
|
||||||
|
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
|
||||||
|
const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
|
||||||
|
const labeler = labelers.find(l => l.creator.did === cause.label.src)
|
||||||
|
let source =
|
||||||
|
labeler?.creator.displayName ||
|
||||||
|
(labeler?.creator.handle ? '@' + labeler?.creator.handle : undefined)
|
||||||
|
if (!source) {
|
||||||
|
if (cause.label.src === BSKY_LABELER_DID) {
|
||||||
|
source = 'Bluesky Moderation'
|
||||||
|
} else {
|
||||||
|
source = cause.label.src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
icon:
|
||||||
|
def.identifier === '!no-unauthenticated'
|
||||||
|
? EyeSlash
|
||||||
|
: def.severity === 'alert'
|
||||||
|
? Warning
|
||||||
|
: CircleInfo,
|
||||||
|
name: strings.name,
|
||||||
|
description: strings.description,
|
||||||
|
source,
|
||||||
|
sourceType: cause.source.type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// should never happen
|
||||||
|
return {
|
||||||
|
icon: CircleInfo,
|
||||||
|
name: '',
|
||||||
|
description: ``,
|
||||||
|
}
|
||||||
|
}, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale])
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useMemo} from 'react'
|
||||||
|
import {ComAtprotoModerationDefs} from '@atproto/api'
|
||||||
|
|
||||||
|
export interface ReportOption {
|
||||||
|
reason: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportOptions {
|
||||||
|
account: ReportOption[]
|
||||||
|
post: ReportOption[]
|
||||||
|
list: ReportOption[]
|
||||||
|
feedgen: ReportOption[]
|
||||||
|
other: ReportOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReportOptions(): ReportOptions {
|
||||||
|
const {_} = useLingui()
|
||||||
|
return useMemo(() => {
|
||||||
|
const other = {
|
||||||
|
reason: ComAtprotoModerationDefs.REASONOTHER,
|
||||||
|
title: _(msg`Other`),
|
||||||
|
description: _(msg`An issue not included in these options`),
|
||||||
|
}
|
||||||
|
const common = [
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONRUDE,
|
||||||
|
title: _(msg`Anti-Social Behavior`),
|
||||||
|
description: _(msg`Harassment, trolling, or intolerance`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONVIOLATION,
|
||||||
|
title: _(msg`Illegal and Urgent`),
|
||||||
|
description: _(msg`Glaring violations of law or terms of service`),
|
||||||
|
},
|
||||||
|
other,
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
account: [
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONMISLEADING,
|
||||||
|
title: _(msg`Misleading Account`),
|
||||||
|
description: _(
|
||||||
|
msg`Impersonation or false claims about identity or affiliation`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONSPAM,
|
||||||
|
title: _(msg`Frequently Posts Unwanted Content`),
|
||||||
|
description: _(msg`Spam; excessive mentions or replies`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONVIOLATION,
|
||||||
|
title: _(msg`Name or Description Violates Community Standards`),
|
||||||
|
description: _(msg`Terms used violate community standards`),
|
||||||
|
},
|
||||||
|
other,
|
||||||
|
],
|
||||||
|
post: [
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONSPAM,
|
||||||
|
title: _(msg`Spam`),
|
||||||
|
description: _(msg`Excessive mentions or replies`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONSEXUAL,
|
||||||
|
title: _(msg`Unwanted Sexual Content`),
|
||||||
|
description: _(msg`Nudity or pornography not labeled as such`),
|
||||||
|
},
|
||||||
|
...common,
|
||||||
|
],
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONVIOLATION,
|
||||||
|
title: _(msg`Name or Description Violates Community Standards`),
|
||||||
|
description: _(msg`Terms used violate community standards`),
|
||||||
|
},
|
||||||
|
...common,
|
||||||
|
],
|
||||||
|
feedgen: [
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONVIOLATION,
|
||||||
|
title: _(msg`Name or Description Violates Community Standards`),
|
||||||
|
description: _(msg`Terms used violate community standards`),
|
||||||
|
},
|
||||||
|
...common,
|
||||||
|
],
|
||||||
|
other: common,
|
||||||
|
}
|
||||||
|
}, [_])
|
||||||
|
}
|
|
@ -1,7 +1,14 @@
|
||||||
import {AppState, AppStateStatus} from 'react-native'
|
import {AppState, AppStateStatus} from 'react-native'
|
||||||
import {QueryClient, focusManager} from '@tanstack/react-query'
|
import {QueryClient, focusManager} from '@tanstack/react-query'
|
||||||
|
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
import {PersistQueryClientProviderProps} from '@tanstack/react-query-persist-client'
|
||||||
|
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
|
|
||||||
|
// any query keys in this array will be persisted to AsyncStorage
|
||||||
|
const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info']
|
||||||
|
|
||||||
focusManager.setEventListener(onFocus => {
|
focusManager.setEventListener(onFocus => {
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
const subscription = AppState.addEventListener(
|
const subscription = AppState.addEventListener(
|
||||||
|
@ -48,3 +55,16 @@ export const queryClient = new QueryClient({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const asyncStoragePersister = createAsyncStoragePersister({
|
||||||
|
storage: AsyncStorage,
|
||||||
|
key: 'queryCache',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] =
|
||||||
|
{
|
||||||
|
shouldDehydrateMutation: (_: any) => false,
|
||||||
|
shouldDehydrateQuery: query => {
|
||||||
|
return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0]))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
@ -21,7 +21,9 @@ export type CommonNavigatorParams = {
|
||||||
PostRepostedBy: {name: string; rkey: string}
|
PostRepostedBy: {name: string; rkey: string}
|
||||||
ProfileFeed: {name: string; rkey: string}
|
ProfileFeed: {name: string; rkey: string}
|
||||||
ProfileFeedLikedBy: {name: string; rkey: string}
|
ProfileFeedLikedBy: {name: string; rkey: string}
|
||||||
|
ProfileLabelerLikedBy: {name: string}
|
||||||
Debug: undefined
|
Debug: undefined
|
||||||
|
DebugMod: undefined
|
||||||
Log: undefined
|
Log: undefined
|
||||||
Support: undefined
|
Support: undefined
|
||||||
PrivacyPolicy: undefined
|
PrivacyPolicy: undefined
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import {ModerationUI} from '@atproto/api'
|
import {ModerationUI} from '@atproto/api'
|
||||||
import {describeModerationCause} from '../moderation'
|
|
||||||
|
|
||||||
// \u2705 = ✅
|
// \u2705 = ✅
|
||||||
// \u2713 = ✓
|
// \u2713 = ✓
|
||||||
|
@ -14,7 +13,7 @@ export function sanitizeDisplayName(
|
||||||
moderation?: ModerationUI,
|
moderation?: ModerationUI,
|
||||||
): string {
|
): string {
|
||||||
if (moderation?.blur) {
|
if (moderation?.blur) {
|
||||||
return `⚠${describeModerationCause(moderation.cause, 'account').name}`
|
return ''
|
||||||
}
|
}
|
||||||
if (typeof str === 'string') {
|
if (typeof str === 'string') {
|
||||||
return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()
|
return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()
|
||||||
|
|
|
@ -9,7 +9,7 @@ export const defaultTheme: Theme = {
|
||||||
palette: {
|
palette: {
|
||||||
default: {
|
default: {
|
||||||
background: lightPalette.white,
|
background: lightPalette.white,
|
||||||
backgroundLight: lightPalette.contrast_50,
|
backgroundLight: lightPalette.contrast_25,
|
||||||
text: lightPalette.black,
|
text: lightPalette.black,
|
||||||
textLight: lightPalette.contrast_700,
|
textLight: lightPalette.contrast_700,
|
||||||
textInverted: lightPalette.white,
|
textInverted: lightPalette.white,
|
||||||
|
|
|
@ -21,7 +21,9 @@ export const router = new Router({
|
||||||
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
|
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
|
||||||
ProfileFeed: '/profile/:name/feed/:rkey',
|
ProfileFeed: '/profile/:name/feed/:rkey',
|
||||||
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
|
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
|
||||||
|
ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by',
|
||||||
Debug: '/sys/debug',
|
Debug: '/sys/debug',
|
||||||
|
DebugMod: '/sys/debug-mod',
|
||||||
Log: '/sys/log',
|
Log: '/sys/log',
|
||||||
AppPasswords: '/settings/app-passwords',
|
AppPasswords: '/settings/app-passwords',
|
||||||
PreferencesFollowingFeed: '/settings/following-feed',
|
PreferencesFollowingFeed: '/settings/following-feed',
|
||||||
|
|
|
@ -0,0 +1,560 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
import {ComAtprotoLabelDefs} from '@atproto/api'
|
||||||
|
import {Trans, msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {LABELS} from '@atproto/api'
|
||||||
|
import {useSafeAreaFrame} from 'react-native-safe-area-context'
|
||||||
|
|
||||||
|
import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
|
||||||
|
import {CenteredView} from '#/view/com/util/Views'
|
||||||
|
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||||
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {
|
||||||
|
useProfileQuery,
|
||||||
|
useProfileUpdateMutation,
|
||||||
|
} from '#/state/queries/profile'
|
||||||
|
import {ScrollView} from '#/view/com/util/Views'
|
||||||
|
|
||||||
|
import {
|
||||||
|
UsePreferencesQueryResponse,
|
||||||
|
useMyLabelersQuery,
|
||||||
|
usePreferencesQuery,
|
||||||
|
usePreferencesSetAdultContentMutation,
|
||||||
|
} from '#/state/queries/preferences'
|
||||||
|
|
||||||
|
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||||
|
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||||
|
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||||
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||||
|
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {InlineLink, Link} from '#/components/Link'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as LabelingService from '#/components/LabelingServiceCard'
|
||||||
|
import {GlobalModerationLabelPref} from '#/components/moderation/GlobalModerationLabelPref'
|
||||||
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
|
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||||
|
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
|
function ErrorState({error}: {error: string}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View style={[a.p_xl]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.leading_normal,
|
||||||
|
a.pb_md,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
Hmmmm, it seems we're having trouble loading this data. See below for
|
||||||
|
more details. If this issue persists, please contact us.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.py_md,
|
||||||
|
a.px_lg,
|
||||||
|
a.rounded_md,
|
||||||
|
a.mb_2xl,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_md, a.leading_normal]}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModerationScreen(
|
||||||
|
_props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>,
|
||||||
|
) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {
|
||||||
|
isLoading: isPreferencesLoading,
|
||||||
|
error: preferencesError,
|
||||||
|
data: preferences,
|
||||||
|
} = usePreferencesQuery()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const {height} = useSafeAreaFrame()
|
||||||
|
|
||||||
|
const isLoading = isPreferencesLoading
|
||||||
|
const error = preferencesError
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredView
|
||||||
|
testID="moderationScreen"
|
||||||
|
style={[
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
{minHeight: height},
|
||||||
|
...(gtMobile ? [a.border_l, a.border_r] : []),
|
||||||
|
]}>
|
||||||
|
<ViewHeader title={_(msg`Moderation`)} showOnDesktop />
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={[a.w_full, a.align_center, a.pt_2xl]}>
|
||||||
|
<Loader size="xl" fill={t.atoms.text.color} />
|
||||||
|
</View>
|
||||||
|
) : error || !preferences ? (
|
||||||
|
<ErrorState
|
||||||
|
error={
|
||||||
|
preferencesError?.toString() ||
|
||||||
|
_(msg`Something went wrong, please try again.`)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ModerationScreenInner preferences={preferences} />
|
||||||
|
)}
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubItem({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
style,
|
||||||
|
}: ViewStyleProp & {
|
||||||
|
title: string
|
||||||
|
icon: React.ComponentType<SVGIconProps>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.p_lg,
|
||||||
|
a.gap_sm,
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||||
|
<Icon size="md" style={[t.atoms.text_contrast_medium]} />
|
||||||
|
<Text style={[a.text_sm, a.font_bold]}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
<ChevronRight
|
||||||
|
size="sm"
|
||||||
|
style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModerationScreenInner({
|
||||||
|
preferences,
|
||||||
|
}: {
|
||||||
|
preferences: UsePreferencesQueryResponse
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
|
const {screen} = useAnalytics()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||||
|
const birthdateDialogControl = Dialog.useDialogControl()
|
||||||
|
const {
|
||||||
|
isLoading: isLabelersLoading,
|
||||||
|
data: labelers,
|
||||||
|
error: labelersError,
|
||||||
|
} = useMyLabelersQuery()
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
screen('Moderation')
|
||||||
|
setMinimalShellMode(false)
|
||||||
|
}, [screen, setMinimalShellMode]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
|
||||||
|
usePreferencesSetAdultContentMutation()
|
||||||
|
const adultContentEnabled = !!(
|
||||||
|
(optimisticAdultContent && optimisticAdultContent.enabled) ||
|
||||||
|
(!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled)
|
||||||
|
)
|
||||||
|
const ageNotSet = !preferences.userAge
|
||||||
|
const isUnderage = (preferences.userAge || 0) < 18
|
||||||
|
|
||||||
|
const onToggleAdultContentEnabled = React.useCallback(
|
||||||
|
async (selected: boolean) => {
|
||||||
|
try {
|
||||||
|
await setAdultContentPref({
|
||||||
|
enabled: selected,
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`Failed to set adult content pref`, {
|
||||||
|
message: e.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setAdultContentPref],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
a.border_0,
|
||||||
|
a.pt_2xl,
|
||||||
|
a.px_lg,
|
||||||
|
gtMobile && a.px_2xl,
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>Moderation tools</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.rounded_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<Button
|
||||||
|
testID="mutedWordsBtn"
|
||||||
|
label={_(msg`Open muted words and tags settings`)}
|
||||||
|
onPress={() => mutedWordsDialogControl.open()}>
|
||||||
|
{state => (
|
||||||
|
<SubItem
|
||||||
|
title={_(msg`Muted words & tags`)}
|
||||||
|
icon={Filter}
|
||||||
|
style={[
|
||||||
|
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Divider />
|
||||||
|
<Link testID="moderationlistsBtn" to="/moderation/modlists">
|
||||||
|
{state => (
|
||||||
|
<SubItem
|
||||||
|
title={_(msg`Moderation lists`)}
|
||||||
|
icon={Group}
|
||||||
|
style={[
|
||||||
|
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Divider />
|
||||||
|
<Link testID="mutedAccountsBtn" to="/moderation/muted-accounts">
|
||||||
|
{state => (
|
||||||
|
<SubItem
|
||||||
|
title={_(msg`Muted accounts`)}
|
||||||
|
icon={Person}
|
||||||
|
style={[
|
||||||
|
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Divider />
|
||||||
|
<Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts">
|
||||||
|
{state => (
|
||||||
|
<SubItem
|
||||||
|
title={_(msg`Blocked accounts`)}
|
||||||
|
icon={CircleBanSign}
|
||||||
|
style={[
|
||||||
|
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pt_2xl,
|
||||||
|
a.pb_md,
|
||||||
|
a.text_md,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
<Trans>Content filters</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.gap_md]}>
|
||||||
|
{ageNotSet && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Confirm your birthdate`)}
|
||||||
|
size="small"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => {
|
||||||
|
birthdateDialogControl.open()
|
||||||
|
}}
|
||||||
|
style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Confirm your age:</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Set birthdate</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<BirthDateSettingsDialog
|
||||||
|
control={birthdateDialogControl}
|
||||||
|
preferences={preferences}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.rounded_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
{!ageNotSet && !isUnderage && (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.py_lg,
|
||||||
|
a.px_lg,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.font_semibold, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>Enable adult content</Trans>
|
||||||
|
</Text>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Toggle to enable or disable adult content`)}
|
||||||
|
name="adultContent"
|
||||||
|
value={adultContentEnabled}
|
||||||
|
onChange={onToggleAdultContentEnabled}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium]}>
|
||||||
|
{adultContentEnabled ? (
|
||||||
|
<Trans>Enabled</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Disabled</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Toggle.Switch />
|
||||||
|
</View>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isUnderage && adultContentEnabled && (
|
||||||
|
<>
|
||||||
|
<GlobalModerationLabelPref labelValueDefinition={LABELS.porn} />
|
||||||
|
<Divider />
|
||||||
|
<GlobalModerationLabelPref
|
||||||
|
labelValueDefinition={LABELS.sexual}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<GlobalModerationLabelPref
|
||||||
|
labelValueDefinition={LABELS['graphic-media']}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<GlobalModerationLabelPref labelValueDefinition={LABELS.nudity} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.font_bold,
|
||||||
|
a.pt_2xl,
|
||||||
|
a.pb_md,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
<Trans>Advanced</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLabelersLoading ? (
|
||||||
|
<Loader />
|
||||||
|
) : labelersError || !labelers ? (
|
||||||
|
<View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
|
||||||
|
<Text>
|
||||||
|
<Trans>
|
||||||
|
We were unable to load your configured labelers at this time.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[a.rounded_sm, t.atoms.bg_contrast_25]}>
|
||||||
|
{labelers.map((labeler, i) => {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={labeler.creator.did}>
|
||||||
|
{i !== 0 && <Divider />}
|
||||||
|
<LabelingService.Link labeler={labeler}>
|
||||||
|
{state => (
|
||||||
|
<LabelingService.Outer
|
||||||
|
style={[
|
||||||
|
i === 0 && {
|
||||||
|
borderTopLeftRadius: a.rounded_sm.borderRadius,
|
||||||
|
borderTopRightRadius: a.rounded_sm.borderRadius,
|
||||||
|
},
|
||||||
|
i === labelers.length - 1 && {
|
||||||
|
borderBottomLeftRadius: a.rounded_sm.borderRadius,
|
||||||
|
borderBottomRightRadius: a.rounded_sm.borderRadius,
|
||||||
|
},
|
||||||
|
(state.hovered || state.pressed) && [
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
],
|
||||||
|
]}>
|
||||||
|
<LabelingService.Avatar />
|
||||||
|
<LabelingService.Content>
|
||||||
|
<LabelingService.Title
|
||||||
|
value={getLabelingServiceTitle({
|
||||||
|
displayName: labeler.creator.displayName,
|
||||||
|
handle: labeler.creator.handle,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<LabelingService.Description
|
||||||
|
value={labeler.creator.description}
|
||||||
|
handle={labeler.creator.handle}
|
||||||
|
/>
|
||||||
|
</LabelingService.Content>
|
||||||
|
</LabelingService.Outer>
|
||||||
|
)}
|
||||||
|
</LabelingService.Link>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.font_bold,
|
||||||
|
a.pt_2xl,
|
||||||
|
a.pb_md,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
<Trans>Logged-out visibility</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<PwiOptOut />
|
||||||
|
|
||||||
|
<View style={{height: 200}} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PwiOptOut() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||||
|
const updateProfile = useProfileUpdateMutation()
|
||||||
|
|
||||||
|
const isOptedOut =
|
||||||
|
profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
|
||||||
|
const canToggle = profile && !updateProfile.isPending
|
||||||
|
|
||||||
|
const onToggleOptOut = React.useCallback(() => {
|
||||||
|
if (!profile) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let wasAdded = false
|
||||||
|
updateProfile.mutate({
|
||||||
|
profile,
|
||||||
|
updates: existing => {
|
||||||
|
// create labels attr if needed
|
||||||
|
existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
|
||||||
|
? existing.labels
|
||||||
|
: {
|
||||||
|
$type: 'com.atproto.label.defs#selfLabels',
|
||||||
|
values: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggle the label
|
||||||
|
const hasLabel = existing.labels.values.some(
|
||||||
|
l => l.val === '!no-unauthenticated',
|
||||||
|
)
|
||||||
|
if (hasLabel) {
|
||||||
|
wasAdded = false
|
||||||
|
existing.labels.values = existing.labels.values.filter(
|
||||||
|
l => l.val !== '!no-unauthenticated',
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
wasAdded = true
|
||||||
|
existing.labels.values.push({val: '!no-unauthenticated'})
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete if no longer needed
|
||||||
|
if (existing.labels.values.length === 0) {
|
||||||
|
delete existing.labels
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
},
|
||||||
|
checkCommitted: res => {
|
||||||
|
const exists = !!res.data.labels?.some(
|
||||||
|
l => l.val === '!no-unauthenticated',
|
||||||
|
)
|
||||||
|
return exists === wasAdded
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [updateProfile, profile])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.pt_sm]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
|
||||||
|
<Toggle.Item
|
||||||
|
disabled={!canToggle}
|
||||||
|
value={isOptedOut}
|
||||||
|
onChange={onToggleOptOut}
|
||||||
|
name="logged_out_visibility"
|
||||||
|
label={_(
|
||||||
|
msg`Discourage apps from showing my account to logged-out users`,
|
||||||
|
)}>
|
||||||
|
<Toggle.Switch />
|
||||||
|
<Toggle.Label style={[a.text_md]}>
|
||||||
|
<Trans>
|
||||||
|
Discourage apps from showing my account to logged-out users
|
||||||
|
</Trans>
|
||||||
|
</Toggle.Label>
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
{updateProfile.isPending && <Loader />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.pt_md, a.gap_md, {paddingLeft: 38}]}>
|
||||||
|
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>
|
||||||
|
Bluesky will not show your profile and posts to logged-out users.
|
||||||
|
Other apps may not honor this request. This does not make your
|
||||||
|
account private.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>
|
||||||
|
Note: Bluesky is an open and public network. This setting only
|
||||||
|
limits the visibility of your content on the Bluesky app and
|
||||||
|
website, and other apps may not respect this setting. Your content
|
||||||
|
may still be shown to logged-out users by other apps and websites.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<InlineLink to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
|
||||||
|
<Trans>Learn more about what is public on Bluesky.</Trans>
|
||||||
|
</InlineLink>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -56,7 +56,9 @@ export function AdultContentEnabledPref({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mutate({
|
mutate({
|
||||||
enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
|
enabled: !(
|
||||||
|
variables?.enabled ?? preferences?.moderationPrefs.adultContentEnabled
|
||||||
|
),
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.show(
|
Toast.show(
|
||||||
|
@ -75,7 +77,10 @@ export function AdultContentEnabledPref({
|
||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
name={_(msg`Enable adult content in your feeds`)}
|
name={_(msg`Enable adult content in your feeds`)}
|
||||||
label={_(msg`Enable adult content in your feeds`)}
|
label={_(msg`Enable adult content in your feeds`)}
|
||||||
value={variables?.enabled ?? preferences?.adultContentEnabled}
|
value={
|
||||||
|
variables?.enabled ??
|
||||||
|
preferences?.moderationPrefs.adultContentEnabled
|
||||||
|
}
|
||||||
onChange={onToggleAdultContent}>
|
onChange={onToggleAdultContent}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
|
|
@ -1,40 +1,51 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {LabelPreference} from '@atproto/api'
|
import {LabelPreference, InterpretedLabelValueDefinition} from '@atproto/api'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import Animated, {Easing, Layout, FadeIn} from 'react-native-reanimated'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CONFIGURABLE_LABEL_GROUPS,
|
|
||||||
ConfigurableLabelGroup,
|
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
usePreferencesSetContentLabelMutation,
|
usePreferencesSetContentLabelMutation,
|
||||||
} from '#/state/queries/preferences'
|
} from '#/state/queries/preferences'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||||
|
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||||
|
|
||||||
export function ModerationOption({
|
export function ModerationOption({
|
||||||
labelGroup,
|
labelValueDefinition,
|
||||||
isMounted,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
labelGroup: ConfigurableLabelGroup
|
labelValueDefinition: InterpretedLabelValueDefinition
|
||||||
isMounted: React.MutableRefObject<boolean>
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup]
|
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||||
|
const label = labelValueDefinition.identifier
|
||||||
const visibility =
|
const visibility =
|
||||||
variables?.visibility ?? preferences?.contentLabels?.[labelGroup]
|
variables?.visibility ?? preferences?.moderationPrefs.labels?.[label]
|
||||||
|
|
||||||
|
const allLabelStrings = useGlobalLabelStrings()
|
||||||
|
const labelStrings =
|
||||||
|
labelValueDefinition.identifier in allLabelStrings
|
||||||
|
? allLabelStrings[labelValueDefinition.identifier]
|
||||||
|
: {
|
||||||
|
name: labelValueDefinition.identifier,
|
||||||
|
description: `Labeled "${labelValueDefinition.identifier}"`,
|
||||||
|
}
|
||||||
|
|
||||||
const onChange = React.useCallback(
|
const onChange = React.useCallback(
|
||||||
(vis: string[]) => {
|
(vis: string[]) => {
|
||||||
mutate({labelGroup, visibility: vis[0] as LabelPreference})
|
mutate({
|
||||||
|
label,
|
||||||
|
visibility: vis[0] as LabelPreference,
|
||||||
|
labelerDid: undefined,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[mutate, labelGroup],
|
[mutate, label],
|
||||||
)
|
)
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
|
@ -44,7 +55,7 @@ export function ModerationOption({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.justify_between,
|
a.justify_between,
|
||||||
|
@ -52,33 +63,37 @@ export function ModerationOption({
|
||||||
a.py_xs,
|
a.py_xs,
|
||||||
a.px_xs,
|
a.px_xs,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
]}
|
]}>
|
||||||
layout={Layout.easing(Easing.ease).duration(200)}
|
<View style={[a.gap_xs, a.flex_1]}>
|
||||||
entering={isMounted.current ? FadeIn : undefined}>
|
<Text style={[a.font_bold]}>{labelStrings.name}</Text>
|
||||||
<View style={[a.gap_xs, {width: '50%'}]}>
|
|
||||||
<Text style={[a.font_bold]}>{groupInfo.title}</Text>
|
|
||||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||||
{groupInfo.subtitle}
|
{labelStrings.description}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={[a.justify_center, {minHeight: 35}]}>
|
<View style={[a.justify_center, {minHeight: 40}]}>
|
||||||
|
{disabled ? (
|
||||||
|
<Text style={[a.font_bold]}>
|
||||||
|
<Trans>Hide</Trans>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
<ToggleButton.Group
|
<ToggleButton.Group
|
||||||
label={_(
|
label={_(
|
||||||
msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`,
|
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
|
||||||
)}
|
)}
|
||||||
values={[visibility ?? 'hide']}
|
values={[visibility ?? 'hide']}
|
||||||
onChange={onChange}>
|
onChange={onChange}>
|
||||||
<ToggleButton.Button name="hide" label={labels.hide}>
|
<ToggleButton.Button name="ignore" label={labels.show}>
|
||||||
{labels.hide}
|
{labels.show}
|
||||||
</ToggleButton.Button>
|
</ToggleButton.Button>
|
||||||
<ToggleButton.Button name="warn" label={labels.warn}>
|
<ToggleButton.Button name="warn" label={labels.warn}>
|
||||||
{labels.warn}
|
{labels.warn}
|
||||||
</ToggleButton.Button>
|
</ToggleButton.Button>
|
||||||
<ToggleButton.Button name="ignore" label={labels.show}>
|
<ToggleButton.Button name="hide" label={labels.hide}>
|
||||||
{labels.show}
|
{labels.hide}
|
||||||
</ToggleButton.Button>
|
</ToggleButton.Button>
|
||||||
</ToggleButton.Group>
|
</ToggleButton.Group>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,15 +2,10 @@ import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import Animated, {Easing, Layout} from 'react-native-reanimated'
|
import {LABELS} from '@atproto/api'
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {
|
import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences'
|
||||||
configurableAdultLabelGroups,
|
|
||||||
configurableOtherLabelGroups,
|
|
||||||
usePreferencesSetAdultContentMutation,
|
|
||||||
} from 'state/queries/preferences'
|
|
||||||
import {Divider} from '#/components/Divider'
|
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||||
|
@ -28,14 +23,6 @@ import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/Adult
|
||||||
import {Context} from '#/screens/Onboarding/state'
|
import {Context} from '#/screens/Onboarding/state'
|
||||||
import {IconCircle} from '#/components/IconCircle'
|
import {IconCircle} from '#/components/IconCircle'
|
||||||
|
|
||||||
function AnimatedDivider() {
|
|
||||||
return (
|
|
||||||
<Animated.View layout={Layout.easing(Easing.ease).duration(200)}>
|
|
||||||
<Divider />
|
|
||||||
</Animated.View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StepModeration() {
|
export function StepModeration() {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
|
@ -52,7 +39,7 @@ export function StepModeration() {
|
||||||
|
|
||||||
const adultContentEnabled = !!(
|
const adultContentEnabled = !!(
|
||||||
(variables && variables.enabled) ||
|
(variables && variables.enabled) ||
|
||||||
(!variables && preferences?.adultContentEnabled)
|
(!variables && preferences?.moderationPrefs.adultContentEnabled)
|
||||||
)
|
)
|
||||||
|
|
||||||
const onContinue = React.useCallback(() => {
|
const onContinue = React.useCallback(() => {
|
||||||
|
@ -86,22 +73,19 @@ export function StepModeration() {
|
||||||
<AdultContentEnabledPref mutate={mutate} variables={variables} />
|
<AdultContentEnabledPref mutate={mutate} variables={variables} />
|
||||||
|
|
||||||
<View style={[a.gap_sm, a.w_full]}>
|
<View style={[a.gap_sm, a.w_full]}>
|
||||||
{adultContentEnabled &&
|
<ModerationOption
|
||||||
configurableAdultLabelGroups.map((g, index) => (
|
labelValueDefinition={LABELS.porn}
|
||||||
<React.Fragment key={index}>
|
disabled={!adultContentEnabled}
|
||||||
{index === 0 && <AnimatedDivider />}
|
/>
|
||||||
<ModerationOption labelGroup={g} isMounted={isMounted} />
|
<ModerationOption
|
||||||
<AnimatedDivider />
|
labelValueDefinition={LABELS.sexual}
|
||||||
</React.Fragment>
|
disabled={!adultContentEnabled}
|
||||||
))}
|
/>
|
||||||
|
<ModerationOption
|
||||||
{configurableOtherLabelGroups.map((g, index) => (
|
labelValueDefinition={LABELS['graphic-media']}
|
||||||
<React.Fragment key={index}>
|
disabled={!adultContentEnabled}
|
||||||
{!adultContentEnabled && index === 0 && <AnimatedDivider />}
|
/>
|
||||||
<ModerationOption labelGroup={g} isMounted={isMounted} />
|
<ModerationOption labelValueDefinition={LABELS.nudity} />
|
||||||
<AnimatedDivider />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -88,7 +88,7 @@ export function SuggestedAccountCard({
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size={48}
|
size={48}
|
||||||
avatar={profile.avatar}
|
avatar={profile.avatar}
|
||||||
moderation={moderation.avatar}
|
moderation={moderation.ui('avatar')}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={[a.flex_1]}>
|
<View style={[a.flex_1]}>
|
||||||
|
|
|
@ -76,7 +76,7 @@ export function StepSuggestedAccounts() {
|
||||||
return aggregateInterestItems(
|
return aggregateInterestItems(
|
||||||
state.interestsStepResults.selectedInterests,
|
state.interestsStepResults.selectedInterests,
|
||||||
state.interestsStepResults.apiResponse.suggestedAccountDids,
|
state.interestsStepResults.apiResponse.suggestedAccountDids,
|
||||||
state.interestsStepResults.apiResponse.suggestedAccountDids.default,
|
state.interestsStepResults.apiResponse.suggestedAccountDids.default || [],
|
||||||
)
|
)
|
||||||
}, [state.interestsStepResults])
|
}, [state.interestsStepResults])
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
|
@ -21,7 +21,7 @@ import {
|
||||||
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
|
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
|
||||||
import {aggregateInterestItems} from '#/screens/Onboarding/util'
|
import {aggregateInterestItems} from '#/screens/Onboarding/util'
|
||||||
import {IconCircle} from '#/components/IconCircle'
|
import {IconCircle} from '#/components/IconCircle'
|
||||||
import {IS_PROD_SERVICE} from 'lib/constants'
|
import {IS_TEST_USER} from 'lib/constants'
|
||||||
import {useSession} from 'state/session'
|
import {useSession} from 'state/session'
|
||||||
|
|
||||||
export function StepTopicalFeeds() {
|
export function StepTopicalFeeds() {
|
||||||
|
@ -32,14 +32,14 @@ export function StepTopicalFeeds() {
|
||||||
const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
|
const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
|
||||||
const [saving, setSaving] = React.useState(false)
|
const [saving, setSaving] = React.useState(false)
|
||||||
const suggestedFeedUris = React.useMemo(() => {
|
const suggestedFeedUris = React.useMemo(() => {
|
||||||
if (!IS_PROD_SERVICE(currentAccount?.service)) return []
|
if (IS_TEST_USER(currentAccount?.handle)) return []
|
||||||
return aggregateInterestItems(
|
return aggregateInterestItems(
|
||||||
state.interestsStepResults.selectedInterests,
|
state.interestsStepResults.selectedInterests,
|
||||||
state.interestsStepResults.apiResponse.suggestedFeedUris,
|
state.interestsStepResults.apiResponse.suggestedFeedUris,
|
||||||
state.interestsStepResults.apiResponse.suggestedFeedUris.default,
|
state.interestsStepResults.apiResponse.suggestedFeedUris.default || [],
|
||||||
).slice(0, 10)
|
).slice(0, 10)
|
||||||
}, [
|
}, [
|
||||||
currentAccount?.service,
|
currentAccount?.handle,
|
||||||
state.interestsStepResults.apiResponse.suggestedFeedUris,
|
state.interestsStepResults.apiResponse.suggestedFeedUris,
|
||||||
state.interestsStepResults.selectedInterests,
|
state.interestsStepResults.selectedInterests,
|
||||||
])
|
])
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Trans, msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useTheme, atoms as a} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
|
||||||
|
export function ErrorState({error}: {error: string}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const onPressBack = React.useCallback(() => {
|
||||||
|
if (navigation.canGoBack()) {
|
||||||
|
navigation.goBack()
|
||||||
|
} else {
|
||||||
|
navigation.navigate('Home')
|
||||||
|
}
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.px_xl]}>
|
||||||
|
<CircleInfo width={48} style={[t.atoms.text_contrast_low]} />
|
||||||
|
|
||||||
|
<Text style={[a.text_xl, a.font_bold, a.pb_md, a.pt_xl]}>
|
||||||
|
<Trans>Hmmmm, we couldn't load that moderation service.</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.leading_normal,
|
||||||
|
a.pb_md,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
This moderation service is unavailable. See below for more details. If
|
||||||
|
this issue persists, contact us.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.py_md,
|
||||||
|
a.px_lg,
|
||||||
|
a.rounded_md,
|
||||||
|
a.mb_2xl,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_md, a.leading_normal]}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{flexDirection: 'row'}}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="solid"
|
||||||
|
label={_(msg`Go Back`)}
|
||||||
|
accessibilityHint="Return to previous page"
|
||||||
|
onPress={onPressBack}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Go Back</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
|
||||||
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
|
import {Shadow} from '#/state/cache/types'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function ProfileHeaderDisplayName({
|
||||||
|
profile,
|
||||||
|
moderation,
|
||||||
|
}: {
|
||||||
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||||
|
moderation: ModerationDecision
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View pointerEvents="none">
|
||||||
|
<Text
|
||||||
|
testID="profileHeaderDisplayName"
|
||||||
|
style={[t.atoms.text, a.text_4xl, {fontWeight: '500'}]}>
|
||||||
|
{sanitizeDisplayName(
|
||||||
|
profile.displayName || sanitizeHandle(profile.handle),
|
||||||
|
moderation.ui('displayName'),
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyActorDefs} from '@atproto/api'
|
||||||
|
import {isInvalidHandle} from 'lib/strings/handles'
|
||||||
|
import {Shadow} from '#/state/cache/types'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function ProfileHeaderHandle({
|
||||||
|
profile,
|
||||||
|
}: {
|
||||||
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const invalidHandle = isInvalidHandle(profile.handle)
|
||||||
|
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_row, a.gap_xs, a.align_center]} pointerEvents="none">
|
||||||
|
{profile.viewer?.followedBy && !blockHide ? (
|
||||||
|
<View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
|
||||||
|
<Text style={[t.atoms.text, a.text_sm]}>
|
||||||
|
<Trans>Follows you</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
invalidHandle
|
||||||
|
? [
|
||||||
|
a.border,
|
||||||
|
a.text_xs,
|
||||||
|
a.px_sm,
|
||||||
|
a.py_xs,
|
||||||
|
a.rounded_xs,
|
||||||
|
{borderColor: t.palette.contrast_200},
|
||||||
|
]
|
||||||
|
: [a.text_md, t.atoms.text_contrast_medium],
|
||||||
|
web({wordBreak: 'break-all'}),
|
||||||
|
]}>
|
||||||
|
{invalidHandle ? <Trans>⚠Invalid Handle</Trans> : `@${profile.handle}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyActorDefs} from '@atproto/api'
|
||||||
|
import {Trans, msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {Shadow} from '#/state/cache/types'
|
||||||
|
import {pluralize} from '#/lib/strings/helpers'
|
||||||
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
|
import {formatCount} from 'view/com/util/numeric/format'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {InlineLink} from '#/components/Link'
|
||||||
|
|
||||||
|
export function ProfileHeaderMetrics({
|
||||||
|
profile,
|
||||||
|
}: {
|
||||||
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const following = formatCount(profile.followsCount || 0)
|
||||||
|
const followers = formatCount(profile.followersCount || 0)
|
||||||
|
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[a.flex_row, a.gap_sm, a.align_center, a.pb_md]}
|
||||||
|
pointerEvents="box-none">
|
||||||
|
<InlineLink
|
||||||
|
testID="profileHeaderFollowersButton"
|
||||||
|
style={[a.flex_row, t.atoms.text]}
|
||||||
|
to={makeProfileLink(profile, 'followers')}
|
||||||
|
label={`${followers} ${pluralizedFollowers}`}>
|
||||||
|
<Text style={[a.font_bold, a.text_md]}>{followers} </Text>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
|
||||||
|
{pluralizedFollowers}
|
||||||
|
</Text>
|
||||||
|
</InlineLink>
|
||||||
|
<InlineLink
|
||||||
|
testID="profileHeaderFollowsButton"
|
||||||
|
style={[a.flex_row, t.atoms.text]}
|
||||||
|
to={makeProfileLink(profile, 'follows')}
|
||||||
|
label={_(msg`${following} following`)}>
|
||||||
|
<Trans>
|
||||||
|
<Text style={[a.font_bold, a.text_md]}>{following} </Text>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
|
||||||
|
following
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
</InlineLink>
|
||||||
|
<Text style={[a.font_bold, t.atoms.text, a.text_md]}>
|
||||||
|
{formatCount(profile.postsCount || 0)}{' '}
|
||||||
|
<Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
|
||||||
|
{pluralize(profile.postsCount || 0, 'post')}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,329 @@
|
||||||
|
import React, {memo, useMemo} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
|
AppBskyLabelerDefs,
|
||||||
|
ModerationOpts,
|
||||||
|
moderateProfile,
|
||||||
|
RichText as RichTextAPI,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {Trans, msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {RichText} from '#/components/RichText'
|
||||||
|
import {useModalControls} from '#/state/modals'
|
||||||
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {Shadow} from '#/state/cache/types'
|
||||||
|
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||||
|
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
|
||||||
|
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {Haptics} from '#/lib/haptics'
|
||||||
|
import {pluralize} from '#/lib/strings/helpers'
|
||||||
|
import {isAppLabeler} from '#/lib/moderation'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme, tokens} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {ProfileHeaderShell} from './Shell'
|
||||||
|
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||||
|
import {ProfileHeaderDisplayName} from './DisplayName'
|
||||||
|
import {ProfileHeaderHandle} from './Handle'
|
||||||
|
import {ProfileHeaderMetrics} from './Metrics'
|
||||||
|
import {
|
||||||
|
Heart2_Stroke2_Corner0_Rounded as Heart,
|
||||||
|
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
||||||
|
} from '#/components/icons/Heart2'
|
||||||
|
import {DialogOuterProps} from '#/components/Dialog'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||||
|
descriptionRT: RichTextAPI | null
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
hideBackButton?: boolean
|
||||||
|
isPlaceholderProfile?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let ProfileHeaderLabeler = ({
|
||||||
|
profile: profileUnshadowed,
|
||||||
|
labeler,
|
||||||
|
descriptionRT,
|
||||||
|
moderationOpts,
|
||||||
|
hideBackButton = false,
|
||||||
|
isPlaceholderProfile,
|
||||||
|
}: Props): React.ReactNode => {
|
||||||
|
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
||||||
|
useProfileShadow(profileUnshadowed)
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {currentAccount, hasSession} = useSession()
|
||||||
|
const {openModal} = useModalControls()
|
||||||
|
const {track} = useAnalytics()
|
||||||
|
const cantSubscribePrompt = Prompt.usePromptControl()
|
||||||
|
const isSelf = currentAccount?.did === profile.did
|
||||||
|
|
||||||
|
const moderation = useMemo(
|
||||||
|
() => moderateProfile(profile, moderationOpts),
|
||||||
|
[profile, moderationOpts],
|
||||||
|
)
|
||||||
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
const {mutateAsync: toggleSubscription, variables} =
|
||||||
|
useLabelerSubscriptionMutation()
|
||||||
|
const isSubscribed =
|
||||||
|
variables?.subscribe ??
|
||||||
|
preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
|
||||||
|
const canSubscribe =
|
||||||
|
isSubscribed ||
|
||||||
|
(preferences ? preferences?.moderationPrefs.labelers.length < 9 : false)
|
||||||
|
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
|
||||||
|
const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
|
||||||
|
useUnlikeMutation()
|
||||||
|
const [likeUri, setLikeUri] = React.useState<string>(
|
||||||
|
labeler.viewer?.like || '',
|
||||||
|
)
|
||||||
|
const [likeCount, setLikeCount] = React.useState(labeler.likeCount || 0)
|
||||||
|
|
||||||
|
const onToggleLiked = React.useCallback(async () => {
|
||||||
|
if (!labeler) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Haptics.default()
|
||||||
|
|
||||||
|
if (likeUri) {
|
||||||
|
await unlikeMod({uri: likeUri})
|
||||||
|
track('CustomFeed:Unlike')
|
||||||
|
setLikeCount(c => c - 1)
|
||||||
|
setLikeUri('')
|
||||||
|
} else {
|
||||||
|
const res = await likeMod({uri: labeler.uri, cid: labeler.cid})
|
||||||
|
track('CustomFeed:Like')
|
||||||
|
setLikeCount(c => c + 1)
|
||||||
|
setLikeUri(res.uri)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
Toast.show(
|
||||||
|
_(
|
||||||
|
msg`There was an an issue contacting the server, please check your internet connection and try again.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.error(`Failed to toggle labeler like`, {message: e.message})
|
||||||
|
}
|
||||||
|
}, [labeler, likeUri, likeMod, unlikeMod, track, _])
|
||||||
|
|
||||||
|
const onPressEditProfile = React.useCallback(() => {
|
||||||
|
track('ProfileHeader:EditProfileButtonClicked')
|
||||||
|
openModal({
|
||||||
|
name: 'edit-profile',
|
||||||
|
profile,
|
||||||
|
})
|
||||||
|
}, [track, openModal, profile])
|
||||||
|
|
||||||
|
const onPressSubscribe = React.useCallback(async () => {
|
||||||
|
if (!canSubscribe) {
|
||||||
|
cantSubscribePrompt.open()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await toggleSubscription({
|
||||||
|
did: profile.did,
|
||||||
|
subscribe: !isSubscribed,
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
// setSubscriptionError(e.message)
|
||||||
|
logger.error(`Failed to subscribe to labeler`, {message: e.message})
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
toggleSubscription,
|
||||||
|
isSubscribed,
|
||||||
|
profile,
|
||||||
|
canSubscribe,
|
||||||
|
cantSubscribePrompt,
|
||||||
|
])
|
||||||
|
|
||||||
|
const isMe = React.useMemo(
|
||||||
|
() => currentAccount?.did === profile.did,
|
||||||
|
[currentAccount, profile],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileHeaderShell
|
||||||
|
profile={profile}
|
||||||
|
moderation={moderation}
|
||||||
|
hideBackButton={hideBackButton}
|
||||||
|
isPlaceholderProfile={isPlaceholderProfile}>
|
||||||
|
<View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none">
|
||||||
|
<View
|
||||||
|
style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]}
|
||||||
|
pointerEvents="box-none">
|
||||||
|
{isMe ? (
|
||||||
|
<Button
|
||||||
|
testID="profileHeaderEditProfileButton"
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="solid"
|
||||||
|
onPress={onPressEditProfile}
|
||||||
|
label={_(msg`Edit profile`)}
|
||||||
|
style={a.rounded_full}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Edit Profile</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
) : !isAppLabeler(profile.did) ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
testID="toggleSubscribeBtn"
|
||||||
|
label={
|
||||||
|
isSubscribed
|
||||||
|
? _(msg`Unsubscribe from this labeler`)
|
||||||
|
: _(msg`Subscribe to this labeler`)
|
||||||
|
}
|
||||||
|
disabled={!hasSession}
|
||||||
|
onPress={onPressSubscribe}>
|
||||||
|
{state => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
paddingVertical: 12,
|
||||||
|
backgroundColor:
|
||||||
|
isSubscribed || !canSubscribe
|
||||||
|
? state.hovered || state.pressed
|
||||||
|
? t.palette.contrast_50
|
||||||
|
: t.palette.contrast_25
|
||||||
|
: state.hovered || state.pressed
|
||||||
|
? tokens.color.temp_purple_dark
|
||||||
|
: tokens.color.temp_purple,
|
||||||
|
},
|
||||||
|
a.px_lg,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
color: canSubscribe
|
||||||
|
? isSubscribed
|
||||||
|
? t.palette.contrast_700
|
||||||
|
: t.palette.white
|
||||||
|
: t.palette.contrast_400,
|
||||||
|
},
|
||||||
|
a.font_bold,
|
||||||
|
a.text_center,
|
||||||
|
]}>
|
||||||
|
{isSubscribed ? (
|
||||||
|
<Trans>Unsubscribe</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Subscribe to Labeler</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<ProfileMenu profile={profile} />
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_col, a.gap_xs, a.pb_md]}>
|
||||||
|
<ProfileHeaderDisplayName profile={profile} moderation={moderation} />
|
||||||
|
<ProfileHeaderHandle profile={profile} />
|
||||||
|
</View>
|
||||||
|
{!isPlaceholderProfile && (
|
||||||
|
<>
|
||||||
|
{isSelf && <ProfileHeaderMetrics profile={profile} />}
|
||||||
|
{descriptionRT && !moderation.ui('profileView').blur ? (
|
||||||
|
<View pointerEvents="auto">
|
||||||
|
<RichText
|
||||||
|
testID="profileHeaderDescription"
|
||||||
|
style={[a.text_md]}
|
||||||
|
numberOfLines={15}
|
||||||
|
value={descriptionRT}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
|
{!isAppLabeler(profile.did) && (
|
||||||
|
<View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}>
|
||||||
|
<Button
|
||||||
|
testID="toggleLikeBtn"
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="solid"
|
||||||
|
shape="round"
|
||||||
|
label={_(msg`Like this feed`)}
|
||||||
|
disabled={!hasSession || isLikePending || isUnlikePending}
|
||||||
|
onPress={onToggleLiked}>
|
||||||
|
{likeUri ? (
|
||||||
|
<HeartFilled fill={t.palette.negative_400} />
|
||||||
|
) : (
|
||||||
|
<Heart fill={t.atoms.text_contrast_medium.color} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{typeof likeCount === 'number' && (
|
||||||
|
<Link
|
||||||
|
to={{
|
||||||
|
screen: 'ProfileLabelerLikedBy',
|
||||||
|
params: {
|
||||||
|
name: labeler.creator.handle || labeler.creator.did,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
size="tiny"
|
||||||
|
label={_(
|
||||||
|
msg`Liked by ${likeCount} ${pluralize(
|
||||||
|
likeCount,
|
||||||
|
'user',
|
||||||
|
)}`,
|
||||||
|
)}>
|
||||||
|
{({hovered, focused, pressed}) => (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
a.text_sm,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
(hovered || focused || pressed) &&
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
Liked by {likeCount} {pluralize(likeCount, 'user')}
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<CantSubscribePrompt control={cantSubscribePrompt} />
|
||||||
|
</ProfileHeaderShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ProfileHeaderLabeler = memo(ProfileHeaderLabeler)
|
||||||
|
export {ProfileHeaderLabeler}
|
||||||
|
|
||||||
|
function CantSubscribePrompt({
|
||||||
|
control,
|
||||||
|
}: {
|
||||||
|
control: DialogOuterProps['control']
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Prompt.Outer control={control}>
|
||||||
|
<Prompt.Title>Unable to subscribe</Prompt.Title>
|
||||||
|
<Prompt.Description>
|
||||||
|
<Trans>
|
||||||
|
We're sorry! You can only subscribe to ten labelers, and you've
|
||||||
|
reached your limit of ten.
|
||||||
|
</Trans>
|
||||||
|
</Prompt.Description>
|
||||||
|
<Prompt.Actions>
|
||||||
|
<Prompt.Action onPress={control.close}>OK</Prompt.Action>
|
||||||
|
</Prompt.Actions>
|
||||||
|
</Prompt.Outer>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,286 @@
|
||||||
|
import React, {memo, useMemo} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
|
ModerationOpts,
|
||||||
|
moderateProfile,
|
||||||
|
RichText as RichTextAPI,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {Trans, msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
|
||||||
|
import {useModalControls} from '#/state/modals'
|
||||||
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
|
import {useSession, useRequireAuth} from '#/state/session'
|
||||||
|
import {Shadow} from '#/state/cache/types'
|
||||||
|
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||||
|
import {
|
||||||
|
useProfileFollowMutationQueue,
|
||||||
|
useProfileBlockMutationQueue,
|
||||||
|
} from '#/state/queries/profile'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {ProfileHeaderShell} from './Shell'
|
||||||
|
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||||
|
import {ProfileHeaderDisplayName} from './DisplayName'
|
||||||
|
import {ProfileHeaderHandle} from './Handle'
|
||||||
|
import {ProfileHeaderMetrics} from './Metrics'
|
||||||
|
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
|
||||||
|
import {RichText} from '#/components/RichText'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
descriptionRT: RichTextAPI | null
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
hideBackButton?: boolean
|
||||||
|
isPlaceholderProfile?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let ProfileHeaderStandard = ({
|
||||||
|
profile: profileUnshadowed,
|
||||||
|
descriptionRT,
|
||||||
|
moderationOpts,
|
||||||
|
hideBackButton = false,
|
||||||
|
isPlaceholderProfile,
|
||||||
|
}: Props): React.ReactNode => {
|
||||||
|
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
||||||
|
useProfileShadow(profileUnshadowed)
|
||||||
|
const t = useTheme()
|
||||||
|
const {currentAccount, hasSession} = useSession()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {openModal} = useModalControls()
|
||||||
|
const {track} = useAnalytics()
|
||||||
|
const moderation = useMemo(
|
||||||
|
() => moderateProfile(profile, moderationOpts),
|
||||||
|
[profile, moderationOpts],
|
||||||
|
)
|
||||||
|
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
|
||||||
|
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||||
|
profile,
|
||||||
|
'ProfileHeader',
|
||||||
|
)
|
||||||
|
const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||||
|
const unblockPromptControl = Prompt.usePromptControl()
|
||||||
|
const requireAuth = useRequireAuth()
|
||||||
|
|
||||||
|
const onPressEditProfile = React.useCallback(() => {
|
||||||
|
track('ProfileHeader:EditProfileButtonClicked')
|
||||||
|
openModal({
|
||||||
|
name: 'edit-profile',
|
||||||
|
profile,
|
||||||
|
})
|
||||||
|
}, [track, openModal, profile])
|
||||||
|
|
||||||
|
const onPressFollow = () => {
|
||||||
|
requireAuth(async () => {
|
||||||
|
try {
|
||||||
|
track('ProfileHeader:FollowButtonClicked')
|
||||||
|
await queueFollow()
|
||||||
|
Toast.show(
|
||||||
|
_(
|
||||||
|
msg`Following ${sanitizeDisplayName(
|
||||||
|
profile.displayName || profile.handle,
|
||||||
|
moderation.ui('displayName'),
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
|
logger.error('Failed to follow', {message: String(e)})
|
||||||
|
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPressUnfollow = () => {
|
||||||
|
requireAuth(async () => {
|
||||||
|
try {
|
||||||
|
track('ProfileHeader:UnfollowButtonClicked')
|
||||||
|
await queueUnfollow()
|
||||||
|
Toast.show(
|
||||||
|
_(
|
||||||
|
msg`No longer following ${sanitizeDisplayName(
|
||||||
|
profile.displayName || profile.handle,
|
||||||
|
moderation.ui('displayName'),
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
|
logger.error('Failed to unfollow', {message: String(e)})
|
||||||
|
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unblockAccount = React.useCallback(async () => {
|
||||||
|
track('ProfileHeader:UnblockAccountButtonClicked')
|
||||||
|
try {
|
||||||
|
await queueUnblock()
|
||||||
|
Toast.show(_(msg`Account unblocked`))
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
|
logger.error('Failed to unblock account', {message: e})
|
||||||
|
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [_, queueUnblock, track])
|
||||||
|
|
||||||
|
const isMe = React.useMemo(
|
||||||
|
() => currentAccount?.did === profile.did,
|
||||||
|
[currentAccount, profile],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileHeaderShell
|
||||||
|
profile={profile}
|
||||||
|
moderation={moderation}
|
||||||
|
hideBackButton={hideBackButton}
|
||||||
|
isPlaceholderProfile={isPlaceholderProfile}>
|
||||||
|
<View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none">
|
||||||
|
<View
|
||||||
|
style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_sm]}
|
||||||
|
pointerEvents="box-none">
|
||||||
|
{isMe ? (
|
||||||
|
<Button
|
||||||
|
testID="profileHeaderEditProfileButton"
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="solid"
|
||||||
|
onPress={onPressEditProfile}
|
||||||
|
label={_(msg`Edit profile`)}
|
||||||
|
style={a.rounded_full}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Edit Profile</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
) : profile.viewer?.blocking ? (
|
||||||
|
profile.viewer?.blockingByList ? null : (
|
||||||
|
<Button
|
||||||
|
testID="unblockBtn"
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="solid"
|
||||||
|
label={_(msg`Unblock`)}
|
||||||
|
disabled={!hasSession}
|
||||||
|
onPress={() => unblockPromptControl.open()}
|
||||||
|
style={a.rounded_full}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans context="action">Unblock</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : !profile.viewer?.blockedBy ? (
|
||||||
|
<>
|
||||||
|
{hasSession && (
|
||||||
|
<Button
|
||||||
|
testID="suggestedFollowsBtn"
|
||||||
|
size="small"
|
||||||
|
color={showSuggestedFollows ? 'primary' : 'secondary'}
|
||||||
|
variant="solid"
|
||||||
|
shape="round"
|
||||||
|
onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
|
||||||
|
label={_(msg`Show follows similar to ${profile.handle}`)}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="user-plus"
|
||||||
|
style={
|
||||||
|
showSuggestedFollows
|
||||||
|
? {color: t.palette.white}
|
||||||
|
: t.atoms.text
|
||||||
|
}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
|
||||||
|
size="small"
|
||||||
|
color={profile.viewer?.following ? 'secondary' : 'primary'}
|
||||||
|
variant="solid"
|
||||||
|
label={
|
||||||
|
profile.viewer?.following
|
||||||
|
? _(msg`Unfollow ${profile.handle}`)
|
||||||
|
: _(msg`Follow ${profile.handle}`)
|
||||||
|
}
|
||||||
|
disabled={!hasSession}
|
||||||
|
onPress={
|
||||||
|
profile.viewer?.following ? onPressUnfollow : onPressFollow
|
||||||
|
}
|
||||||
|
style={[a.rounded_full, a.gap_xs]}>
|
||||||
|
<ButtonIcon
|
||||||
|
position="left"
|
||||||
|
icon={profile.viewer?.following ? Check : Plus}
|
||||||
|
/>
|
||||||
|
<ButtonText>
|
||||||
|
{profile.viewer?.following ? (
|
||||||
|
<Trans>Following</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Follow</Trans>
|
||||||
|
)}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<ProfileMenu profile={profile} />
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_col, a.gap_xs, a.pb_sm]}>
|
||||||
|
<ProfileHeaderDisplayName profile={profile} moderation={moderation} />
|
||||||
|
<ProfileHeaderHandle profile={profile} />
|
||||||
|
</View>
|
||||||
|
{!isPlaceholderProfile && (
|
||||||
|
<>
|
||||||
|
<ProfileHeaderMetrics profile={profile} />
|
||||||
|
{descriptionRT && !moderation.ui('profileView').blur ? (
|
||||||
|
<View pointerEvents="auto">
|
||||||
|
<RichText
|
||||||
|
testID="profileHeaderDescription"
|
||||||
|
style={[a.text_md]}
|
||||||
|
numberOfLines={15}
|
||||||
|
value={descriptionRT}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{showSuggestedFollows && (
|
||||||
|
<ProfileHeaderSuggestedFollows
|
||||||
|
actorDid={profile.did}
|
||||||
|
requestDismiss={() => {
|
||||||
|
if (showSuggestedFollows) {
|
||||||
|
setShowSuggestedFollows(false)
|
||||||
|
} else {
|
||||||
|
track('ProfileHeader:SuggestedFollowsOpened')
|
||||||
|
setShowSuggestedFollows(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Prompt.Basic
|
||||||
|
control={unblockPromptControl}
|
||||||
|
title={_(msg`Unblock Account?`)}
|
||||||
|
description={_(
|
||||||
|
msg`The account will be able to interact with you after unblocking.`,
|
||||||
|
)}
|
||||||
|
onConfirm={unblockAccount}
|
||||||
|
confirmButtonCta={
|
||||||
|
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
|
||||||
|
}
|
||||||
|
confirmButtonColor="negative"
|
||||||
|
/>
|
||||||
|
</ProfileHeaderShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ProfileHeaderStandard = memo(ProfileHeaderStandard)
|
||||||
|
export {ProfileHeaderStandard}
|
|
@ -0,0 +1,164 @@
|
||||||
|
import React, {memo} from 'react'
|
||||||
|
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {BACK_HITSLOP} from 'lib/constants'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {Shadow} from '#/state/cache/types'
|
||||||
|
import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
|
||||||
|
import {BlurView} from 'view/com/util/BlurView'
|
||||||
|
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
||||||
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
|
import {UserBanner} from 'view/com/util/UserBanner'
|
||||||
|
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||||
|
moderation: ModerationDecision
|
||||||
|
hideBackButton?: boolean
|
||||||
|
isPlaceholderProfile?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let ProfileHeaderShell = ({
|
||||||
|
children,
|
||||||
|
profile,
|
||||||
|
moderation,
|
||||||
|
hideBackButton = false,
|
||||||
|
isPlaceholderProfile,
|
||||||
|
}: React.PropsWithChildren<Props>): React.ReactNode => {
|
||||||
|
const t = useTheme()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {openLightbox} = useLightboxControls()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const {isDesktop} = useWebMediaQueries()
|
||||||
|
|
||||||
|
const onPressBack = React.useCallback(() => {
|
||||||
|
if (navigation.canGoBack()) {
|
||||||
|
navigation.goBack()
|
||||||
|
} else {
|
||||||
|
navigation.navigate('Home')
|
||||||
|
}
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
|
const onPressAvi = React.useCallback(() => {
|
||||||
|
const modui = moderation.ui('avatar')
|
||||||
|
if (profile.avatar && !(modui.blur && modui.noOverride)) {
|
||||||
|
openLightbox(new ProfileImageLightbox(profile))
|
||||||
|
}
|
||||||
|
}, [openLightbox, profile, moderation])
|
||||||
|
|
||||||
|
const isMe = React.useMemo(
|
||||||
|
() => currentAccount?.did === profile.did,
|
||||||
|
[currentAccount, profile],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={t.atoms.bg} pointerEvents="box-none">
|
||||||
|
<View pointerEvents="none">
|
||||||
|
{isPlaceholderProfile ? (
|
||||||
|
<LoadingPlaceholder
|
||||||
|
width="100%"
|
||||||
|
height={150}
|
||||||
|
style={{borderRadius: 0}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserBanner
|
||||||
|
type={profile.associated?.labeler ? 'labeler' : 'default'}
|
||||||
|
banner={profile.banner}
|
||||||
|
moderation={moderation.ui('banner')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<View style={[a.px_lg, a.pb_sm]} pointerEvents="box-none">
|
||||||
|
<ProfileHeaderAlerts moderation={moderation} />
|
||||||
|
{isMe && (
|
||||||
|
<LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!isDesktop && !hideBackButton && (
|
||||||
|
<TouchableWithoutFeedback
|
||||||
|
testID="profileHeaderBackBtn"
|
||||||
|
onPress={onPressBack}
|
||||||
|
hitSlop={BACK_HITSLOP}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Back`)}
|
||||||
|
accessibilityHint="">
|
||||||
|
<View style={styles.backBtnWrapper}>
|
||||||
|
<BlurView style={styles.backBtn} blurType="dark">
|
||||||
|
<FontAwesomeIcon size={18} icon="angle-left" color="white" />
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
)}
|
||||||
|
<TouchableWithoutFeedback
|
||||||
|
testID="profileHeaderAviButton"
|
||||||
|
onPress={onPressAvi}
|
||||||
|
accessibilityRole="image"
|
||||||
|
accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
|
||||||
|
accessibilityHint="">
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.bg,
|
||||||
|
{borderColor: t.atoms.bg.backgroundColor},
|
||||||
|
styles.avi,
|
||||||
|
profile.associated?.labeler && styles.aviLabeler,
|
||||||
|
]}>
|
||||||
|
<UserAvatar
|
||||||
|
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
size={90}
|
||||||
|
avatar={profile.avatar}
|
||||||
|
moderation={moderation.ui('avatar')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ProfileHeaderShell = memo(ProfileHeaderShell)
|
||||||
|
export {ProfileHeaderShell}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backBtnWrapper: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 15,
|
||||||
|
// @ts-ignore web only
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 15,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
avi: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 110,
|
||||||
|
left: 10,
|
||||||
|
width: 94,
|
||||||
|
height: 94,
|
||||||
|
borderRadius: 47,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
aviLabeler: {
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
})
|
|
@ -0,0 +1,78 @@
|
||||||
|
import React, {memo} from 'react'
|
||||||
|
import {StyleSheet, View} from 'react-native'
|
||||||
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
|
AppBskyLabelerDefs,
|
||||||
|
ModerationOpts,
|
||||||
|
RichText as RichTextAPI,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
|
||||||
|
import {ProfileHeaderStandard} from './ProfileHeaderStandard'
|
||||||
|
import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
|
||||||
|
|
||||||
|
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
return (
|
||||||
|
<View style={pal.view}>
|
||||||
|
<LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
|
||||||
|
<View
|
||||||
|
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
|
||||||
|
<LoadingPlaceholder width={80} height={80} style={styles.br40} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={[styles.buttonsLine]}>
|
||||||
|
<LoadingPlaceholder width={167} height={31} style={styles.br50} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ProfileHeaderLoading = memo(ProfileHeaderLoading)
|
||||||
|
export {ProfileHeaderLoading}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
|
||||||
|
descriptionRT: RichTextAPI | null
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
hideBackButton?: boolean
|
||||||
|
isPlaceholderProfile?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let ProfileHeader = (props: Props): React.ReactNode => {
|
||||||
|
if (props.profile.associated?.labeler) {
|
||||||
|
if (!props.labeler) {
|
||||||
|
return <ProfileHeaderLoading />
|
||||||
|
}
|
||||||
|
return <ProfileHeaderLabeler {...props} labeler={props.labeler} />
|
||||||
|
}
|
||||||
|
return <ProfileHeaderStandard {...props} />
|
||||||
|
}
|
||||||
|
ProfileHeader = memo(ProfileHeader)
|
||||||
|
export {ProfileHeader}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
avi: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 110,
|
||||||
|
left: 10,
|
||||||
|
width: 84,
|
||||||
|
height: 84,
|
||||||
|
borderRadius: 42,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingBottom: 4,
|
||||||
|
},
|
||||||
|
buttonsLine: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
br40: {borderRadius: 40},
|
||||||
|
br50: {borderRadius: 50},
|
||||||
|
})
|
|
@ -0,0 +1,46 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
|
||||||
|
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||||
|
import {LikedByList} from '#/components/LikedByList'
|
||||||
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||||
|
|
||||||
|
import {atoms as a, useBreakpoints} from '#/alf'
|
||||||
|
|
||||||
|
export function ProfileLabelerLikedByScreen({
|
||||||
|
route,
|
||||||
|
}: NativeStackScreenProps<CommonNavigatorParams, 'ProfileLabelerLikedBy'>) {
|
||||||
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
|
const {name: handleOrDid} = route.params
|
||||||
|
const uri = makeRecordUri(handleOrDid, 'app.bsky.labeler.service', 'self')
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
setMinimalShellMode(false)
|
||||||
|
}, [setMinimalShellMode]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.mx_auto,
|
||||||
|
a.w_full,
|
||||||
|
a.h_full_vh,
|
||||||
|
gtMobile && [
|
||||||
|
{
|
||||||
|
maxWidth: 600,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]}>
|
||||||
|
<ViewHeader title={_(msg`Liked By`)} />
|
||||||
|
<LikedByList uri={uri} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {ListRef} from 'view/com/util/List'
|
||||||
|
import {Feed} from 'view/com/posts/Feed'
|
||||||
|
import {EmptyState} from 'view/com/util/EmptyState'
|
||||||
|
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||||
|
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||||
|
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||||
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {SectionRef} from './types'
|
||||||
|
|
||||||
|
interface FeedSectionProps {
|
||||||
|
feed: FeedDescriptor
|
||||||
|
headerHeight: number
|
||||||
|
isFocused: boolean
|
||||||
|
scrollElRef: ListRef
|
||||||
|
ignoreFilterFor?: string
|
||||||
|
}
|
||||||
|
export const ProfileFeedSection = React.forwardRef<
|
||||||
|
SectionRef,
|
||||||
|
FeedSectionProps
|
||||||
|
>(function FeedSectionImpl(
|
||||||
|
{feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [hasNew, setHasNew] = React.useState(false)
|
||||||
|
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
||||||
|
|
||||||
|
const onScrollToTop = React.useCallback(() => {
|
||||||
|
scrollElRef.current?.scrollToOffset({
|
||||||
|
animated: isNative,
|
||||||
|
offset: -headerHeight,
|
||||||
|
})
|
||||||
|
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
|
||||||
|
setHasNew(false)
|
||||||
|
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
scrollToTop: onScrollToTop,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const renderPostsEmpty = React.useCallback(() => {
|
||||||
|
return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
|
||||||
|
}, [_])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Feed
|
||||||
|
testID="postsFeed"
|
||||||
|
enabled={isFocused}
|
||||||
|
feed={feed}
|
||||||
|
scrollElRef={scrollElRef}
|
||||||
|
onHasNew={setHasNew}
|
||||||
|
onScrolledDownChange={setIsScrolledDown}
|
||||||
|
renderEmptyState={renderPostsEmpty}
|
||||||
|
headerOffset={headerHeight}
|
||||||
|
renderEndOfFeed={ProfileEndOfFeed}
|
||||||
|
ignoreFilterFor={ignoreFilterFor}
|
||||||
|
/>
|
||||||
|
{(isScrolledDown || hasNew) && (
|
||||||
|
<LoadLatestBtn
|
||||||
|
onPress={onScrollToTop}
|
||||||
|
label={_(msg`Load new posts`)}
|
||||||
|
showIndicator={hasNew}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function ProfileEndOfFeed() {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}>
|
||||||
|
<Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}>
|
||||||
|
<Trans>End of feed</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,233 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {
|
||||||
|
AppBskyLabelerDefs,
|
||||||
|
ModerationOpts,
|
||||||
|
interpretLabelValueDefinitions,
|
||||||
|
InterpretedLabelValueDefinition,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {Trans, msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useSafeAreaFrame} from 'react-native-safe-area-context'
|
||||||
|
|
||||||
|
import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||||
|
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||||
|
import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation'
|
||||||
|
import {ListRef} from '#/view/com/util/List'
|
||||||
|
import {SectionRef} from './types'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
|
||||||
|
import {useTheme, atoms as a} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {CenteredView, ScrollView} from '#/view/com/util/Views'
|
||||||
|
import {ErrorState} from '../ErrorState'
|
||||||
|
import {ModerationLabelPref} from '#/components/moderation/ModerationLabelPref'
|
||||||
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
|
|
||||||
|
interface LabelsSectionProps {
|
||||||
|
isLabelerLoading: boolean
|
||||||
|
labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined
|
||||||
|
labelerError: Error | null
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
scrollElRef: ListRef
|
||||||
|
headerHeight: number
|
||||||
|
}
|
||||||
|
export const ProfileLabelsSection = React.forwardRef<
|
||||||
|
SectionRef,
|
||||||
|
LabelsSectionProps
|
||||||
|
>(function LabelsSectionImpl(
|
||||||
|
{
|
||||||
|
isLabelerLoading,
|
||||||
|
labelerInfo,
|
||||||
|
labelerError,
|
||||||
|
moderationOpts,
|
||||||
|
scrollElRef,
|
||||||
|
headerHeight,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {height: minHeight} = useSafeAreaFrame()
|
||||||
|
|
||||||
|
const onScrollToTop = React.useCallback(() => {
|
||||||
|
// @ts-ignore TODO fix this
|
||||||
|
scrollElRef.current?.scrollTo({
|
||||||
|
animated: isNative,
|
||||||
|
x: 0,
|
||||||
|
y: -headerHeight,
|
||||||
|
})
|
||||||
|
}, [scrollElRef, headerHeight])
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
scrollToTop: onScrollToTop,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredView>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.border_l,
|
||||||
|
a.border_r,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
{
|
||||||
|
minHeight,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{isLabelerLoading ? (
|
||||||
|
<View style={[a.w_full, a.align_center]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
) : labelerError || !labelerInfo ? (
|
||||||
|
<ErrorState
|
||||||
|
error={
|
||||||
|
labelerError?.toString() ||
|
||||||
|
_(msg`Something went wrong, please try again.`)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProfileLabelsSectionInner
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
labelerInfo={labelerInfo}
|
||||||
|
scrollElRef={scrollElRef}
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ProfileLabelsSectionInner({
|
||||||
|
moderationOpts,
|
||||||
|
labelerInfo,
|
||||||
|
scrollElRef,
|
||||||
|
headerHeight,
|
||||||
|
}: {
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed
|
||||||
|
scrollElRef: ListRef
|
||||||
|
headerHeight: number
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const contextScrollHandlers = useScrollHandlers()
|
||||||
|
|
||||||
|
const scrollHandler = useAnimatedScrollHandler({
|
||||||
|
onBeginDrag(e, ctx) {
|
||||||
|
contextScrollHandlers.onBeginDrag?.(e, ctx)
|
||||||
|
},
|
||||||
|
onEndDrag(e, ctx) {
|
||||||
|
contextScrollHandlers.onEndDrag?.(e, ctx)
|
||||||
|
},
|
||||||
|
onScroll(e, ctx) {
|
||||||
|
contextScrollHandlers.onScroll?.(e, ctx)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {labelValues} = labelerInfo.policies
|
||||||
|
const isSubscribed = isLabelerSubscribed(labelerInfo, moderationOpts)
|
||||||
|
const labelDefs = React.useMemo(() => {
|
||||||
|
const customDefs = interpretLabelValueDefinitions(labelerInfo)
|
||||||
|
return labelValues
|
||||||
|
.map(val => lookupLabelValueDefinition(val, customDefs))
|
||||||
|
.filter(
|
||||||
|
def => def && def?.configurable,
|
||||||
|
) as InterpretedLabelValueDefinition[]
|
||||||
|
}, [labelerInfo, labelValues])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
// @ts-ignore TODO fix this
|
||||||
|
ref={scrollElRef}
|
||||||
|
scrollEventThrottle={1}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: headerHeight,
|
||||||
|
borderWidth: 0,
|
||||||
|
}}
|
||||||
|
contentOffset={{x: 0, y: headerHeight * -1}}
|
||||||
|
onScroll={scrollHandler}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.pt_xl,
|
||||||
|
a.px_lg,
|
||||||
|
isNative && a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<View>
|
||||||
|
<Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
|
||||||
|
<Trans>
|
||||||
|
Labels are annotations on users and content. They can be used to
|
||||||
|
hide, warn, and categorize the network.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
{labelerInfo.creator.viewer?.blocking ? (
|
||||||
|
<View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}>
|
||||||
|
<CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
|
<Text
|
||||||
|
style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
|
||||||
|
<Trans>
|
||||||
|
Blocking does not prevent this labeler from placing labels on
|
||||||
|
your account.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{labelValues.length === 0 ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pt_xl,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
a.leading_snug,
|
||||||
|
a.text_sm,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
This labeler hasn't declared what labels it publishes, and may
|
||||||
|
not be active.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
) : !isSubscribed ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pt_xl,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
a.leading_snug,
|
||||||
|
a.text_sm,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
Subscribe to @{labelerInfo.creator.handle} to use these labels:
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{labelDefs.length > 0 && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.mt_xl,
|
||||||
|
a.w_full,
|
||||||
|
a.rounded_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
{labelDefs.map((labelDef, i) => {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={labelDef.identifier}>
|
||||||
|
{i !== 0 && <Divider />}
|
||||||
|
<ModerationLabelPref
|
||||||
|
disabled={isSubscribed ? undefined : true}
|
||||||
|
labelValueDefinition={labelDef}
|
||||||
|
labelerDid={labelerInfo.creator.did}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{height: 400}} />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface SectionRef {
|
||||||
|
scrollToTop: () => void
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
|
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
|
|
||||||
import {ImageModel} from '#/state/models/media/image'
|
import {ImageModel} from '#/state/models/media/image'
|
||||||
|
@ -14,32 +14,6 @@ export interface EditProfileModal {
|
||||||
onUpdate?: () => void
|
onUpdate?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModerationDetailsModal {
|
|
||||||
name: 'moderation-details'
|
|
||||||
context: 'account' | 'content'
|
|
||||||
moderation: ModerationUI
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ReportModal = {
|
|
||||||
name: 'report'
|
|
||||||
} & (
|
|
||||||
| {
|
|
||||||
uri: string
|
|
||||||
cid: string
|
|
||||||
}
|
|
||||||
| {did: string}
|
|
||||||
)
|
|
||||||
|
|
||||||
export type AppealLabelModal = {
|
|
||||||
name: 'appeal-label'
|
|
||||||
} & (
|
|
||||||
| {
|
|
||||||
uri: string
|
|
||||||
cid: string
|
|
||||||
}
|
|
||||||
| {did: string}
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface CreateOrEditListModal {
|
export interface CreateOrEditListModal {
|
||||||
name: 'create-or-edit-list'
|
name: 'create-or-edit-list'
|
||||||
purpose?: string
|
purpose?: string
|
||||||
|
@ -123,10 +97,6 @@ export interface AddAppPasswordModal {
|
||||||
name: 'add-app-password'
|
name: 'add-app-password'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentFilteringSettingsModal {
|
|
||||||
name: 'content-filtering-settings'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContentLanguagesSettingsModal {
|
export interface ContentLanguagesSettingsModal {
|
||||||
name: 'content-languages-settings'
|
name: 'content-languages-settings'
|
||||||
}
|
}
|
||||||
|
@ -181,15 +151,9 @@ export type Modal =
|
||||||
| SwitchAccountModal
|
| SwitchAccountModal
|
||||||
|
|
||||||
// Curation
|
// Curation
|
||||||
| ContentFilteringSettingsModal
|
|
||||||
| ContentLanguagesSettingsModal
|
| ContentLanguagesSettingsModal
|
||||||
| PostLanguagesSettingsModal
|
| PostLanguagesSettingsModal
|
||||||
|
|
||||||
// Moderation
|
|
||||||
| ModerationDetailsModal
|
|
||||||
| ReportModal
|
|
||||||
| AppealLabelModal
|
|
||||||
|
|
||||||
// Lists
|
// Lists
|
||||||
| CreateOrEditListModal
|
| CreateOrEditListModal
|
||||||
| UserAddRemoveListsModal
|
| UserAddRemoveListsModal
|
||||||
|
|
|
@ -15,6 +15,7 @@ export {
|
||||||
useSetExternalEmbedPref,
|
useSetExternalEmbedPref,
|
||||||
} from './external-embeds-prefs'
|
} from './external-embeds-prefs'
|
||||||
export * from './hidden-posts'
|
export * from './hidden-posts'
|
||||||
|
export {useLabelDefinitions} from './label-defs'
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
return (
|
return (
|
||||||
|
|