Add timestamp field to video report dialog (#11339)
This commit is contained in:
@@ -244,17 +244,6 @@
|
|||||||
"count": 3
|
"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": {
|
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": {
|
||||||
"typescript/no-explicit-any": {
|
"typescript/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
|
|||||||
@@ -857,6 +857,7 @@ export type Events = {
|
|||||||
reason: string
|
reason: string
|
||||||
labeler: string
|
labeler: string
|
||||||
details: boolean
|
details: boolean
|
||||||
|
videoTimestamp: boolean
|
||||||
}
|
}
|
||||||
'reportDialog:failure': {}
|
'reportDialog:failure': {}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const Input = createInput(TextInput)
|
|||||||
export function Outer({
|
export function Outer({
|
||||||
children,
|
children,
|
||||||
control,
|
control,
|
||||||
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
nativeOptions,
|
nativeOptions,
|
||||||
testID,
|
testID,
|
||||||
@@ -97,9 +98,10 @@ export function Outer({
|
|||||||
const open = useCallback<DialogControlProps['open']>(() => {
|
const open = useCallback<DialogControlProps['open']>(() => {
|
||||||
// Run any leftover callbacks that might have been queued up before calling `.open()`
|
// Run any leftover callbacks that might have been queued up before calling `.open()`
|
||||||
callQueuedCallbacks()
|
callQueuedCallbacks()
|
||||||
|
onOpen?.()
|
||||||
setDialogIsOpen(control.id, true)
|
setDialogIsOpen(control.id, true)
|
||||||
ref.current?.present()
|
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.
|
// This is the function that we call when we want to dismiss the dialog.
|
||||||
const close = useCallback<DialogControlProps['close']>(cb => {
|
const close = useCallback<DialogControlProps['close']>(cb => {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const preventDefault = (e: any) => e.preventDefault()
|
|||||||
export function Outer({
|
export function Outer({
|
||||||
children,
|
children,
|
||||||
control,
|
control,
|
||||||
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
webOptions,
|
webOptions,
|
||||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||||
@@ -61,9 +62,10 @@ export function Outer({
|
|||||||
const {setDialogIsOpen} = useDialogStateControlContext()
|
const {setDialogIsOpen} = useDialogStateControlContext()
|
||||||
|
|
||||||
const open = useCallback(() => {
|
const open = useCallback(() => {
|
||||||
|
onOpen?.()
|
||||||
setDialogIsOpen(control.id, true)
|
setDialogIsOpen(control.id, true)
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
}, [setIsOpen, setDialogIsOpen, control.id])
|
}, [setIsOpen, setDialogIsOpen, control.id, onOpen])
|
||||||
|
|
||||||
const close = useCallback<DialogControlProps['close']>(
|
const close = useCallback<DialogControlProps['close']>(
|
||||||
cb => {
|
cb => {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type DialogControlOpenOptions = {
|
|||||||
|
|
||||||
export type DialogOuterProps = {
|
export type DialogOuterProps = {
|
||||||
control: DialogControlProps
|
control: DialogControlProps
|
||||||
|
onOpen?: () => void
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
||||||
webOptions?: {
|
webOptions?: {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import {useImperativeHandle, useRef, useState} from 'react'
|
|||||||
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
|
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
import {type AppBskyEmbedVideo} from '@atproto/api'
|
import {type AppBskyEmbedVideo} from '@atproto/api'
|
||||||
import {BlueskyVideoView} from '@bsky.app/video'
|
import {BlueskyVideoView} from '@bsky.app/video'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {HITSLOP_30} from '#/lib/constants'
|
import {HITSLOP_30} from '#/lib/constants'
|
||||||
import {useAutoplayDisabled} from '#/state/preferences'
|
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 {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||||
import {KeepAwake} from '#/components/KeepAwake'
|
import {KeepAwake} from '#/components/KeepAwake'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
|
import {useReportDialogMetadataContext} from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||||
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||||
import {GifPresentationControls} from '../GifPresentationControls'
|
import {GifPresentationControls} from '../GifPresentationControls'
|
||||||
import {TimeIndicator} from './TimeIndicator'
|
import {TimeIndicator} from './TimeIndicator'
|
||||||
@@ -39,11 +39,13 @@ export function VideoEmbedInnerNative({
|
|||||||
*/
|
*/
|
||||||
onError?: (error: string) => void
|
onError?: (error: string) => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const videoRef = useRef<BlueskyVideoView>(null)
|
const videoRef = useRef<BlueskyVideoView>(null)
|
||||||
const autoplayDisabled = useAutoplayDisabled()
|
const autoplayDisabled = useAutoplayDisabled()
|
||||||
const isWithinMessage = useIsWithinMessage()
|
const isWithinMessage = useIsWithinMessage()
|
||||||
const [muted, setMuted] = useVideoMuteState()
|
const [muted, setMuted] = useVideoMuteState()
|
||||||
|
const reportDialogMetadata = useReportDialogMetadataContext()
|
||||||
|
const maxTimeRemainingSeconds = useRef(0)
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const [timeRemaining, setTimeRemaining] = useState(0)
|
const [timeRemaining, setTimeRemaining] = useState(0)
|
||||||
@@ -84,16 +86,30 @@ export function VideoEmbedInnerNative({
|
|||||||
setIsPlaying(e.nativeEvent.status === 'playing')
|
setIsPlaying(e.nativeEvent.status === 'playing')
|
||||||
}}
|
}}
|
||||||
onTimeRemainingChange={e => {
|
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 => {
|
||||||
onError?.(e.nativeEvent.error)
|
onError?.(e.nativeEvent.error)
|
||||||
setError(e.nativeEvent.error)
|
setError(e.nativeEvent.error)
|
||||||
}}
|
}}
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
accessibilityLabel={
|
accessibilityLabel={embed.alt ? l`Video: ${embed.alt}` : l`Video`}
|
||||||
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
|
|
||||||
}
|
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
/>
|
/>
|
||||||
{isGif ? (
|
{isGif ? (
|
||||||
@@ -144,7 +160,7 @@ function VideoPresentationControls({
|
|||||||
timeRemaining: number
|
timeRemaining: number
|
||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const [muted] = useVideoMuteState()
|
const [muted] = useVideoMuteState()
|
||||||
|
|
||||||
@@ -159,14 +175,14 @@ function VideoPresentationControls({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={enterFullscreen}
|
onPress={enterFullscreen}
|
||||||
style={a.flex_1}
|
style={a.flex_1}
|
||||||
accessibilityLabel={_(msg`Video`)}
|
accessibilityLabel={l`Video`}
|
||||||
accessibilityHint={_(msg`Enters full screen`)}
|
accessibilityHint={l`Enters full screen`}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
/>
|
/>
|
||||||
<ControlButton
|
<ControlButton
|
||||||
onPress={togglePlayback}
|
onPress={togglePlayback}
|
||||||
label={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
|
label={isPlaying ? l`Pause` : l`Play`}
|
||||||
accessibilityHint={_(msg`Plays or pauses the video`)}
|
accessibilityHint={l`Plays or pauses the video`}
|
||||||
style={{left: 6}}>
|
style={{left: 6}}>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<PauseIcon width={13} fill={t.palette.white} />
|
<PauseIcon width={13} fill={t.palette.white} />
|
||||||
@@ -175,15 +191,14 @@ function VideoPresentationControls({
|
|||||||
)}
|
)}
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
{showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />}
|
{showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />}
|
||||||
|
|
||||||
<ControlButton
|
<ControlButton
|
||||||
onPress={toggleMuted}
|
onPress={toggleMuted}
|
||||||
label={
|
label={
|
||||||
muted
|
muted
|
||||||
? _(msg({message: `Unmute`, context: 'video'}))
|
? l({message: `Unmute`, context: 'video'})
|
||||||
: _(msg({message: `Mute`, context: 'video'}))
|
: l({message: `Mute`, context: 'video'})
|
||||||
}
|
}
|
||||||
accessibilityHint={_(msg`Toggles the sound`)}
|
accessibilityHint={l`Toggles the sound`}
|
||||||
style={{right: 6}}>
|
style={{right: 6}}>
|
||||||
{muted ? (
|
{muted ? (
|
||||||
<MuteIcon width={13} fill={t.palette.white} />
|
<MuteIcon width={13} fill={t.palette.white} />
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {useCallback, useEffect, useId, useRef, useState} from 'react'
|
import {useCallback, useEffect, useId, useRef, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import type * as HlsTypes from 'hls.js'
|
import type * as HlsTypes from 'hls.js'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
||||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||||
|
import {useReportDialogMetadataContext} from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||||
import * as BandwidthEstimate from './bandwidth-estimate'
|
import * as BandwidthEstimate from './bandwidth-estimate'
|
||||||
import {
|
import {
|
||||||
HLSFatalError,
|
HLSFatalError,
|
||||||
@@ -36,9 +36,10 @@ export function VideoEmbedInnerWeb({
|
|||||||
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
|
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
|
||||||
const [hlsLoading, setHlsLoading] = useState(false)
|
const [hlsLoading, setHlsLoading] = useState(false)
|
||||||
const figId = useId()
|
const figId = useId()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const [isFullscreen] = useFullscreen(containerRef)
|
const [isFullscreen] = useFullscreen(containerRef)
|
||||||
const isGif = embed.presentation === 'gif'
|
const isGif = embed.presentation === 'gif'
|
||||||
|
const reportDialogMetadata = useReportDialogMetadataContext()
|
||||||
|
|
||||||
// send error up to error boundary
|
// send error up to error boundary
|
||||||
const [error, setError] = useState<Error | null>(null)
|
const [error, setError] = useState<Error | null>(null)
|
||||||
@@ -63,7 +64,7 @@ export function VideoEmbedInnerWeb({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[a.flex_1, a.rounded_md, a.overflow_hidden]}
|
style={[a.flex_1, a.rounded_md, a.overflow_hidden]}
|
||||||
accessibilityLabel={_(msg`Embedded video player`)}
|
accessibilityLabel={l`Embedded video player`}
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<div ref={containerRef} style={{height: '100%', width: '100%'}}>
|
<div ref={containerRef} style={{height: '100%', width: '100%'}}>
|
||||||
<figure style={{margin: 0, position: 'absolute', inset: 0}}>
|
<figure style={{margin: 0, position: 'absolute', inset: 0}}>
|
||||||
@@ -76,7 +77,16 @@ export function VideoEmbedInnerWeb({
|
|||||||
muted={embed.presentation === 'gif' || !focused}
|
muted={embed.presentation === 'gif' || !focused}
|
||||||
aria-labelledby={embed.alt ? figId : undefined}
|
aria-labelledby={embed.alt ? figId : undefined}
|
||||||
onTimeUpdate={e => {
|
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}
|
loop={loop}
|
||||||
/>
|
/>
|
||||||
@@ -141,9 +151,10 @@ type CachedPromise<T> = Promise<T> & {value: undefined | T}
|
|||||||
const promiseForHls = import(
|
const promiseForHls = import(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
'hls.js/dist/hls.min'
|
'hls.js/dist/hls.min'
|
||||||
|
// oxlint-disable-next-line typescript/no-unsafe-member-access
|
||||||
).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default>
|
).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default>
|
||||||
promiseForHls.value = undefined
|
promiseForHls.value = undefined
|
||||||
promiseForHls.then(Hls => {
|
void promiseForHls.then(Hls => {
|
||||||
promiseForHls.value = Hls
|
promiseForHls.value = Hls
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,7 +177,7 @@ function useHLS({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Hls) {
|
if (!Hls) {
|
||||||
setHlsLoading(true)
|
setHlsLoading(true)
|
||||||
promiseForHls.then(loadedHls => {
|
void promiseForHls.then(loadedHls => {
|
||||||
setHls(() => loadedHls)
|
setHls(() => loadedHls)
|
||||||
setHlsLoading(false)
|
setHlsLoading(false)
|
||||||
})
|
})
|
||||||
@@ -303,7 +314,7 @@ function useHLS({
|
|||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
if (
|
if (
|
||||||
data.details === 'manifestLoadError' &&
|
(data.details as string) === 'manifestLoadError' &&
|
||||||
data.response?.code === 404
|
data.response?.code === 404
|
||||||
) {
|
) {
|
||||||
setError(new VideoNotFoundError())
|
setError(new VideoNotFoundError())
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import {View} from 'react-native'
|
|||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {formatTime} from '#/lib/media/video/formatTime'
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
import {IS_WEB_FIREFOX, IS_WEB_TOUCH_DEVICE} from '#/env'
|
import {IS_WEB_FIREFOX, IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||||
import {formatTime} from './utils'
|
|
||||||
|
|
||||||
export function Scrubber({
|
export function Scrubber({
|
||||||
duration,
|
duration,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {msg} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {formatTime} from '#/lib/media/video/formatTime'
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
import {
|
import {
|
||||||
useAutoplayDisabled,
|
useAutoplayDisabled,
|
||||||
@@ -31,7 +32,7 @@ import {GifPresentationControls} from '../../GifPresentationControls'
|
|||||||
import {TimeIndicator} from '../TimeIndicator'
|
import {TimeIndicator} from '../TimeIndicator'
|
||||||
import {ControlButton} from './ControlButton'
|
import {ControlButton} from './ControlButton'
|
||||||
import {Scrubber} from './Scrubber'
|
import {Scrubber} from './Scrubber'
|
||||||
import {formatTime, useVideoElement} from './utils'
|
import {useVideoElement} from './utils'
|
||||||
import {type ControlsProps} from './VideoControls.shared'
|
import {type ControlsProps} from './VideoControls.shared'
|
||||||
import {VolumeControl} from './VolumeControl'
|
import {VolumeControl} from './VolumeControl'
|
||||||
|
|
||||||
|
|||||||
@@ -238,16 +238,3 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement | null>) {
|
|||||||
canPlay,
|
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}`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
|
|||||||
import {GalleryBleed} from '#/components/images/Gallery'
|
import {GalleryBleed} from '#/components/images/Gallery'
|
||||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
|
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||||
import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed'
|
import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed'
|
||||||
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
@@ -311,7 +312,7 @@ export function QuoteEmbed({
|
|||||||
} = useInteractionState()
|
} = useInteractionState()
|
||||||
|
|
||||||
const contents = (
|
const contents = (
|
||||||
<>
|
<ReportDialogMetadataContext.Provider key={quote.uri}>
|
||||||
<PostMeta
|
<PostMeta
|
||||||
author={quote.author}
|
author={quote.author}
|
||||||
moderation={moderation}
|
moderation={moderation}
|
||||||
@@ -350,7 +351,7 @@ export function QuoteEmbed({
|
|||||||
post={quote}
|
post={quote}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</ReportDialogMetadataContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export type ItemProps = ViewStyleProp & {
|
|||||||
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
|
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
|
||||||
hitSlop?: PressableProps['hitSlop']
|
hitSlop?: PressableProps['hitSlop']
|
||||||
highlightRow?: boolean
|
highlightRow?: boolean
|
||||||
|
testID?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useItemContext() {
|
export function useItemContext() {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import {createContext, useContext, useRef} from 'react'
|
||||||
|
|
||||||
|
export type ReportDialogMetadata = {
|
||||||
|
videoTimestampSeconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportDialogMetadataRef = React.RefObject<ReportDialogMetadata>
|
||||||
|
|
||||||
|
const Context = createContext<ReportDialogMetadataRef | null>(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<ReportDialogMetadata>({})
|
||||||
|
|
||||||
|
return <Context.Provider value={metadata}>{children}</Context.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReportDialogMetadataContext() {
|
||||||
|
return useContext(Context)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type $Typed,
|
type $Typed,
|
||||||
|
BSKY_LABELER_DID,
|
||||||
type ChatBskyConvoDefs,
|
type ChatBskyConvoDefs,
|
||||||
type ComAtprotoModerationCreateReport,
|
type ComAtprotoModerationCreateReport,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
@@ -9,7 +10,7 @@ import {useMutation} from '@tanstack/react-query'
|
|||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useAgent} from '#/state/session'
|
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 ReportState} from './state'
|
||||||
import {type ParsedReportSubject} from './types'
|
import {type ParsedReportSubject} from './types'
|
||||||
|
|
||||||
@@ -21,9 +22,15 @@ export function useSubmitReportMutation() {
|
|||||||
async mutationFn({
|
async mutationFn({
|
||||||
subject,
|
subject,
|
||||||
state,
|
state,
|
||||||
|
videoTimestampSeconds,
|
||||||
}: {
|
}: {
|
||||||
subject: ParsedReportSubject
|
subject: ParsedReportSubject
|
||||||
state: ReportState
|
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) {
|
if (!state.selectedOption) {
|
||||||
throw new Error(_(msg`Please select a reason for this report`))
|
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__) {
|
if (__DEV__) {
|
||||||
logger.info('Submitting report (dry run)', {
|
logger.info('Submitting report (dry run)', {
|
||||||
labeler: {
|
labeler: {
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
|
import {applicationId} from 'expo-application'
|
||||||
import {
|
import {
|
||||||
ComAtprotoModerationDefs as RootReportDefs,
|
ComAtprotoModerationDefs as RootReportDefs,
|
||||||
ToolsOzoneReportDefs as OzoneReportDefs,
|
ToolsOzoneReportDefs as OzoneReportDefs,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {type ParsedReportSubject} from '#/components/moderation/ReportDialog/types'
|
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 DMCA_LINK = 'https://bsky.social/about/support/copyright'
|
||||||
export const SUPPORT_PAGE = 'https://bsky.social/about/support'
|
export const SUPPORT_PAGE = 'https://bsky.social/about/support'
|
||||||
export const NCII_FORM = 'https://forms.bsky.app/f/ncii'
|
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<string, string> = {}
|
export const NEW_TO_OLD_REASON_MAPPING: Record<string, string> = {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {type AppBskyLabelerDefs, BSKY_LABELER_DID} from '@atproto/api'
|
|||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {wait} from '#/lib/async/wait'
|
import {wait} from '#/lib/async/wait'
|
||||||
|
import {formatTime} from '#/lib/media/video/formatTime'
|
||||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||||
import {useCallOnce} from '#/lib/once'
|
import {useCallOnce} from '#/lib/once'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
@@ -22,6 +23,7 @@ import * as Admonition from '#/components/Admonition'
|
|||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate'
|
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate'
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +48,7 @@ import {
|
|||||||
} from './const'
|
} from './const'
|
||||||
import {useCopyForSubject} from './copy'
|
import {useCopyForSubject} from './copy'
|
||||||
import {classifyReportError} from './errors'
|
import {classifyReportError} from './errors'
|
||||||
|
import {useReportDialogMetadataContext} from './ReportDialogMetadataContext'
|
||||||
import {
|
import {
|
||||||
getNciiQualificationOutcome,
|
getNciiQualificationOutcome,
|
||||||
initialState,
|
initialState,
|
||||||
@@ -79,19 +82,47 @@ export function ReportDialog(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
|
const reportDialogMetadata = useReportDialogMetadataContext()
|
||||||
const subject = useMemo(
|
const subject = useMemo(
|
||||||
() => (props.subject ? parseReportSubject(props.subject) : undefined),
|
() => (props.subject ? parseReportSubject(props.subject) : undefined),
|
||||||
[props.subject],
|
[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 propsOnClose = props.onClose
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
ax.metric('reportDialog:close', {})
|
ax.metric('reportDialog:close', {})
|
||||||
propsOnClose?.()
|
propsOnClose?.()
|
||||||
}, [ax, propsOnClose])
|
}, [ax, propsOnClose])
|
||||||
return (
|
return (
|
||||||
<Dialog.Outer control={props.control} onClose={onClose}>
|
<Dialog.Outer control={props.control} onOpen={onOpen} onClose={onClose}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
{subject ? <Inner {...props} subject={subject} /> : <Invalid />}
|
{subject ? (
|
||||||
|
<Inner
|
||||||
|
key={presentation.openCount}
|
||||||
|
{...props}
|
||||||
|
subject={subject}
|
||||||
|
videoTimestampSeconds={presentation.videoTimestampSeconds}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Invalid />
|
||||||
|
)}
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -118,7 +149,11 @@ function Invalid() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Inner(props: ReportDialogProps) {
|
function Inner(
|
||||||
|
props: ReportDialogProps & {
|
||||||
|
videoTimestampSeconds?: number
|
||||||
|
},
|
||||||
|
) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const logger = ax.logger.useChild(ax.logger.Context.ReportDialog)
|
const logger = ax.logger.useChild(ax.logger.Context.ReportDialog)
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
@@ -142,6 +177,8 @@ function Inner(props: ReportDialogProps) {
|
|||||||
const [isPending, setIsPending] = useState(false)
|
const [isPending, setIsPending] = useState(false)
|
||||||
const [isSuccess, setIsSuccess] = useState(false)
|
const [isSuccess, setIsSuccess] = useState(false)
|
||||||
|
|
||||||
|
const {videoTimestampSeconds} = props
|
||||||
|
|
||||||
// some reasons ONLY go to Bluesky
|
// some reasons ONLY go to Bluesky
|
||||||
const isBskyOnlyReason = state?.selectedOption?.reason
|
const isBskyOnlyReason = state?.selectedOption?.reason
|
||||||
? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason)
|
? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason)
|
||||||
@@ -230,6 +267,7 @@ function Inner(props: ReportDialogProps) {
|
|||||||
submitReport({
|
submitReport({
|
||||||
subject: props.subject,
|
subject: props.subject,
|
||||||
state,
|
state,
|
||||||
|
videoTimestampSeconds,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setIsSuccess(true)
|
setIsSuccess(true)
|
||||||
@@ -237,6 +275,7 @@ function Inner(props: ReportDialogProps) {
|
|||||||
reason: state.selectedOption?.reason ?? '',
|
reason: state.selectedOption?.reason ?? '',
|
||||||
labeler: state.selectedLabeler?.creator.handle ?? '',
|
labeler: state.selectedLabeler?.creator.handle ?? '',
|
||||||
details: !!state.details,
|
details: !!state.details,
|
||||||
|
videoTimestamp: state.includeVideoTimestamp,
|
||||||
})
|
})
|
||||||
// give time for user feedback
|
// give time for user feedback
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -282,7 +321,7 @@ function Inner(props: ReportDialogProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
}
|
}
|
||||||
}, [logger, submitReport, props, state, ax, l])
|
}, [logger, submitReport, props, state, ax, l, videoTimestampSeconds])
|
||||||
|
|
||||||
useCallOnce(() => {
|
useCallOnce(() => {
|
||||||
ax.metric('reportDialog:open', {
|
ax.metric('reportDialog:open', {
|
||||||
@@ -602,6 +641,18 @@ function Inner(props: ReportDialogProps) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{videoTimestampSeconds !== undefined &&
|
||||||
|
state.selectedLabeler?.creator.did === BSKY_LABELER_DID && (
|
||||||
|
<IncludeVideoTimestampToggle
|
||||||
|
seconds={videoTimestampSeconds}
|
||||||
|
selected={state.includeVideoTimestamp}
|
||||||
|
onChange={include => {
|
||||||
|
dispatch({type: 'setIncludeVideoTimestamp', include})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
testID="report:submit"
|
testID="report:submit"
|
||||||
label={l`Submit report`}
|
label={l`Submit report`}
|
||||||
@@ -632,6 +683,42 @@ function Inner(props: ReportDialogProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-in for attaching how far the viewer had watched to a video report. Only
|
||||||
|
* rendered once we have a position and the report is going to Bluesky.
|
||||||
|
*/
|
||||||
|
function IncludeVideoTimestampToggle({
|
||||||
|
seconds,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
seconds: number
|
||||||
|
selected: boolean
|
||||||
|
onChange: (selected: boolean) => void
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const videoTimestamp = formatTime(seconds)
|
||||||
|
const label = l({
|
||||||
|
message: `Include video timestamp (${videoTimestamp})`,
|
||||||
|
comment:
|
||||||
|
'Checkbox shown when reporting a video. The value is the current playback position, formatted as minutes:seconds.',
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Toggle.Item
|
||||||
|
testID="report:includeVideoTimestamp"
|
||||||
|
name="includeVideoTimestamp"
|
||||||
|
type="checkbox"
|
||||||
|
label={label}
|
||||||
|
value={selected}
|
||||||
|
onChange={onChange}>
|
||||||
|
<Toggle.Checkbox />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.font_normal, a.leading_snug]}>
|
||||||
|
{label}
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</Toggle.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ActionOnce({
|
function ActionOnce({
|
||||||
check,
|
check,
|
||||||
callback,
|
callback,
|
||||||
|
|||||||
@@ -108,3 +108,43 @@ describe('reducer NCII qualification', () => {
|
|||||||
expect(reducer(state, {type: 'clearCategory'}).ncii).toBeUndefined()
|
expect(reducer(state, {type: 'clearCategory'}).ncii).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('reducer video timestamp', () => {
|
||||||
|
const labeler = {} as AppBskyLabelerDefs.LabelerViewDetailed
|
||||||
|
const opted: ReportState = {...initialState, includeVideoTimestamp: true}
|
||||||
|
|
||||||
|
it('is off by default', () => {
|
||||||
|
expect(initialState.includeVideoTimestamp).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles on and back off', () => {
|
||||||
|
let state = reducer(initialState, {
|
||||||
|
type: 'setIncludeVideoTimestamp',
|
||||||
|
include: true,
|
||||||
|
})
|
||||||
|
expect(state.includeVideoTimestamp).toBe(true)
|
||||||
|
state = reducer(state, {type: 'setIncludeVideoTimestamp', include: false})
|
||||||
|
expect(state.includeVideoTimestamp).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears when the moderation service is cleared', () => {
|
||||||
|
expect(reducer(opted, {type: 'clearLabeler'}).includeVideoTimestamp).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears when a moderation service is selected', () => {
|
||||||
|
expect(
|
||||||
|
reducer(opted, {type: 'selectLabeler', labeler}).includeVideoTimestamp,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears when the reason or category is cleared', () => {
|
||||||
|
expect(reducer(opted, {type: 'clearOption'}).includeVideoTimestamp).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
expect(reducer(opted, {type: 'clearCategory'}).includeVideoTimestamp).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export type ReportState = {
|
|||||||
detailsOpen: boolean
|
detailsOpen: boolean
|
||||||
activeStepIndex1: number
|
activeStepIndex1: number
|
||||||
error?: string
|
error?: string
|
||||||
|
/**
|
||||||
|
* Whether to attach how far the viewer had watched to the report. Only
|
||||||
|
* offered for posts with a video.
|
||||||
|
*/
|
||||||
|
includeVideoTimestamp: boolean
|
||||||
/**
|
/**
|
||||||
* Present while the selected reason is NCII. Tracks the answer to the
|
* Present while the selected reason is NCII. Tracks the answer to the
|
||||||
* qualifying question that determines whether the report should go through
|
* qualifying question that determines whether the report should go through
|
||||||
@@ -85,6 +90,10 @@ export type ReportAction =
|
|||||||
| {
|
| {
|
||||||
type: 'showDetails'
|
type: 'showDetails'
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'setIncludeVideoTimestamp'
|
||||||
|
include: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const initialState: ReportState = {
|
export const initialState: ReportState = {
|
||||||
selectedCategory: undefined,
|
selectedCategory: undefined,
|
||||||
@@ -93,6 +102,7 @@ export const initialState: ReportState = {
|
|||||||
details: undefined,
|
details: undefined,
|
||||||
detailsOpen: false,
|
detailsOpen: false,
|
||||||
activeStepIndex1: 1,
|
activeStepIndex1: 1,
|
||||||
|
includeVideoTimestamp: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reducer(state: ReportState, action: ReportAction): ReportState {
|
export function reducer(state: ReportState, action: ReportAction): ReportState {
|
||||||
@@ -114,6 +124,7 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
|
|||||||
activeStepIndex1: 1,
|
activeStepIndex1: 1,
|
||||||
detailsOpen: false,
|
detailsOpen: false,
|
||||||
ncii: undefined,
|
ncii: undefined,
|
||||||
|
includeVideoTimestamp: false,
|
||||||
}
|
}
|
||||||
case 'selectOption': {
|
case 'selectOption': {
|
||||||
const isNcii = action.option.reason === OzoneReportDefs.REASONSEXUALNCII
|
const isNcii = action.option.reason === OzoneReportDefs.REASONSEXUALNCII
|
||||||
@@ -134,6 +145,7 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
|
|||||||
activeStepIndex1: 2,
|
activeStepIndex1: 2,
|
||||||
detailsOpen: false,
|
detailsOpen: false,
|
||||||
ncii: undefined,
|
ncii: undefined,
|
||||||
|
includeVideoTimestamp: false,
|
||||||
}
|
}
|
||||||
case 'answerNciiQuestion': {
|
case 'answerNciiQuestion': {
|
||||||
const ncii = {...state.ncii, [action.question]: action.answer}
|
const ncii = {...state.ncii, [action.question]: action.answer}
|
||||||
@@ -163,12 +175,18 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
|
|||||||
detailsOpen: state.selectedOption
|
detailsOpen: state.selectedOption
|
||||||
? OTHER_REPORT_REASONS.has(state.selectedOption?.reason)
|
? OTHER_REPORT_REASONS.has(state.selectedOption?.reason)
|
||||||
: false,
|
: false,
|
||||||
|
/*
|
||||||
|
* Picking a service is a fresh consent decision - the opt-in is scoped
|
||||||
|
* to Bluesky, so it must not survive a switch to another labeler.
|
||||||
|
*/
|
||||||
|
includeVideoTimestamp: false,
|
||||||
}
|
}
|
||||||
case 'clearLabeler':
|
case 'clearLabeler':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selectedLabeler: undefined,
|
selectedLabeler: undefined,
|
||||||
activeStepIndex1: 3,
|
activeStepIndex1: 3,
|
||||||
|
includeVideoTimestamp: false,
|
||||||
}
|
}
|
||||||
case 'setDetails':
|
case 'setDetails':
|
||||||
return {
|
return {
|
||||||
@@ -190,5 +208,10 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
|
|||||||
...state,
|
...state,
|
||||||
detailsOpen: true,
|
detailsOpen: true,
|
||||||
}
|
}
|
||||||
|
case 'setIncludeVideoTimestamp':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
includeVideoTimestamp: action.include,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {formatTime} from '../formatTime'
|
||||||
|
|
||||||
|
describe('formatTime', () => {
|
||||||
|
it('formats seconds as minutes and seconds', () => {
|
||||||
|
expect(formatTime(0)).toBe('0:00')
|
||||||
|
expect(formatTime(65)).toBe('1:05')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rounds fractional seconds', () => {
|
||||||
|
expect(formatTime(12.5)).toBe('0:13')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles an unknown duration', () => {
|
||||||
|
expect(formatTime(NaN)).toBe('--')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Formats a duration in seconds as minutes and seconds for video controls and
|
||||||
|
* related UI.
|
||||||
|
*/
|
||||||
|
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}`
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ import {GalleryBleed} from '#/components/images/Gallery'
|
|||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
|
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||||
import {type AppModerationCause} from '#/components/Pills'
|
import {type AppModerationCause} from '#/components/Pills'
|
||||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||||
import {TranslatedPost} from '#/components/Post/Translated'
|
import {TranslatedPost} from '#/components/Post/Translated'
|
||||||
@@ -84,16 +85,16 @@ export function ThreadItemAnchor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadItemAnchorInner
|
<ReportDialogMetadataContext.Provider key={postShadow.uri}>
|
||||||
// Safeguard from clobbering per-post state below:
|
<ThreadItemAnchorInner
|
||||||
key={postShadow.uri}
|
item={item}
|
||||||
item={item}
|
isRoot={isRoot}
|
||||||
isRoot={isRoot}
|
postShadow={postShadow}
|
||||||
postShadow={postShadow}
|
onPostSuccess={onPostSuccess}
|
||||||
onPostSuccess={onPostSuccess}
|
threadgateRecord={threadgateRecord}
|
||||||
threadgateRecord={threadgateRecord}
|
postSource={postSource}
|
||||||
postSource={postSource}
|
/>
|
||||||
/>
|
</ReportDialogMetadataContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
} from '#/components/images/Gallery'
|
} from '#/components/images/Gallery'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
import {PostHider} from '#/components/moderation/PostHider'
|
import {PostHider} from '#/components/moderation/PostHider'
|
||||||
|
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||||
import {type AppModerationCause} from '#/components/Pills'
|
import {type AppModerationCause} from '#/components/Pills'
|
||||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||||
@@ -76,13 +77,15 @@ export function ThreadItemPost({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadItemPostInner
|
<ReportDialogMetadataContext.Provider key={postShadow.uri}>
|
||||||
item={item}
|
<ThreadItemPostInner
|
||||||
postShadow={postShadow}
|
item={item}
|
||||||
threadgateRecord={threadgateRecord}
|
postShadow={postShadow}
|
||||||
overrides={overrides}
|
threadgateRecord={threadgateRecord}
|
||||||
onPostSuccess={onPostSuccess}
|
overrides={overrides}
|
||||||
/>
|
onPostSuccess={onPostSuccess}
|
||||||
|
/>
|
||||||
|
</ReportDialogMetadataContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Tra
|
|||||||
import {GalleryBleed} from '#/components/images/Gallery'
|
import {GalleryBleed} from '#/components/images/Gallery'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
import {PostHider} from '#/components/moderation/PostHider'
|
import {PostHider} from '#/components/moderation/PostHider'
|
||||||
|
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||||
import {type AppModerationCause} from '#/components/Pills'
|
import {type AppModerationCause} from '#/components/Pills'
|
||||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||||
@@ -75,15 +76,15 @@ export function ThreadItemTreePost({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadItemTreePostInner
|
<ReportDialogMetadataContext.Provider key={postShadow.uri}>
|
||||||
// Safeguard from clobbering per-post state below:
|
<ThreadItemTreePostInner
|
||||||
key={postShadow.uri}
|
item={item}
|
||||||
item={item}
|
postShadow={postShadow}
|
||||||
postShadow={postShadow}
|
threadgateRecord={threadgateRecord}
|
||||||
threadgateRecord={threadgateRecord}
|
overrides={overrides}
|
||||||
overrides={overrides}
|
onPostSuccess={onPostSuccess}
|
||||||
onPostSuccess={onPostSuccess}
|
/>
|
||||||
/>
|
</ReportDialogMetadataContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'
|
|||||||
import {useEventListener} from 'expo'
|
import {useEventListener} from 'expo'
|
||||||
import {type VideoPlayer} from 'expo-video'
|
import {type VideoPlayer} from 'expo-video'
|
||||||
|
|
||||||
|
import {formatTime} from '#/lib/media/video/formatTime'
|
||||||
import {atoms as a, tokens} from '#/alf'
|
import {atoms as a, tokens} from '#/alf'
|
||||||
import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils'
|
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
// magic number that is roughly the min height of the write reply button
|
// magic number that is roughly the min height of the write reply button
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ import * as Layout from '#/components/Layout'
|
|||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {ListFooter} from '#/components/Lists'
|
import {ListFooter} from '#/components/Lists'
|
||||||
import * as Hider from '#/components/moderation/Hider'
|
import * as Hider from '#/components/moderation/Hider'
|
||||||
|
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||||
import {PostControls} from '#/components/PostControls'
|
import {PostControls} from '#/components/PostControls'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
@@ -112,7 +113,7 @@ function createThreeVideoPlayers(
|
|||||||
const eventInterval = platform({
|
const eventInterval = platform({
|
||||||
ios: 0.2,
|
ios: 0.2,
|
||||||
android: 0.5,
|
android: 0.5,
|
||||||
default: 0,
|
default: 0.2,
|
||||||
})
|
})
|
||||||
const p1 = createVideoPlayer(sources?.[0] ?? '')
|
const p1 = createVideoPlayer(sources?.[0] ?? '')
|
||||||
p1.loop = true
|
p1.loop = true
|
||||||
@@ -528,7 +529,7 @@ let VideoItem = ({
|
|||||||
// we can't distinguish between them
|
// we can't distinguish between them
|
||||||
const shouldRenderVideo = active || ios(adjacent)
|
const shouldRenderVideo = active || ios(adjacent)
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<View style={[a.relative, {height, width}]}>
|
<View style={[a.relative, {height, width}]}>
|
||||||
{postShadow === POST_TOMBSTONE ? (
|
{postShadow === POST_TOMBSTONE ? (
|
||||||
<View
|
<View
|
||||||
@@ -573,6 +574,12 @@ let VideoItem = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReportDialogMetadataContext.Provider key={post.uri}>
|
||||||
|
{content}
|
||||||
|
</ReportDialogMetadataContext.Provider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
VideoItem = memo(VideoItem)
|
VideoItem = memo(VideoItem)
|
||||||
|
|
||||||
@@ -587,6 +594,8 @@ function VideoItemInner({
|
|||||||
}) {
|
}) {
|
||||||
const {bottom} = useSafeAreaInsets()
|
const {bottom} = useSafeAreaInsets()
|
||||||
const [isReady, setIsReady] = useState(!IS_ANDROID)
|
const [isReady, setIsReady] = useState(!IS_ANDROID)
|
||||||
|
const reportDialogMetadata =
|
||||||
|
ReportDialogMetadataContext.useReportDialogMetadataContext()
|
||||||
|
|
||||||
usePlaybackTelemetry({player, active, playlist: embed.playlist})
|
usePlaybackTelemetry({player, active, playlist: embed.playlist})
|
||||||
|
|
||||||
@@ -594,6 +603,19 @@ function VideoItemInner({
|
|||||||
if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) {
|
if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) {
|
||||||
setIsReady(true)
|
setIsReady(true)
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* Players are pooled and reassigned as the user swipes. Only trust the item
|
||||||
|
* that's actually on screen.
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
active &&
|
||||||
|
embed.presentation !== 'gif' &&
|
||||||
|
reportDialogMetadata &&
|
||||||
|
Number.isFinite(evt.currentTime) &&
|
||||||
|
evt.currentTime >= 0
|
||||||
|
) {
|
||||||
|
reportDialogMetadata.current.videoTimestampSeconds = evt.currentTime
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+13
-10
@@ -31,6 +31,7 @@ import {
|
|||||||
} from '#/components/images/Gallery'
|
} from '#/components/images/Gallery'
|
||||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
|
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||||
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
|
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
|
||||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||||
@@ -81,16 +82,18 @@ export function Post({
|
|||||||
}
|
}
|
||||||
if (record && richText && moderation) {
|
if (record && richText && moderation) {
|
||||||
return (
|
return (
|
||||||
<PostInner
|
<ReportDialogMetadataContext.Provider key={postShadowed.uri}>
|
||||||
post={postShadowed}
|
<PostInner
|
||||||
record={record}
|
post={postShadowed}
|
||||||
richText={richText}
|
record={record}
|
||||||
moderation={moderation}
|
richText={richText}
|
||||||
showReplyLine={showReplyLine}
|
moderation={moderation}
|
||||||
hideTopBorder={hideTopBorder}
|
showReplyLine={showReplyLine}
|
||||||
style={style}
|
hideTopBorder={hideTopBorder}
|
||||||
onBeforePress={onBeforePress}
|
style={style}
|
||||||
/>
|
onBeforePress={onBeforePress}
|
||||||
|
/>
|
||||||
|
</ReportDialogMetadataContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from '#/components/images/Gallery'
|
} from '#/components/images/Gallery'
|
||||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
|
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||||
import {type AppModerationCause} from '#/components/Pills'
|
import {type AppModerationCause} from '#/components/Pills'
|
||||||
import {Embed} from '#/components/Post/Embed'
|
import {Embed} from '#/components/Post/Embed'
|
||||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||||
@@ -112,27 +113,27 @@ export function PostFeedItem({
|
|||||||
}
|
}
|
||||||
if (richText && moderation) {
|
if (richText && moderation) {
|
||||||
return (
|
return (
|
||||||
<FeedItemInner
|
<ReportDialogMetadataContext.Provider key={postShadowed.uri}>
|
||||||
// Safeguard from clobbering per-post state below:
|
<FeedItemInner
|
||||||
key={postShadowed.uri}
|
post={postShadowed}
|
||||||
post={postShadowed}
|
record={record}
|
||||||
record={record}
|
reason={reason}
|
||||||
reason={reason}
|
feedContext={feedContext}
|
||||||
feedContext={feedContext}
|
reqId={reqId}
|
||||||
reqId={reqId}
|
richText={richText}
|
||||||
richText={richText}
|
parentAuthor={parentAuthor}
|
||||||
parentAuthor={parentAuthor}
|
showReplyTo={showReplyTo}
|
||||||
showReplyTo={showReplyTo}
|
moderation={moderation}
|
||||||
moderation={moderation}
|
isThreadChild={isThreadChild}
|
||||||
isThreadChild={isThreadChild}
|
isThreadLastChild={isThreadLastChild}
|
||||||
isThreadLastChild={isThreadLastChild}
|
isThreadParent={isThreadParent}
|
||||||
isThreadParent={isThreadParent}
|
hideTopBorder={hideTopBorder}
|
||||||
hideTopBorder={hideTopBorder}
|
isParentBlocked={isParentBlocked}
|
||||||
isParentBlocked={isParentBlocked}
|
isParentNotFound={isParentNotFound}
|
||||||
isParentNotFound={isParentNotFound}
|
rootPost={rootPost}
|
||||||
rootPost={rootPost}
|
onShowLess={onShowLess}
|
||||||
onShowLess={onShowLess}
|
/>
|
||||||
/>
|
</ReportDialogMetadataContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
Reference in New Issue
Block a user