Add repost-only mutes (#11223)

Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
This commit is contained in:
Eric Bailey
2026-08-06 13:53:02 -05:00
committed by GitHub
parent 1cbe7a39e6
commit 724013df1b
14 changed files with 259 additions and 64 deletions
+2
View File
@@ -510,6 +510,8 @@ export type Events = {
}
'profile:mute': {}
'profile:unmute': {}
'profile:muteReposts': {}
'profile:unmuteReposts': {}
'profile:block': {}
'profile:unblock': {}
'suggestedUser:follow': {
+24 -1
View File
@@ -1,12 +1,13 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {BSKY_LABELER_DID, type ModerationCause} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
import {Button} from '#/components/Button'
import {RepostStrike_Stroke2_Corner0_Rounded as RepostStrikeIcon} from '#/components/icons/Repost'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
@@ -215,3 +216,25 @@ export function FollowsYou({size = 'sm'}: CommonProps) {
</View>
)
}
export function MutedOnlyReposts(props: CommonProps) {
const t = useTheme()
const {t: l} = useLingui()
const size = props.size === 'lg' ? 16 : 12
return (
<LabelBase
{...props}
label={l`Reposts Hidden`}
icon={
<RepostStrikeIcon
width={size}
fill={t.atoms.text_contrast_medium.color}
/>
}
disabled={true}
onPress={() => {}}
/>
)
}
+3 -1
View File
@@ -623,8 +623,9 @@ export function Labels({
const moderation = moderateProfile(profile, moderationOpts)
const modui = moderation.ui('profileList')
const followedBy = profile.viewer?.followedBy
const mutedOnlyReposts = profile.viewer?.mutedOnlyReposts
if (!followedBy && !modui.inform && !modui.alert) {
if (!followedBy && !mutedOnlyReposts && !modui.inform && !modui.alert) {
return null
}
@@ -637,6 +638,7 @@ export function Labels({
{modui.informs.map(inform => (
<Pills.Label key={getModerationCauseKey(inform)} cause={inform} />
))}
{mutedOnlyReposts && <Pills.MutedOnlyReposts />}
</Pills.Row>
)
}
+4
View File
@@ -15,3 +15,7 @@ export const Repost_Stroke2_Corner3_Rounded = createSinglePathSVG({
export const RepostRepost_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M6.043 14.293a1 1 0 1 1 1.414 1.414L5.164 18l2.293 2.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47Zm6.22 0a1 1 0 0 1 1.414 1.414L12.384 17H18a1 1 0 0 0 1-1v-3a1 1 0 1 1 2 0v3a3 3 0 0 1-3 3h-5.616l1.293 1.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47ZM3 11V8a3 3 0 0 1 3-3h5.586l-1.293-1.293-.068-.076a1 1 0 0 1 1.406-1.406l.076.068 2.47 2.47.12.133a1.75 1.75 0 0 1 0 2.209l-.12.132-2.47 2.47a1 1 0 1 1-1.414-1.414L11.586 7H6a1 1 0 0 0-1 1v3a1 1 0 1 1-2 0Zm13.543-8.707a1 1 0 0 1 1.338-.068l.076.068 2.47 2.47.12.133a1.75 1.75 0 0 1 0 2.209l-.12.132-2.47 2.47a1 1 0 1 1-1.414-1.414L18.836 6l-2.293-2.293-.068-.076a1 1 0 0 1 .068-1.338Z',
})
export const RepostStrike_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M2.293 2.293a1 1 0 0 1 1.414 0l18 18a1 1 0 0 1-1.414 1.414L17.586 19H6.414l1.293 1.293a1 1 0 0 1-1.414 1.414l-3-3a1 1 0 0 1 0-1.414l3-3a1 1 0 1 1 1.414 1.414L6.414 17h9.172L5.909 7.323A2 2 0 0 0 5 9v2a1 1 0 0 1-2 0V9c0-1.255.579-2.372 1.482-3.104L2.293 3.707a1 1 0 0 1 0-1.414M20 12a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1M16.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-3 3a1 1 0 0 1-1.414-1.414L17.586 7H11a1 1 0 0 1 0-2h6.586l-1.293-1.293a1 1 0 0 1 0-1.414',
})
@@ -3,16 +3,21 @@ import {type ModerationDecision} from '@atproto/api'
import {getModerationCauseKey, unique} from '#/lib/moderation'
import * as Pills from '#/components/Pills'
import type * as bsky from '#/types/bsky'
export function ProfileHeaderAlerts({
moderation,
profile,
style,
}: {
moderation: ModerationDecision
profile: bsky.profile.AnyProfileView
style?: StyleProp<ViewStyle>
}) {
const modui = moderation.ui('profileView')
if (!modui.alert && !modui.inform) {
const mutedOnlyReposts = profile.viewer?.mutedOnlyReposts
if (!mutedOnlyReposts && !modui.alert && !modui.inform) {
return null
}
@@ -32,6 +37,7 @@ export function ProfileHeaderAlerts({
cause={cause}
/>
))}
{mutedOnlyReposts && <Pills.MutedOnlyReposts size="lg" />}
</Pills.Row>
)
}
@@ -35,7 +35,7 @@ export function GrowableBanner({
backButton?: React.ReactNode
children: React.ReactNode
onPress?: () => void
bannerRef?: AnimatedRef<Animated.View>
bannerRef?: AnimatedRef
testID?: string
label?: string
}) {
@@ -87,7 +87,7 @@ function GrowableBannerInner({
backButton?: React.ReactNode
children: React.ReactNode
onPress?: () => void
bannerRef?: AnimatedRef<Animated.View>
bannerRef?: AnimatedRef
testID?: string
label?: string
}) {
+3 -2
View File
@@ -60,7 +60,7 @@ let ProfileHeaderShell = ({
const liveStatusControl = useDialogControl()
const aviRef = useAnimatedRef()
const bannerRef = useAnimatedRef<Animated.View>()
const bannerRef = useAnimatedRef()
const onPressBack = useCallback(() => {
if (navigation.canGoBack()) {
@@ -73,7 +73,7 @@ let ProfileHeaderShell = ({
const _openLightbox = useCallback(
(
uri: string,
thumbRef: AnimatedRef<any>,
thumbRef: AnimatedRef,
type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi',
) => {
openLightbox({
@@ -239,6 +239,7 @@ let ProfileHeaderShell = ({
) : (
<ProfileHeaderAlerts
moderation={moderation}
profile={profile}
style={[
a.px_lg,
a.pt_xs,
+11 -1
View File
@@ -41,6 +41,7 @@ export type {Shadow} from './types'
export interface ProfileShadow {
followingUri: string | undefined
muted: boolean | undefined
mutedOnlyReposts: boolean | undefined
blockingUri: string | undefined
verification: AppBskyActorDefs.VerificationState
status: AppBskyActorDefs.StatusView | undefined
@@ -243,6 +244,11 @@ export function isProfileShadowApplied<
if ('muted' in shadow) {
if (profile.viewer?.muted !== shadow.muted) return false
}
if ('mutedOnlyReposts' in shadow) {
if (profile.viewer?.mutedOnlyReposts !== shadow.mutedOnlyReposts) {
return false
}
}
if ('blockingUri' in shadow) {
if (profile.viewer?.blocking !== shadow.blockingUri) return false
}
@@ -274,13 +280,17 @@ export function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
? shadow.followingUri
: profile.viewer?.following,
muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted,
mutedOnlyReposts:
'mutedOnlyReposts' in shadow
? shadow.mutedOnlyReposts
: profile.viewer?.mutedOnlyReposts,
blocking:
'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking,
activitySubscription:
'activitySubscription' in shadow
? shadow.activitySubscription
: profile.viewer?.activitySubscription,
},
} satisfies AppBskyActorDefs.ViewerState,
verification:
'verification' in shadow ? shadow.verification : profile.verification,
status:
+78 -1
View File
@@ -449,9 +449,13 @@ export function useProfileMuteMutationQueue(
})
const queueMute = useCallback(() => {
// optimistically update
/*
* Optimistically update. A full mute replaces any stored repost-only
* scope on the server, so clear it here too.
*/
updateProfileShadow(queryClient, did, {
muted: true,
mutedOnlyReposts: false,
})
return queueToggle(true)
}, [queryClient, did, queueToggle])
@@ -460,6 +464,7 @@ export function useProfileMuteMutationQueue(
// optimistically update
updateProfileShadow(queryClient, did, {
muted: false,
mutedOnlyReposts: false,
})
return queueToggle(false)
}, [queryClient, did, queueToggle])
@@ -467,6 +472,65 @@ export function useProfileMuteMutationQueue(
return [queueMute, queueUnmute] as const
}
/**
* Toggles a repost-only mute: muting hides just the account's reposts,
* unmuting removes the mute entirely. Not applicable when the account is
* fully muted (viewer.muted).
*/
export function useProfileMuteRepostsMutationQueue(
profile: Shadow<bsky.profile.AnyProfileView>,
) {
const ax = useAnalytics()
const queryClient = useQueryClient()
const did = profile.did
const initialMutedOnlyReposts = !!profile.viewer?.mutedOnlyReposts
const muteRepostsMutation = useProfileMuteRepostsMutation()
const unmuteMutation = useProfileUnmuteMutation()
const queueToggle = useToggleMutationQueue({
initialState: initialMutedOnlyReposts,
runMutation: async (_prevMutedOnlyReposts, shouldMute) => {
if (shouldMute) {
await muteRepostsMutation.mutateAsync({
did,
})
ax.metric('profile:muteReposts', {})
return true
} else {
await unmuteMutation.mutateAsync({
did,
})
ax.metric('profile:unmuteReposts', {})
return false
}
},
onSuccess(finalMutedOnlyReposts) {
// finalize
updateProfileShadow(queryClient, did, {
mutedOnlyReposts: finalMutedOnlyReposts,
})
},
})
const queueMuteReposts = useCallback(() => {
// optimistically update
updateProfileShadow(queryClient, did, {
mutedOnlyReposts: true,
})
return queueToggle(true)
}, [queryClient, did, queueToggle])
const queueUnmuteReposts = useCallback(() => {
// optimistically update
updateProfileShadow(queryClient, did, {
mutedOnlyReposts: false,
})
return queueToggle(false)
}, [queryClient, did, queueToggle])
return [queueMuteReposts, queueUnmuteReposts] as const
}
function useProfileMuteMutation() {
const queryClient = useQueryClient()
const agent = useAgent()
@@ -480,6 +544,19 @@ function useProfileMuteMutation() {
})
}
function useProfileMuteRepostsMutation() {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => {
await agent.mute(did, {onlyReposts: true})
},
onSuccess() {
void queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
},
})
}
function useProfileUnmuteMutation() {
const queryClient = useQueryClient()
const agent = useAgent()
+109 -35
View File
@@ -15,6 +15,7 @@ import {
useProfileBlockMutationQueue,
useProfileFollowMutationQueue,
useProfileMuteMutationQueue,
useProfileMuteRepostsMutationQueue,
} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {EventStopper} from '#/view/com/util/EventStopper'
@@ -28,20 +29,24 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {ListSparkle_Stroke2_Corner0_Rounded as ListIcon} from '#/components/icons/ListSparkle'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinusIcon} from '#/components/icons/PeopleRemove2'
import {
PersonCheck_Stroke2_Corner0_Rounded as PersonCheck,
PersonX_Stroke2_Corner0_Rounded as PersonX,
PersonCheck_Stroke2_Corner0_Rounded as PersonCheckIcon,
PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
} from '#/components/icons/Person'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {StarterPack} from '#/components/icons/StarterPack'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {
Repost_Stroke2_Corner0_Rounded as RepostIcon,
RepostStrike_Stroke2_Corner0_Rounded as RepostStrikeIcon,
} from '#/components/icons/Repost'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack'
import * as Menu from '#/components/Menu'
import {BlockDialog} from '#/components/moderation/BlockDialog'
import {
@@ -93,6 +98,8 @@ let ProfileMenu = ({
const {mutate: saveNux} = useSaveNux()
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueMuteReposts, queueUnmuteReposts] =
useProfileMuteRepostsMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
@@ -162,6 +169,40 @@ let ProfileMenu = ({
}
}, [ax, profile.viewer?.muted, queueUnmute, l, queueMute])
const onPressMuteReposts = useCallback(async () => {
if (profile.viewer?.mutedOnlyReposts) {
try {
await queueUnmuteReposts()
Toast.show(
l({message: 'Reposts will be shown in feeds', context: 'toast'}),
)
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
ax.logger.error('Failed to show reposts', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error',
})
}
}
} else {
try {
await queueMuteReposts()
Toast.show(
l({message: 'Reposts will be hidden in feeds', context: 'toast'}),
)
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
ax.logger.error('Failed to hide reposts', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error',
})
}
}
}
}, [ax, profile.viewer, queueUnmuteReposts, l, queueMuteReposts])
const blockAccount = useCallback(async () => {
if (profile.viewer?.blocking) {
try {
@@ -265,7 +306,7 @@ let ProfileMenu = ({
size="small"
shape="round">
{statusNudgeActive && <Gradient style={[a.rounded_full]} />}
<ButtonIcon icon={Ellipsis} size="sm" />
<ButtonIcon icon={EllipsisIcon} size="sm" />
</Button>
{statusNudgeActive && <Dot top={1} right={1} />}
</>
@@ -331,7 +372,9 @@ let ProfileMenu = ({
<Trans>Follow account</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={isFollowing ? UserMinus : Plus} />
<Menu.ItemIcon
icon={isFollowing ? UserMinusIcon : PlusIcon}
/>
</Menu.Item>
)}
</>
@@ -343,7 +386,7 @@ let ProfileMenu = ({
<Menu.ItemText>
<Trans>Add to starter packs</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={StarterPack} />
<Menu.ItemIcon icon={StarterPackIcon} />
</Menu.Item>
<Menu.Item
testID="profileHeaderDropdownListAddRemoveBtn"
@@ -352,7 +395,7 @@ let ProfileMenu = ({
<Menu.ItemText>
<Trans>Add to lists</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={List} />
<Menu.ItemIcon icon={ListIcon} />
</Menu.Item>
{isSelf && canGoLive && (
<Menu.Item
@@ -435,25 +478,54 @@ let ProfileMenu = ({
<>
{!profile.viewer?.blocking &&
!profile.viewer?.mutedByList && (
<Menu.Item
testID="profileHeaderDropdownMuteBtn"
label={
profile.viewer?.muted
? l`Unmute account`
: l`Mute account`
}
onPress={() => void onPressMuteAccount()}>
<Menu.ItemText>
{profile.viewer?.muted ? (
<Trans>Unmute account</Trans>
) : (
<Trans>Mute account</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon
icon={profile.viewer?.muted ? Unmute : Mute}
/>
</Menu.Item>
<>
{!profile.viewer?.muted && (
<Menu.Item
testID="profileHeaderDropdownMuteRepostsBtn"
label={
profile.viewer?.mutedOnlyReposts
? l`Show reposts in feeds`
: l`Hide reposts in feeds`
}
onPress={() => void onPressMuteReposts()}>
<Menu.ItemText>
{profile.viewer?.mutedOnlyReposts ? (
<Trans>Show reposts in feeds</Trans>
) : (
<Trans>Hide reposts in feeds</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon
icon={
profile.viewer?.mutedOnlyReposts
? RepostIcon
: RepostStrikeIcon
}
/>
</Menu.Item>
)}
<Menu.Item
testID="profileHeaderDropdownMuteBtn"
label={
profile.viewer?.muted
? l`Unmute account`
: l`Mute account`
}
onPress={() => void onPressMuteAccount()}>
<Menu.ItemText>
{profile.viewer?.muted ? (
<Trans>Unmute account</Trans>
) : (
<Trans>Mute account</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon
icon={
profile.viewer?.muted ? UnmuteIcon : MuteIcon
}
/>
</Menu.Item>
</>
)}
{!profile.viewer?.blockingByList && (
<Menu.Item
@@ -473,7 +545,9 @@ let ProfileMenu = ({
</Menu.ItemText>
<Menu.ItemIcon
icon={
profile.viewer?.blocking ? PersonCheck : PersonX
profile.viewer?.blocking
? PersonCheckIcon
: PersonXIcon
}
/>
</Menu.Item>
@@ -485,7 +559,7 @@ let ProfileMenu = ({
<Menu.ItemText>
<Trans>Report account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
<Menu.ItemIcon icon={FlagIcon} />
</Menu.Item>
</>
)}