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
Paul Frazee 2024-03-18 12:46:28 -07:00 committed by GitHub
parent d5ebbeb3fc
commit 20d463ff2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
165 changed files with 7034 additions and 5009 deletions

View File

@ -3,8 +3,8 @@ on:
push:
branches:
- main
- traffic-reduction
- respect-optout-for-embeds
- 3p-moderators
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -188,6 +188,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
e.GET("/support", 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/feed/:rkey", 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)
e.GET("/profile/:ident/rss", server.WebProfileRSS)

View File

@ -44,7 +44,7 @@
"update-extensions": "scripts/updateExtensions.sh"
},
"dependencies": {
"@atproto/api": "^0.10.5",
"@atproto/api": "^0.11.2",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1",
@ -76,7 +76,9 @@
"@segment/sovran-react-native": "^0.4.5",
"@sentry/react-native": "5.5.0",
"@tamagui/focus-scope": "^1.84.1",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "^5.8.1",
"@tanstack/react-query-persist-client": "^5.25.0",
"@tiptap/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220",
"@tiptap/extension-hard-break": "^2.0.3",

View File

@ -5,7 +5,7 @@ import React, {useState, useEffect} from 'react'
import {RootSiblingParent} from 'react-native-root-siblings'
import * as SplashScreen from 'expo-splash-screen'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {QueryClientProvider} from '@tanstack/react-query'
import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
import {
SafeAreaProvider,
initialWindowMetrics,
@ -22,7 +22,11 @@ import {s} from 'lib/styles'
import {Shell} from 'view/shell'
import * as notifications from 'lib/notifications/notifications'
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 {Provider as ShellStateProvider} from 'state/shell'
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 LoggedOutViewProvider} from 'state/shell/logged-out'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
@ -79,21 +84,23 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<StatsigProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
<LabelDefsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</LabelDefsProvider>
</StatsigProvider>
</React.Fragment>
</Splash>
@ -118,7 +125,9 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
@ -140,7 +149,7 @@ function App() {
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
</PersistQueryClientProvider>
)
}

View File

@ -1,7 +1,7 @@
import 'lib/sentry' // must be near top
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 {RootSiblingParent} from 'react-native-root-siblings'
@ -13,7 +13,11 @@ import {init as initPersistedState} from '#/state/persisted'
import {Shell} from 'view/shell/index'
import {ToastContainer} from 'view/com/util/Toast.web'
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 ModalStateProvider} from 'state/modals'
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 LoggedOutViewProvider} from 'state/shell/logged-out'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
@ -56,21 +61,23 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<StatsigProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
<LabelDefsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</LabelDefsProvider>
</StatsigProvider>
</React.Fragment>
</Alf>
@ -93,7 +100,9 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
@ -115,7 +124,7 @@ function App() {
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
</PersistQueryClientProvider>
)
}

View File

@ -46,7 +46,7 @@ import {SearchScreen} from './view/screens/Search'
import {FeedsScreen} from './view/screens/Feeds'
import {NotificationsScreen} from './view/screens/Notifications'
import {ListsScreen} from './view/screens/Lists'
import {ModerationScreen} from './view/screens/Moderation'
import {ModerationScreen} from '#/screens/Moderation'
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
import {NotFoundScreen} from './view/screens/NotFound'
import {SettingsScreen} from './view/screens/Settings'
@ -61,6 +61,7 @@ import {PostThreadScreen} from './view/screens/PostThread'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {Storybook} from './view/screens/Storybook'
import {DebugModScreen} from './view/screens/DebugMod'
import {LogScreen} from './view/screens/Log'
import {SupportScreen} from './view/screens/Support'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
@ -78,6 +79,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack
import {msg} from '@lingui/macro'
import {i18n, MessageDescriptor} from '@lingui/core'
import HashtagScreen from '#/screens/Hashtag'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@ -198,11 +200,21 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => ProfileFeedLikedByScreen}
options={{title: title(msg`Liked by`)}}
/>
<Stack.Screen
name="ProfileLabelerLikedBy"
getComponent={() => ProfileLabelerLikedByScreen}
options={{title: title(msg`Liked by`)}}
/>
<Stack.Screen
name="Debug"
getComponent={() => Storybook}
options={{title: title(msg`Storybook`), requireAuth: true}}
/>
<Stack.Screen
name="DebugMod"
getComponent={() => DebugModScreen}
options={{title: title(msg`Moderation states`), requireAuth: true}}
/>
<Stack.Screen
name="Log"
getComponent={() => LogScreen}

