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