From 724013df1be54990e069907c66c379e077d67e61 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 6 Aug 2026 13:53:02 -0500 Subject: [PATCH 01/35] Add repost-only mutes (#11223) Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> --- .../repostStrike_stroke2_corner0_rounded.svg | 1 + oxlint-suppressions.json | 5 - package.json | 2 +- pnpm-lock.yaml | 28 ++-- src/analytics/metrics/types.ts | 2 + src/components/Pills.tsx | 25 ++- src/components/ProfileCard.tsx | 4 +- src/components/icons/Repost.tsx | 4 + .../moderation/ProfileHeaderAlerts.tsx | 8 +- src/screens/Profile/Header/GrowableBanner.tsx | 4 +- src/screens/Profile/Header/Shell.tsx | 5 +- src/state/cache/profile-shadow.ts | 12 +- src/state/queries/profile.ts | 79 +++++++++- src/view/com/profile/ProfileMenu.tsx | 144 +++++++++++++----- 14 files changed, 259 insertions(+), 64 deletions(-) create mode 100644 assets/icons/repostStrike_stroke2_corner0_rounded.svg 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 - + )} From 1c349ad7da4fd835d3bc1067046292fc07f92271 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:11:05 -0700 Subject: [PATCH 02/35] Add timestamp field to video report dialog (#11339) --- oxlint-suppressions.json | 11 --- src/analytics/metrics/types.ts | 1 + src/components/Dialog/index.tsx | 4 +- src/components/Dialog/index.web.tsx | 4 +- src/components/Dialog/types.ts | 1 + .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 47 +++++---- .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 27 ++++-- .../VideoEmbedInner/web-controls/Scrubber.tsx | 2 +- .../web-controls/VideoControls.tsx | 3 +- .../VideoEmbedInner/web-controls/utils.tsx | 13 --- src/components/Post/Embed/index.tsx | 5 +- src/components/forms/Toggle/index.tsx | 1 + .../ReportDialogMetadataContext.tsx | 24 +++++ .../moderation/ReportDialog/action.ts | 24 ++++- .../moderation/ReportDialog/const.ts | 13 +++ .../moderation/ReportDialog/index.tsx | 95 ++++++++++++++++++- .../moderation/ReportDialog/state.test.ts | 40 ++++++++ .../moderation/ReportDialog/state.ts | 23 +++++ .../media/video/__tests__/formatTime.test.ts | 16 ++++ src/lib/media/video/formatTime.ts | 16 ++++ .../components/ThreadItemAnchor.tsx | 21 ++-- .../PostThread/components/ThreadItemPost.tsx | 17 ++-- .../components/ThreadItemTreePost.tsx | 19 ++-- src/screens/VideoFeed/components/Scrubber.tsx | 2 +- src/screens/VideoFeed/index.tsx | 26 ++++- src/view/com/post/Post.tsx | 23 +++-- src/view/com/posts/PostFeedItem.tsx | 43 +++++---- 27 files changed, 402 insertions(+), 119 deletions(-) create mode 100644 src/components/moderation/ReportDialog/ReportDialogMetadataContext.tsx create mode 100644 src/lib/media/video/__tests__/formatTime.test.ts create mode 100644 src/lib/media/video/formatTime.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 2aee77333c..ae3513a254 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -244,17 +244,6 @@ "count": 3 } }, - "src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": { - "typescript/no-floating-promises": { - "count": 2 - }, - "typescript/no-unsafe-enum-comparison": { - "count": 1 - }, - "typescript/no-unsafe-member-access": { - "count": 1 - } - }, "src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": { "typescript/no-explicit-any": { "count": 2 diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 9e5358e23a..739b52f622 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -857,6 +857,7 @@ export type Events = { reason: string labeler: string details: boolean + videoTimestamp: boolean } 'reportDialog:failure': {} diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 4f48dfce8d..4f6984a4a0 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -62,6 +62,7 @@ export const Input = createInput(TextInput) export function Outer({ children, control, + onOpen, onClose, nativeOptions, testID, @@ -97,9 +98,10 @@ export function Outer({ const open = useCallback(() => { // Run any leftover callbacks that might have been queued up before calling `.open()` callQueuedCallbacks() + onOpen?.() setDialogIsOpen(control.id, true) ref.current?.present() - }, [setDialogIsOpen, control.id, callQueuedCallbacks]) + }, [setDialogIsOpen, control.id, callQueuedCallbacks, onOpen]) // This is the function that we call when we want to dismiss the dialog. const close = useCallback(cb => { diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 5363fbf1bd..c26a71825b 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -52,6 +52,7 @@ const preventDefault = (e: any) => e.preventDefault() export function Outer({ children, control, + onOpen, onClose, webOptions, }: React.PropsWithChildren) { @@ -61,9 +62,10 @@ export function Outer({ const {setDialogIsOpen} = useDialogStateControlContext() const open = useCallback(() => { + onOpen?.() setDialogIsOpen(control.id, true) setIsOpen(true) - }, [setIsOpen, setDialogIsOpen, control.id]) + }, [setIsOpen, setDialogIsOpen, control.id, onOpen]) const close = useCallback( cb => { diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index 79c40942de..341cb6c365 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -60,6 +60,7 @@ export type DialogControlOpenOptions = { export type DialogOuterProps = { control: DialogControlProps + onOpen?: () => void onClose?: () => void nativeOptions?: Omit webOptions?: { diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx index bead58fd91..bcc33ba50b 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -2,8 +2,7 @@ import {useImperativeHandle, useRef, useState} from 'react' import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' import {type AppBskyEmbedVideo} from '@atproto/api' import {BlueskyVideoView} from '@bsky.app/video' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {HITSLOP_30} from '#/lib/constants' import {useAutoplayDisabled} from '#/state/preferences' @@ -16,6 +15,7 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' import {KeepAwake} from '#/components/KeepAwake' import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {useReportDialogMetadataContext} from '#/components/moderation/ReportDialog/ReportDialogMetadataContext' import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {GifPresentationControls} from '../GifPresentationControls' import {TimeIndicator} from './TimeIndicator' @@ -39,11 +39,13 @@ export function VideoEmbedInnerNative({ */ onError?: (error: string) => void }) { - const {_} = useLingui() + const {t: l} = useLingui() const videoRef = useRef(null) const autoplayDisabled = useAutoplayDisabled() const isWithinMessage = useIsWithinMessage() const [muted, setMuted] = useVideoMuteState() + const reportDialogMetadata = useReportDialogMetadataContext() + const maxTimeRemainingSeconds = useRef(0) const [isPlaying, setIsPlaying] = useState(false) const [timeRemaining, setTimeRemaining] = useState(0) @@ -84,16 +86,30 @@ export function VideoEmbedInnerNative({ setIsPlaying(e.nativeEvent.status === 'playing') }} onTimeRemainingChange={e => { - setTimeRemaining(e.nativeEvent.timeRemaining) + const {timeRemaining} = e.nativeEvent + setTimeRemaining(timeRemaining) + if ( + !isGif && + reportDialogMetadata && + Number.isFinite(timeRemaining) && + timeRemaining >= 0 + ) { + maxTimeRemainingSeconds.current = Math.max( + maxTimeRemainingSeconds.current, + timeRemaining, + ) + reportDialogMetadata.current.videoTimestampSeconds = Math.max( + 0, + maxTimeRemainingSeconds.current - timeRemaining, + ) + } }} onError={e => { onError?.(e.nativeEvent.error) setError(e.nativeEvent.error) }} ref={videoRef} - accessibilityLabel={ - embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) - } + accessibilityLabel={embed.alt ? l`Video: ${embed.alt}` : l`Video`} accessibilityHint="" /> {isGif ? ( @@ -144,7 +160,7 @@ function VideoPresentationControls({ timeRemaining: number isPlaying: boolean }) { - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() const [muted] = useVideoMuteState() @@ -159,14 +175,14 @@ function VideoPresentationControls({ {isPlaying ? ( @@ -175,15 +191,14 @@ function VideoPresentationControls({ )} {showTime && } - {muted ? ( diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 0599240816..d20cc0d90a 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -1,13 +1,13 @@ import {useCallback, useEffect, useId, useRef, useState} from 'react' import {View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import type * as HlsTypes from 'hls.js' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a} from '#/alf' import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog' import {useFullscreen} from '#/components/hooks/useFullscreen' +import {useReportDialogMetadataContext} from '#/components/moderation/ReportDialog/ReportDialogMetadataContext' import * as BandwidthEstimate from './bandwidth-estimate' import { HLSFatalError, @@ -36,9 +36,10 @@ export function VideoEmbedInnerWeb({ const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) const [hlsLoading, setHlsLoading] = useState(false) const figId = useId() - const {_} = useLingui() + const {t: l} = useLingui() const [isFullscreen] = useFullscreen(containerRef) const isGif = embed.presentation === 'gif' + const reportDialogMetadata = useReportDialogMetadataContext() // send error up to error boundary const [error, setError] = useState(null) @@ -63,7 +64,7 @@ export function VideoEmbedInnerWeb({ return (
@@ -76,7 +77,16 @@ export function VideoEmbedInnerWeb({ muted={embed.presentation === 'gif' || !focused} aria-labelledby={embed.alt ? figId : undefined} onTimeUpdate={e => { - lastKnownTime.current = e.currentTarget.currentTime + const currentTime = e.currentTarget.currentTime + lastKnownTime.current = currentTime + if ( + !isGif && + reportDialogMetadata && + Number.isFinite(currentTime) && + currentTime >= 0 + ) { + reportDialogMetadata.current.videoTimestampSeconds = currentTime + } }} loop={loop} /> @@ -141,9 +151,10 @@ type CachedPromise = Promise & {value: undefined | T} const promiseForHls = import( // @ts-ignore 'hls.js/dist/hls.min' + // oxlint-disable-next-line typescript/no-unsafe-member-access ).then(mod => mod.default) as CachedPromise promiseForHls.value = undefined -promiseForHls.then(Hls => { +void promiseForHls.then(Hls => { promiseForHls.value = Hls }) @@ -166,7 +177,7 @@ function useHLS({ useEffect(() => { if (!Hls) { setHlsLoading(true) - promiseForHls.then(loadedHls => { + void promiseForHls.then(loadedHls => { setHls(() => loadedHls) setHlsLoading(false) }) @@ -303,7 +314,7 @@ function useHLS({ hls.on(Hls.Events.ERROR, (_event, data) => { if (data.fatal) { if ( - data.details === 'manifestLoadError' && + (data.details as string) === 'manifestLoadError' && data.response?.code === 404 ) { setError(new VideoNotFoundError()) diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx index 13fe496a4f..7ee85107d1 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx @@ -3,11 +3,11 @@ import {View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' +import {formatTime} from '#/lib/media/video/formatTime' import {clamp} from '#/lib/numbers' import {atoms as a, useTheme, web} from '#/alf' import {useInteractionState} from '#/components/hooks/useInteractionState' import {IS_WEB_FIREFOX, IS_WEB_TOUCH_DEVICE} from '#/env' -import {formatTime} from './utils' export function Scrubber({ duration, diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx index 57f52e2f22..e852db3b27 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx @@ -4,6 +4,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {formatTime} from '#/lib/media/video/formatTime' import {clamp} from '#/lib/numbers' import { useAutoplayDisabled, @@ -31,7 +32,7 @@ import {GifPresentationControls} from '../../GifPresentationControls' import {TimeIndicator} from '../TimeIndicator' import {ControlButton} from './ControlButton' import {Scrubber} from './Scrubber' -import {formatTime, useVideoElement} from './utils' +import {useVideoElement} from './utils' import {type ControlsProps} from './VideoControls.shared' import {VolumeControl} from './VolumeControl' diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx index 164b214ce7..9c0f644b58 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx @@ -238,16 +238,3 @@ export function useVideoElement(ref: RefObject) { canPlay, } } - -export function formatTime(time: number) { - if (isNaN(time)) { - return '--' - } - - time = Math.round(time) - - const minutes = Math.floor(time / 60) - const seconds = String(time % 60).padStart(2, '0') - - return `${minutes}:${seconds}` -} diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index c715dbd3ad..02d20caf12 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -23,6 +23,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState' import {GalleryBleed} from '#/components/images/Gallery' import {ContentHider} from '#/components/moderation/ContentHider' import {PostAlerts} from '#/components/moderation/PostAlerts' +import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext' import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed' import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils' import {RichText} from '#/components/RichText' @@ -311,7 +312,7 @@ export function QuoteEmbed({ } = useInteractionState() const contents = ( - <> + )} - + ) return ( diff --git a/src/components/forms/Toggle/index.tsx b/src/components/forms/Toggle/index.tsx index a62a82b62f..6d5f8f2a89 100644 --- a/src/components/forms/Toggle/index.tsx +++ b/src/components/forms/Toggle/index.tsx @@ -82,6 +82,7 @@ export type ItemProps = ViewStyleProp & { children: ((props: ItemState) => React.ReactNode) | React.ReactNode hitSlop?: PressableProps['hitSlop'] highlightRow?: boolean + testID?: string } export function useItemContext() { diff --git a/src/components/moderation/ReportDialog/ReportDialogMetadataContext.tsx b/src/components/moderation/ReportDialog/ReportDialogMetadataContext.tsx new file mode 100644 index 0000000000..b4f0c205f0 --- /dev/null +++ b/src/components/moderation/ReportDialog/ReportDialogMetadataContext.tsx @@ -0,0 +1,24 @@ +import {createContext, useContext, useRef} from 'react' + +export type ReportDialogMetadata = { + videoTimestampSeconds?: number +} + +export type ReportDialogMetadataRef = React.RefObject + +const Context = createContext(null) +Context.displayName = 'ReportDialogMetadataContext' + +/** + * Scopes report metadata to a rendered subject. The mutable ref lets media + * events update metadata without rerendering the post on every playback tick. + */ +export function Provider({children}: React.PropsWithChildren) { + const metadata = useRef({}) + + return {children} +} + +export function useReportDialogMetadataContext() { + return useContext(Context) +} diff --git a/src/components/moderation/ReportDialog/action.ts b/src/components/moderation/ReportDialog/action.ts index ac702abd67..20303ddef5 100644 --- a/src/components/moderation/ReportDialog/action.ts +++ b/src/components/moderation/ReportDialog/action.ts @@ -1,5 +1,6 @@ import { type $Typed, + BSKY_LABELER_DID, type ChatBskyConvoDefs, type ComAtprotoModerationCreateReport, } from '@atproto/api' @@ -9,7 +10,7 @@ import {useMutation} from '@tanstack/react-query' import {logger} from '#/logger' import {useAgent} from '#/state/session' -import {NEW_TO_OLD_REASONS_MAP} from './const' +import {NEW_TO_OLD_REASONS_MAP, REPORT_MOD_TOOL_NAME} from './const' import {type ReportState} from './state' import {type ParsedReportSubject} from './types' @@ -21,9 +22,15 @@ export function useSubmitReportMutation() { async mutationFn({ subject, state, + videoTimestampSeconds, }: { subject: ParsedReportSubject state: ReportState + /** + * How far the viewer watched when the dialog opened, if the subject is a + * post with a video. + */ + videoTimestampSeconds?: number }) { if (!state.selectedOption) { throw new Error(_(msg`Please select a reason for this report`)) @@ -115,6 +122,21 @@ export function useSubmitReportMutation() { } } + const modToolMeta = + state.includeVideoTimestamp && + videoTimestampSeconds != null && + subject.type === 'post' && + labeler.creator.did === BSKY_LABELER_DID + ? {videoTimestampSeconds} + : undefined + + if (modToolMeta) { + report.modTool = { + name: REPORT_MOD_TOOL_NAME, + meta: modToolMeta, + } + } + if (__DEV__) { logger.info('Submitting report (dry run)', { labeler: { diff --git a/src/components/moderation/ReportDialog/const.ts b/src/components/moderation/ReportDialog/const.ts index 8f5f16db9f..727b2f90e9 100644 --- a/src/components/moderation/ReportDialog/const.ts +++ b/src/components/moderation/ReportDialog/const.ts @@ -1,14 +1,27 @@ +import {applicationId} from 'expo-application' import { ComAtprotoModerationDefs as RootReportDefs, ToolsOzoneReportDefs as OzoneReportDefs, } from '@atproto/api' import {type ParsedReportSubject} from '#/components/moderation/ReportDialog/types' +import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' export const DMCA_LINK = 'https://bsky.social/about/support/copyright' export const SUPPORT_PAGE = 'https://bsky.social/about/support' export const NCII_FORM = 'https://forms.bsky.app/f/ncii' +/** + * Identifies this client as the source of a report. + */ +export const REPORT_MOD_TOOL_NAME = IS_IOS + ? `bsky-app/ios/${applicationId}` + : IS_ANDROID + ? `bsky-app/android/${applicationId}` + : IS_WEB + ? `bsky-web/${window.location.hostname}` + : 'bsky' // Should never occur + export const NEW_TO_OLD_REASON_MAPPING: Record = {} /** diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx index 6f405372fc..7c63d04a47 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -11,6 +11,7 @@ import {type AppBskyLabelerDefs, BSKY_LABELER_DID} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {wait} from '#/lib/async/wait' +import {formatTime} from '#/lib/media/video/formatTime' import {getLabelingServiceTitle} from '#/lib/moderation' import {useCallOnce} from '#/lib/once' import {sanitizeHandle} from '#/lib/strings/handles' @@ -22,6 +23,7 @@ import * as Admonition from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import * as Toggle from '#/components/forms/Toggle' import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate' import { @@ -46,6 +48,7 @@ import { } from './const' import {useCopyForSubject} from './copy' import {classifyReportError} from './errors' +import {useReportDialogMetadataContext} from './ReportDialogMetadataContext' import { getNciiQualificationOutcome, initialState, @@ -79,19 +82,47 @@ export function ReportDialog( }, ) { const ax = useAnalytics() + const reportDialogMetadata = useReportDialogMetadataContext() const subject = useMemo( () => (props.subject ? parseReportSubject(props.subject) : undefined), [props.subject], ) + const [presentation, setPresentation] = useState<{ + openCount: number + videoTimestampSeconds?: number + }>({openCount: 0}) + const onOpen = useCallback(() => { + const seconds = + subject?.type === 'post' && subject.attributes.video + ? reportDialogMetadata?.current.videoTimestampSeconds + : undefined + + setPresentation(current => ({ + openCount: current.openCount + 1, + // Values below one second indicate that the video was never meaningfully + // played, so avoid offering to attach a noisy "0:00" timestamp. + videoTimestampSeconds: + seconds !== undefined && seconds >= 1 ? Math.floor(seconds) : undefined, + })) + }, [reportDialogMetadata, subject]) const propsOnClose = props.onClose const onClose = useCallback(() => { ax.metric('reportDialog:close', {}) propsOnClose?.() }, [ax, propsOnClose]) return ( - + - {subject ? : } + {subject ? ( + + ) : ( + + )} ) } @@ -118,7 +149,11 @@ function Invalid() { ) } -function Inner(props: ReportDialogProps) { +function Inner( + props: ReportDialogProps & { + videoTimestampSeconds?: number + }, +) { const ax = useAnalytics() const logger = ax.logger.useChild(ax.logger.Context.ReportDialog) const t = useTheme() @@ -142,6 +177,8 @@ function Inner(props: ReportDialogProps) { const [isPending, setIsPending] = useState(false) const [isSuccess, setIsSuccess] = useState(false) + const {videoTimestampSeconds} = props + // some reasons ONLY go to Bluesky const isBskyOnlyReason = state?.selectedOption?.reason ? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason) @@ -230,6 +267,7 @@ function Inner(props: ReportDialogProps) { submitReport({ subject: props.subject, state, + videoTimestampSeconds, }), ) setIsSuccess(true) @@ -237,6 +275,7 @@ function Inner(props: ReportDialogProps) { reason: state.selectedOption?.reason ?? '', labeler: state.selectedLabeler?.creator.handle ?? '', details: !!state.details, + videoTimestamp: state.includeVideoTimestamp, }) // give time for user feedback setTimeout(() => { @@ -282,7 +321,7 @@ function Inner(props: ReportDialogProps) { } finally { setIsPending(false) } - }, [logger, submitReport, props, state, ax, l]) + }, [logger, submitReport, props, state, ax, l, videoTimestampSeconds]) useCallOnce(() => { ax.metric('reportDialog:open', { @@ -602,6 +641,18 @@ function Inner(props: ReportDialogProps) { )} + + {videoTimestampSeconds !== undefined && + state.selectedLabeler?.creator.did === BSKY_LABELER_DID && ( + { + dispatch({type: 'setIncludeVideoTimestamp', include}) + }} + /> + )} +