View File

@ -50,6 +50,9 @@ export const atoms = {
h_full: {
height: '100%',
},
h_full_vh: web({
height: '100vh',
}),
/*
* Border radius
@ -243,6 +246,9 @@ export const atoms = {
font_normal: {
fontWeight: tokens.fontWeight.normal,
},
font_semibold: {
fontWeight: '500',
},
font_bold: {
fontWeight: tokens.fontWeight.semibold,
},
@ -528,6 +534,10 @@ export const atoms = {
/*
* Margin
*/
mx_auto: {
marginLeft: 'auto',
marginRight: 'auto',
},
m_2xs: {
margin: tokens.space._2xs,
},

View File

@ -12,6 +12,9 @@ export const dimScale = generateScale(12, 100)
export const color = {
trueBlack: '#000000',
temp_purple: 'rgb(105 0 255)',
temp_purple_dark: 'rgb(83 0 202)',
gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,

View File

@ -15,6 +15,7 @@ import LinearGradient from 'react-native-linear-gradient'
import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
import {normalizeTextStyles} from '#/components/Typography'
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
export type ButtonColor =
@ -139,7 +140,7 @@ export function Button({
}))
}, [setState])
const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => {
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
@ -191,14 +192,14 @@ export function Button({
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.contrast_50,
backgroundColor: t.palette.contrast_25,
})
hoverStyles.push({
backgroundColor: t.palette.contrast_100,
backgroundColor: t.palette.contrast_50,
})
} else {
baseStyles.push({
backgroundColor: t.palette.contrast_200,
backgroundColor: t.palette.contrast_100,
})
}
} else if (variant === 'outline') {
@ -308,12 +309,6 @@ export function Button({
return {
baseStyles,
hoverStyles,
focusStyles: [
...hoverStyles,
{
outline: 0,
} as ViewStyle,
],
}
}, [t, variant, color, size, shape, disabled])
@ -376,10 +371,8 @@ export function Button({
a.flex_row,
a.align_center,
a.justify_center,
a.justify_center,
flattenedBaseStyles,
...(state.hovered || state.pressed ? hoverStyles : []),
...(state.focused ? focusStyles : []),
flatten(style),
]}
onPressIn={onPressIn}
@ -398,7 +391,7 @@ export function Button({
]}>
<LinearGradient
colors={
state.hovered || state.pressed || state.focused
state.hovered || state.pressed
? gradientHoverColors
: gradientColors
}
@ -527,7 +520,14 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
const textStyles = useSharedButtonTextStyles()
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}
</Text>
)

View File

@ -99,7 +99,7 @@ export function Outer({
style={[
web(a.fixed),
a.inset_0,
{opacity: 0.5, backgroundColor: t.palette.black},
{opacity: 0.8, backgroundColor: t.palette.black},
]}
/>
)}

View File

@ -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]}
/>
)
}

View File

@ -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} />
)
}

View File

@ -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>
)
}

View File

@ -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
}

View File

