diff --git a/assets/icons/repostStrike_stroke2_corner0_rounded.svg b/assets/icons/repostStrike_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..755fb00498 --- /dev/null +++ b/assets/icons/repostStrike_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 2bc0ab246f..2aee77333c 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -992,11 +992,6 @@ "count": 3 } }, - "src/screens/Profile/Header/Shell.tsx": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "src/screens/Profile/KnownFollowers.tsx": { "typescript/no-misused-promises": { "count": 2 diff --git a/package.json b/package.json index 6af3698d37..d457df50f4 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ }, "dependencies": { "@atproto/api": "0.20.37", - "@atproto/common-web": "0.5.6", + "@atproto/common-web": "0.5.7", "@atproto/syntax": "0.7.2", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce0b6b65f5..ded12e931f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,8 +245,8 @@ importers: specifier: 0.20.37 version: 0.20.37 '@atproto/common-web': - specifier: 0.5.6 - version: 0.5.6 + specifier: 0.5.7 + version: 0.5.7 '@atproto/syntax': specifier: 0.7.2 version: 0.7.2 @@ -878,24 +878,24 @@ packages: resolution: {integrity: sha512-TEt8oKMbuC+1GvqyLKDBLqmptnk1mh52FdHPQEomlPIWxh88YcNnk0g0iKNVBevaeeLtWZF1a7Am/t/q255u5Q==} engines: {node: '>=22'} - '@atproto/common-web@0.5.6': - resolution: {integrity: sha512-5Y4MIK9dpkJPiKiE6u7iEHitxj+g3aAU2GfGL686JlKE2zDKD4y18BJb+uVek6nXQKb5XOdNVvw+7BHarpn8Fw==} + '@atproto/common-web@0.5.7': + resolution: {integrity: sha512-lvU8lFO5UByacsl3AReXkpTWjcPBS8mBqt40B45HwCt+s58a+xT/YI/+AUpmzSmsPVZCpeCmP6wgsOSC8RILXg==} engines: {node: '>=22'} '@atproto/common-web@0.5.8': resolution: {integrity: sha512-GiYY2Jgbg1aWe9QGT3TswC/wZNicipBDz0nFo4FrP6XqENEJJRxGKAf/zjvqbesfIQFhHXi1VbSFY7GStB8v8w==} engines: {node: '>=22'} - '@atproto/lex-data@0.1.5': - resolution: {integrity: sha512-TEM6GHuYpNm4O90LjNgbYq1Gmcr875S+BHrDxkg4PB5w/nlsz4HlbkGQG/WP/xbIV5O8TF5nNHDpCZ5ezTRrFA==} + '@atproto/lex-data@0.1.6': + resolution: {integrity: sha512-rvovRrYWDEuerX+ZeQbdI5dKs0bYvuMjhwDOtT+pkOvdY4MkfN41DJCz/RAYVAQ7noPdaalB6pAX9euukheE/w==} engines: {node: '>=22'} '@atproto/lex-data@0.1.7': resolution: {integrity: sha512-kW/dPLqo/WgCLV+XESR4JKwV6c1rZWJGOfuPupZGTjEDAKoBbKXdaEzX9/1vKQYbZ9U3j0DS/n7OFFK7wBugyQ==} engines: {node: '>=22'} - '@atproto/lex-json@0.1.4': - resolution: {integrity: sha512-ENR2cWkVrES+UL6TovbCRdX9BJOyHHJUS8jYx3Lxp3j4vEphjn/u+DW7bWloST2O8ID2OuVlt6+28ftNEEjmQQ==} + '@atproto/lex-json@0.1.5': + resolution: {integrity: sha512-qOp6+Za5p/xDXACYIP3vySbKaf9CJDwiZEN78ghPT1gRoJohKG3ONt/WmvOxBVJqc6VTRaqEpbG9vX9oLF2qsA==} engines: {node: '>=22'} '@atproto/lex-json@0.1.6': @@ -9426,10 +9426,10 @@ snapshots: tlds: 1.261.0 zod: 3.25.76 - '@atproto/common-web@0.5.6': + '@atproto/common-web@0.5.7': dependencies: - '@atproto/lex-data': 0.1.5 - '@atproto/lex-json': 0.1.4 + '@atproto/lex-data': 0.1.6 + '@atproto/lex-json': 0.1.5 '@atproto/syntax': 0.7.2 zod: 3.25.76 @@ -9440,7 +9440,7 @@ snapshots: '@atproto/syntax': 0.7.3 zod: 3.25.76 - '@atproto/lex-data@0.1.5': + '@atproto/lex-data@0.1.6': dependencies: multiformats: 13.4.2 tslib: 2.8.1 @@ -9452,9 +9452,9 @@ snapshots: tslib: 2.8.1 unicode-segmenter: 0.14.5 - '@atproto/lex-json@0.1.4': + '@atproto/lex-json@0.1.5': dependencies: - '@atproto/lex-data': 0.1.5 + '@atproto/lex-data': 0.1.6 tslib: 2.8.1 '@atproto/lex-json@0.1.6': diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index b5e01d11e3..9e5358e23a 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -510,6 +510,8 @@ export type Events = { } 'profile:mute': {} 'profile:unmute': {} + 'profile:muteReposts': {} + 'profile:unmuteReposts': {} 'profile:block': {} 'profile:unblock': {} 'suggestedUser:follow': { diff --git a/src/components/Pills.tsx b/src/components/Pills.tsx index c24e51047c..e38afb6c87 100644 --- a/src/components/Pills.tsx +++ b/src/components/Pills.tsx @@ -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) { ) } + +export function MutedOnlyReposts(props: CommonProps) { + const t = useTheme() + const {t: l} = useLingui() + + const size = props.size === 'lg' ? 16 : 12 + + return ( + + } + disabled={true} + onPress={() => {}} + /> + ) +} diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 7b4d75164d..c37f99c075 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -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 => ( ))} + {mutedOnlyReposts && } ) } diff --git a/src/components/icons/Repost.tsx b/src/components/icons/Repost.tsx index abf2c8ac27..41d76fd215 100644 --- a/src/components/icons/Repost.tsx +++ b/src/components/icons/Repost.tsx @@ -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', +}) diff --git a/src/components/moderation/ProfileHeaderAlerts.tsx b/src/components/moderation/ProfileHeaderAlerts.tsx index f35bf14fe1..e6a13e2944 100644 --- a/src/components/moderation/ProfileHeaderAlerts.tsx +++ b/src/components/moderation/ProfileHeaderAlerts.tsx @@ -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 }) { 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 && } ) } diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx index ef152aa32f..1c2ba2ae1a 100644 --- a/src/screens/Profile/Header/GrowableBanner.tsx +++ b/src/screens/Profile/Header/GrowableBanner.tsx @@ -35,7 +35,7 @@ export function GrowableBanner({ backButton?: React.ReactNode children: React.ReactNode onPress?: () => void - bannerRef?: AnimatedRef + bannerRef?: AnimatedRef testID?: string label?: string }) { @@ -87,7 +87,7 @@ function GrowableBannerInner({ backButton?: React.ReactNode children: React.ReactNode onPress?: () => void - bannerRef?: AnimatedRef + bannerRef?: AnimatedRef testID?: string label?: string }) { diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 94a2a9bb18..a142e1139c 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -60,7 +60,7 @@ let ProfileHeaderShell = ({ const liveStatusControl = useDialogControl() const aviRef = useAnimatedRef() - const bannerRef = useAnimatedRef() + const bannerRef = useAnimatedRef() const onPressBack = useCallback(() => { if (navigation.canGoBack()) { @@ -73,7 +73,7 @@ let ProfileHeaderShell = ({ const _openLightbox = useCallback( ( uri: string, - thumbRef: AnimatedRef, + thumbRef: AnimatedRef, type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi', ) => { openLightbox({ @@ -239,6 +239,7 @@ let ProfileHeaderShell = ({ ) : ( ( ? 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: diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 526a9df8f3..23fd4340ac 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -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, +) { + 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({ + 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() diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 51e89f843a..0ea99f8028 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -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 && } - + {statusNudgeActive && } @@ -331,7 +372,9 @@ let ProfileMenu = ({ Follow account )} - + )} @@ -343,7 +386,7 @@ let ProfileMenu = ({ Add to starter packs - + Add to lists - + {isSelf && canGoLive && ( {!profile.viewer?.blocking && !profile.viewer?.mutedByList && ( - void onPressMuteAccount()}> - - {profile.viewer?.muted ? ( - Unmute account - ) : ( - Mute account - )} - - - + <> + {!profile.viewer?.muted && ( + void onPressMuteReposts()}> + + {profile.viewer?.mutedOnlyReposts ? ( + Show reposts in feeds + ) : ( + Hide reposts in feeds + )} + + + + )} + void onPressMuteAccount()}> + + {profile.viewer?.muted ? ( + Unmute account + ) : ( + Mute account + )} + + + + )} {!profile.viewer?.blockingByList && ( @@ -485,7 +559,7 @@ let ProfileMenu = ({ Report account - + )}