@ -251,7 +251,7 @@ export function InlineLink({
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const flattenedStyle = flatten(style)
const flattenedStyle = flatten(style) || {}
return (
<Text

View File

@ -33,7 +33,7 @@ export function ListFooter({
a.border_t,
a.pb_lg,
t.atoms.border_contrast_low,
{height: 100},
{height: 180},
]}>
{isFetching ? (
<Loader size="xl" />

View File

@ -223,7 +223,7 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
style={flatten([
a.flex_row,
a.align_center,
a.gap_sm,
a.gap_lg,
a.py_sm,
a.rounded_xs,
{minHeight: 32, paddingHorizontal: 10},

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1 @@
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'

View File

@ -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>
)
}

View File

@ -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
}
}

View File

@ -59,7 +59,7 @@ export function TagMenu({
const displayTag = '#' + tag
const isMuted = Boolean(
(preferences?.mutedWords?.find(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(

View File

@ -50,7 +50,7 @@ export function TagMenu({
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
useRemoveMutedWordMutation()
const isMuted = Boolean(
(preferences?.mutedWords?.find(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(

View File

@ -1,5 +1,10 @@
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 {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
* returns it as-is.
*/
function normalizeTextStyles(styles: TextStyle[]) {
export function normalizeTextStyles(styles: StyleProp<TextStyle>) {
const s = flatten(styles)
// should always be defined on these components
const fontSize = s.fontSize || atoms.text_md.fontSize

View File

@ -18,7 +18,7 @@ export function useGlobalDialogsControlContext() {
export function Provider({children}: React.PropsWithChildren<{}>) {
const mutedWordsDialogControl = Dialog.useDialogControl()
const ctx = React.useMemo(
const ctx = React.useMemo<ControlsContext>(
() => ({mutedWordsDialogControl}),
[mutedWordsDialogControl],
)

View File

@ -233,8 +233,8 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
</Trans>
</Text>
</View>
) : preferences.mutedWords.length ? (
[...preferences.mutedWords]
) : preferences.moderationPrefs.mutedWords.length ? (
[...preferences.moderationPrefs.mutedWords]
.reverse()
.map((word, i) => (
<MutedWordRow

View File

@ -2,7 +2,14 @@ import React from 'react'
import {Pressable, View, ViewStyle} from 'react-native'
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 {useInteractionState} from '#/components/hooks/useInteractionState'
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
@ -220,20 +227,17 @@ export function Item({
onPressOut={onPressOut}
onFocus={onFocus}
onBlur={onBlur}
style={[
a.flex_row,
a.align_center,
a.gap_sm,
focused ? web({outline: 'none'}) : {},
flatten(style),
]}>
style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}>
{typeof children === 'function' ? children(state) : children}
</Pressable>
</ItemContext.Provider>
)
}
export function Label({children}: React.PropsWithChildren<{}>) {
export function Label({
children,
style,
}: React.PropsWithChildren<TextStyleProp>) {
const t = useTheme()
const {disabled} = useItemContext()
return (
@ -242,11 +246,14 @@ export function Label({children}: React.PropsWithChildren<{}>) {
a.font_bold,
{
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({
paddingTop: 3,
}),
flatten(style),
]}>
{children}
</Text>
@ -257,7 +264,6 @@ export function Label({children}: React.PropsWithChildren<{}>) {
export function createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
@ -280,7 +286,7 @@ export function createSharedToggleStyles({
borderColor: t.palette.primary_500,
})
if (hovered || focused) {
if (hovered) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
@ -289,7 +295,7 @@ export function createSharedToggleStyles({
})
}
} else {
if (hovered || focused) {
if (hovered) {
baseHover.push({
backgroundColor:
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,
})
if (hovered || focused) {
if (hovered) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
@ -353,7 +359,7 @@ export function Checkbox() {
width: 20,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
hovered ? baseHoverStyles : {},
]}>
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
</View>
@ -385,7 +391,7 @@ export function Switch() {
width: 30,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
hovered ? baseHoverStyles : {},
]}>
<View
style={[
@ -437,7 +443,7 @@ export function Radio() {
width: 20,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
hovered ? baseHoverStyles : {},
]}>
{selected ? (
<View

View File

@ -8,7 +8,9 @@ import * as Toggle from '#/components/forms/Toggle'
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
AccessibilityProps &
React.PropsWithChildren<{testID?: string}>
React.PropsWithChildren<{
testID?: string
}>
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
multiple?: boolean
@ -101,12 +103,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
native({
paddingBottom: 10,
}),
a.px_sm,
a.px_md,
t.atoms.bg,
t.atoms.border_contrast_low,
baseStyles,
activeStyles,
(state.hovered || state.focused || state.pressed) && hoverStyles,
(state.hovered || state.pressed) && hoverStyles,
]}>
{typeof children === 'string' ? (
<Text

View File

@ -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
}

View File

@ -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',
})

View File

@ -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',
})

View File

@ -7,3 +7,11 @@ export const ChevronLeft_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',
})
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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
},
})

View File

@ -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>
)
}

View File

@ -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} />
)
}

View File

@ -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>
</>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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} />
</>
)
}

View File

@ -1,45 +1,50 @@
import React, {ComponentProps} from 'react'
import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
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 {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> {
iconSize: number
iconStyles: StyleProp<ViewStyle>
moderation: ModerationUI
modui: ModerationUI
}
export function PostHider({
testID,
href,
moderation,
modui,
style,
children,
iconSize,
iconStyles,
...props
}: Props) {
const pal = usePalette('default')
const t = useTheme()
const {_} = useLingui()
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 (
<Link
testID={testID}
style={style}
href={href}
noFeedback
accessible={false}
{...props}>
{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 ? (
<Pressable
onPress={() => {
if (!moderation.noOverride) {
if (!modui.noOverride) {
setOverride(v => !v)
}
}}
@ -62,49 +65,45 @@ export function PostHider({
}
accessibilityLabel=""
style={[
styles.description,
a.flex_row,
a.align_center,
a.gap_sm,
a.py_md,
{
paddingLeft: 6,
paddingRight: 18,
},
override ? {paddingBottom: 0} : undefined,
pal.view,
t.atoms.bg,
]}>
<ModerationDetailsDialog control={control} modcause={blur} />
<Pressable
onPress={() => {
openModal({
name: 'moderation-details',
context: 'content',
moderation,
})
control.open()
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Learn more about this warning`)}
accessibilityHint="">
<View
style={[
pal.viewLight,
t.atoms.bg_contrast_25,
a.align_center,
a.justify_center,
{
width: iconSize,
height: iconSize,
borderRadius: iconSize,
alignItems: 'center',
justifyContent: 'center',
},
iconStyles,
]}>
{isMute ? (
<FontAwesomeIcon
icon={['far', 'eye-slash']}
size={14}
color={pal.colors.textLight}
/>
) : (
<ShieldExclamation size={14} style={pal.textLight} />
)}
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
</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}
</Text>
{!moderation.noOverride && (
<Text type="sm" style={[styles.showBtn, pal.link]}>
{!modui.noOverride && (
<Text style={[{color: t.palette.primary_500}]}>
{override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
</Text>
)}
@ -114,26 +113,14 @@ export function PostHider({
testID={testID}
style={addStyle(style, styles.child)}
href={href}
noFeedback>
accessible={false}
{...props}>
{children}
</Link>
)
}
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: {
borderWidth: 0,
borderTopWidth: 0,

View File

@ -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} />
</>
)
}

View File

@ -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>
)
}

View File

@ -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)
})
})
})

View File

@ -35,6 +35,10 @@ export const MAX_GRAPHEME_LENGTH = 300
// but increasing limit per user feedback
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) {
return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
}

View File

@ -1,380 +1,30 @@
import {
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
moderatePost,
AppBskyActorDefs,
AppBskyFeedPost,
AppBskyRichtextFacet,
AppBskyEmbedImages,
AppBskyEmbedExternal,
} from '@atproto/api'
import {moderatePost, BSKY_LABELER_DID} from '@atproto/api'
type ModeratePost = typeof moderatePost
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
}
type Options = Parameters<ModeratePost>[1]
export function moderatePost_wrapped(
subject: Parameters<ModeratePost>[0],
opts: Options,
) {
const {hiddenPosts = [], mutedWords = [], ...options} = opts
const moderations = moderatePost(subject, options)
const isOwnPost = subject.author.did === opts.userDid
// HACK
// temporarily translate 'gore' into 'graphic-media' during the transition period
// can remove this in a few months
// -prf
translateOldLabels(subject)
if (hiddenPosts.includes(subject.uri)) {
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)) {
let muted = hasMutedWord({
mutedWords,
text: subject.record.text,
facets: subject.record.facets || [],
outlineTags: subject.record.tags || [],
languages: subject.record.langs,
isOwnPost,
})
if (
subject.record.embed &&
AppBskyEmbedImages.isMain(subject.record.embed)
) {
for (const image of subject.record.embed.images) {
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
return moderatePost(subject, opts)
}
function translateOldLabels(subject: Parameters<ModeratePost>[0]) {
if (subject.labels) {
for (const label of subject.labels) {
if (
label.val === 'gore' &&
(!label.src || label.src === BSKY_LABELER_DID)
) {
label.val = 'graphic-media'
}
}
}
}

View File

@ -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 {
name: string
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
}
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
export function getModerationCauseKey(cause: ModerationCause): string {
const source =
cause.source.type === 'labeler'
? cause.source.labeler.did
? cause.source.did
: cause.source.type === 'list'
? cause.source.list.uri
: 'user'
@ -147,3 +23,59 @@ export function getModerationCauseKey(cause: ModerationCause): string {
}
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)
}

View File

@ -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.`),
},
}),
[_],
)
}

View File

@ -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`)
}
}

View File

@ -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}"`,
}
}

View File

@ -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])
}

View File

@ -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,
}
}, [_])
}

View File

@ -1,7 +1,14 @@
import {AppState, AppStateStatus} from 'react-native'
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'
// any query keys in this array will be persisted to AsyncStorage
const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info']
focusManager.setEventListener(onFocus => {
if (isNative) {
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]))
},
}

View File

@ -21,7 +21,9 @@ export type CommonNavigatorParams = {
PostRepostedBy: {name: string; rkey: string}
ProfileFeed: {name: string; rkey: string}
ProfileFeedLikedBy: {name: string; rkey: string}
ProfileLabelerLikedBy: {name: string}
Debug: undefined
DebugMod: undefined
Log: undefined
Support: undefined
PrivacyPolicy: undefined

View File

@ -1,5 +1,4 @@
import {ModerationUI} from '@atproto/api'
import {describeModerationCause} from '../moderation'
// \u2705 = ✅
// \u2713 = ✓
@ -14,7 +13,7 @@ export function sanitizeDisplayName(
moderation?: ModerationUI,
): string {
if (moderation?.blur) {
return `${describeModerationCause(moderation.cause, 'account').name}`
return ''
}
if (typeof str === 'string') {
return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()

View File

@ -9,7 +9,7 @@ export const defaultTheme: Theme = {
palette: {
default: {
background: lightPalette.white,
backgroundLight: lightPalette.contrast_50,
backgroundLight: lightPalette.contrast_25,
text: lightPalette.black,
textLight: lightPalette.contrast_700,
textInverted: lightPalette.white,

View File

@ -21,7 +21,9 @@ export const router = new Router({
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
ProfileFeed: '/profile/:name/feed/:rkey',
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by',
Debug: '/sys/debug',
DebugMod: '/sys/debug-mod',
Log: '/sys/log',
AppPasswords: '/settings/app-passwords',
PreferencesFollowingFeed: '/settings/following-feed',

View File

@ -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>
)
}

View File

@ -56,7 +56,9 @@ export function AdultContentEnabledPref({
try {
mutate({
enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
enabled: !(
variables?.enabled ?? preferences?.moderationPrefs.adultContentEnabled
),
})
} catch (e) {
Toast.show(
@ -75,7 +77,10 @@ export function AdultContentEnabledPref({
<Toggle.Item
name={_(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}>
<View
style={[

View File

@ -1,40 +1,51 @@
import React from 'react'
import {View} from 'react-native'
import {LabelPreference} from '@atproto/api'
import {LabelPreference, InterpretedLabelValueDefinition} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import Animated, {Easing, Layout, FadeIn} from 'react-native-reanimated'
import {msg, Trans} from '@lingui/macro'
import {
CONFIGURABLE_LABEL_GROUPS,
ConfigurableLabelGroup,
usePreferencesQuery,
usePreferencesSetContentLabelMutation,
} from '#/state/queries/preferences'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import * as ToggleButton from '#/components/forms/ToggleButton'
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
export function ModerationOption({
labelGroup,
isMounted,
labelValueDefinition,
disabled,
}: {
labelGroup: ConfigurableLabelGroup
isMounted: React.MutableRefObject<boolean>
labelValueDefinition: InterpretedLabelValueDefinition
disabled?: boolean
}) {
const {_} = useLingui()
const t = useTheme()
const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup]
const {data: preferences} = usePreferencesQuery()
const {mutate, variables} = usePreferencesSetContentLabelMutation()
const label = labelValueDefinition.identifier
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(
(vis: string[]) => {
mutate({labelGroup, visibility: vis[0] as LabelPreference})
mutate({
label,
visibility: vis[0] as LabelPreference,
labelerDid: undefined,
})
},
[mutate, labelGroup],
[mutate, label],
)
const labels = {
@ -44,7 +55,7 @@ export function ModerationOption({
}
return (
<Animated.View
<View
style={[
a.flex_row,
a.justify_between,
@ -52,33 +63,37 @@ export function ModerationOption({
a.py_xs,
a.px_xs,
a.align_center,
]}
layout={Layout.easing(Easing.ease).duration(200)}
entering={isMounted.current ? FadeIn : undefined}>
<View style={[a.gap_xs, {width: '50%'}]}>
<Text style={[a.font_bold]}>{groupInfo.title}</Text>
]}>
<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]}>
{groupInfo.subtitle}
{labelStrings.description}
</Text>
</View>
<View style={[a.justify_center, {minHeight: 35}]}>
<ToggleButton.Group
label={_(
msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`,
)}
values={[visibility ?? 'hide']}
onChange={onChange}>
<ToggleButton.Button name="hide" label={labels.hide}>
{labels.hide}
</ToggleButton.Button>
<ToggleButton.Button name="warn" label={labels.warn}>
{labels.warn}
</ToggleButton.Button>
<ToggleButton.Button name="ignore" label={labels.show}>
{labels.show}
</ToggleButton.Button>
</ToggleButton.Group>
<View style={[a.justify_center, {minHeight: 40}]}>
{disabled ? (
<Text style={[a.font_bold]}>
<Trans>Hide</Trans>
</Text>
) : (
<ToggleButton.Group
label={_(
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
)}
values={[visibility ?? 'hide']}
onChange={onChange}>
<ToggleButton.Button name="ignore" label={labels.show}>
{labels.show}
</ToggleButton.Button>
<ToggleButton.Button name="warn" label={labels.warn}>
{labels.warn}
</ToggleButton.Button>
<ToggleButton.Button name="hide" label={labels.hide}>
{labels.hide}
</ToggleButton.Button>
</ToggleButton.Group>
)}
</View>
</Animated.View>
</View>
)
}

View File

@ -2,15 +2,10 @@ import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
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 {
configurableAdultLabelGroups,
configurableOtherLabelGroups,
usePreferencesSetAdultContentMutation,
} from 'state/queries/preferences'
import {Divider} from '#/components/Divider'
import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
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 {IconCircle} from '#/components/IconCircle'
function AnimatedDivider() {
return (
<Animated.View layout={Layout.easing(Easing.ease).duration(200)}>
<Divider />
</Animated.View>
)
}
export function StepModeration() {
const {_} = useLingui()
const {track} = useAnalytics()
@ -52,7 +39,7 @@ export function StepModeration() {
const adultContentEnabled = !!(
(variables && variables.enabled) ||
(!variables && preferences?.adultContentEnabled)
(!variables && preferences?.moderationPrefs.adultContentEnabled)
)
const onContinue = React.useCallback(() => {
@ -86,22 +73,19 @@ export function StepModeration() {
<AdultContentEnabledPref mutate={mutate} variables={variables} />
<View style={[a.gap_sm, a.w_full]}>
{adultContentEnabled &&
configurableAdultLabelGroups.map((g, index) => (
<React.Fragment key={index}>
{index === 0 && <AnimatedDivider />}
<ModerationOption labelGroup={g} isMounted={isMounted} />
<AnimatedDivider />
</React.Fragment>
))}
{configurableOtherLabelGroups.map((g, index) => (
<React.Fragment key={index}>
{!adultContentEnabled && index === 0 && <AnimatedDivider />}
<ModerationOption labelGroup={g} isMounted={isMounted} />
<AnimatedDivider />
</React.Fragment>
))}
<ModerationOption
labelValueDefinition={LABELS.porn}
disabled={!adultContentEnabled}
/>
<ModerationOption
labelValueDefinition={LABELS.sexual}
disabled={!adultContentEnabled}
/>
<ModerationOption
labelValueDefinition={LABELS['graphic-media']}
disabled={!adultContentEnabled}
/>
<ModerationOption labelValueDefinition={LABELS.nudity} />
</View>
</>
)}

View File

@ -88,7 +88,7 @@ export function SuggestedAccountCard({
<UserAvatar
size={48}
avatar={profile.avatar}
moderation={moderation.avatar}
moderation={moderation.ui('avatar')}
/>
</View>
<View style={[a.flex_1]}>

View File

@ -76,7 +76,7 @@ export function StepSuggestedAccounts() {
return aggregateInterestItems(
state.interestsStepResults.selectedInterests,
state.interestsStepResults.apiResponse.suggestedAccountDids,
state.interestsStepResults.apiResponse.suggestedAccountDids.default,
state.interestsStepResults.apiResponse.suggestedAccountDids.default || [],
)
}, [state.interestsStepResults])
const moderationOpts = useModerationOpts()

View File

@ -21,7 +21,7 @@ import {
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {IconCircle} from '#/components/IconCircle'
import {IS_PROD_SERVICE} from 'lib/constants'
import {IS_TEST_USER} from 'lib/constants'
import {useSession} from 'state/session'
export function StepTopicalFeeds() {
@ -32,14 +32,14 @@ export function StepTopicalFeeds() {
const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
const [saving, setSaving] = React.useState(false)
const suggestedFeedUris = React.useMemo(() => {
if (!IS_PROD_SERVICE(currentAccount?.service)) return []
if (IS_TEST_USER(currentAccount?.handle)) return []
return aggregateInterestItems(
state.interestsStepResults.selectedInterests,
state.interestsStepResults.apiResponse.suggestedFeedUris,
state.interestsStepResults.apiResponse.suggestedFeedUris.default,
state.interestsStepResults.apiResponse.suggestedFeedUris.default || [],
).slice(0, 10)
}, [
currentAccount?.service,
currentAccount?.handle,
state.interestsStepResults.apiResponse.suggestedFeedUris,
state.interestsStepResults.selectedInterests,
])

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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}

View File

@ -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,
},
})

View File

@ -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},
})

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,3 @@
export interface SectionRef {
scrollToTop: () => void
}

View File

@ -1,5 +1,5 @@
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 {ImageModel} from '#/state/models/media/image'
@ -14,32 +14,6 @@ export interface EditProfileModal {
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 {
name: 'create-or-edit-list'
purpose?: string
@ -123,10 +97,6 @@ export interface AddAppPasswordModal {
name: 'add-app-password'
}
export interface ContentFilteringSettingsModal {
name: 'content-filtering-settings'
}
export interface ContentLanguagesSettingsModal {
name: 'content-languages-settings'
}
@ -181,15 +151,9 @@ export type Modal =
| SwitchAccountModal
// Curation
| ContentFilteringSettingsModal
| ContentLanguagesSettingsModal
| PostLanguagesSettingsModal
// Moderation
| ModerationDetailsModal
| ReportModal
| AppealLabelModal
// Lists
| CreateOrEditListModal
| UserAddRemoveListsModal

View File

@ -15,6 +15,7 @@ export {
useSetExternalEmbedPref,
} from './external-embeds-prefs'
export * from './hidden-posts'
export {useLabelDefinitions} from './label-defs'
export function Provider({children}: React.PropsWithChildren<{}>) {
return (

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