Add timestamp field to video report dialog (#11339)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -857,6 +857,7 @@ export type Events = {
|
||||
reason: string
|
||||
labeler: string
|
||||
details: boolean
|
||||
videoTimestamp: boolean
|
||||
}
|
||||
'reportDialog:failure': {}
|
||||
|
||||
|
||||
@@ -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<DialogControlProps['open']>(() => {
|
||||
// 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<DialogControlProps['close']>(cb => {
|
||||
|
||||
@@ -52,6 +52,7 @@ const preventDefault = (e: any) => e.preventDefault()
|
||||
export function Outer({
|
||||
children,
|
||||
control,
|
||||
onOpen,
|
||||
onClose,
|
||||
webOptions,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
@@ -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<DialogControlProps['close']>(
|
||||
cb => {
|
||||
|
||||
@@ -60,6 +60,7 @@ export type DialogControlOpenOptions = {
|
||||
|
||||
export type DialogOuterProps = {
|
||||
control: DialogControlProps
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
||||
webOptions?: {
|
||||
|
||||
@@ -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<BlueskyVideoView>(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({
|
||||
<Pressable
|
||||
onPress={enterFullscreen}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={_(msg`Video`)}
|
||||
accessibilityHint={_(msg`Enters full screen`)}
|
||||
accessibilityLabel={l`Video`}
|
||||
accessibilityHint={l`Enters full screen`}
|
||||
accessibilityRole="button"
|
||||
/>
|
||||
<ControlButton
|
||||
onPress={togglePlayback}
|
||||
label={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
|
||||
accessibilityHint={_(msg`Plays or pauses the video`)}
|
||||
label={isPlaying ? l`Pause` : l`Play`}
|
||||
accessibilityHint={l`Plays or pauses the video`}
|
||||
style={{left: 6}}>
|
||||
{isPlaying ? (
|
||||
<PauseIcon width={13} fill={t.palette.white} />
|
||||
@@ -175,15 +191,14 @@ function VideoPresentationControls({
|
||||
)}
|
||||
</ControlButton>
|
||||
{showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />}
|
||||
|
||||
<ControlButton
|
||||
onPress={toggleMuted}
|
||||
label={
|
||||
muted
|
||||
? _(msg({message: `Unmute`, context: 'video'}))
|
||||
: _(msg({message: `Mute`, context: 'video'}))
|
||||
? l({message: `Unmute`, context: 'video'})
|
||||
: l({message: `Mute`, context: 'video'})
|
||||
}
|
||||
accessibilityHint={_(msg`Toggles the sound`)}
|
||||
accessibilityHint={l`Toggles the sound`}
|
||||
style={{right: 6}}>
|
||||
{muted ? (
|
||||
<MuteIcon width={13} fill={t.palette.white} />
|
||||
|
||||
@@ -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<Error | null>(null)
|
||||
@@ -63,7 +64,7 @@ export function VideoEmbedInnerWeb({
|
||||
return (
|
||||
<View
|
||||
style={[a.flex_1, a.rounded_md, a.overflow_hidden]}
|
||||
accessibilityLabel={_(msg`Embedded video player`)}
|
||||
accessibilityLabel={l`Embedded video player`}
|
||||
accessibilityHint="">
|
||||
<div ref={containerRef} style={{height: '100%', width: '100%'}}>
|
||||
<figure style={{margin: 0, position: 'absolute', inset: 0}}>
|
||||
@@ -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<T> = Promise<T> & {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<typeof HlsTypes.default>
|
||||
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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -238,16 +238,3 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement | null>) {
|
||||
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 {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 = (
|
||||
<>
|
||||
<ReportDialogMetadataContext.Provider key={quote.uri}>
|
||||
<PostMeta
|
||||
author={quote.author}
|
||||
moderation={moderation}
|
||||
@@ -350,7 +351,7 @@ export function QuoteEmbed({
|
||||
post={quote}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ReportDialogMetadataContext.Provider>
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
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: {
|
||||
|
||||
@@ -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<string, string> = {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 (
|
||||
<Dialog.Outer control={props.control} onClose={onClose}>
|
||||
<Dialog.Outer control={props.control} onOpen={onOpen} onClose={onClose}>
|
||||
<Dialog.Handle />
|
||||
{subject ? <Inner {...props} subject={subject} /> : <Invalid />}
|
||||
{subject ? (
|
||||
<Inner
|
||||
key={presentation.openCount}
|
||||
{...props}
|
||||
subject={subject}
|
||||
videoTimestampSeconds={presentation.videoTimestampSeconds}
|
||||
/>
|
||||
) : (
|
||||
<Invalid />
|
||||
)}
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{videoTimestampSeconds !== undefined &&
|
||||
state.selectedLabeler?.creator.did === BSKY_LABELER_DID && (
|
||||
<IncludeVideoTimestampToggle
|
||||
seconds={videoTimestampSeconds}
|
||||
selected={state.includeVideoTimestamp}
|
||||
onChange={include => {
|
||||
dispatch({type: 'setIncludeVideoTimestamp', include})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
testID="report:submit"
|
||||
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({
|
||||
check,
|
||||
callback,
|
||||
|
||||
@@ -108,3 +108,43 @@ describe('reducer NCII qualification', () => {
|
||||
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
|
||||
activeStepIndex1: number
|
||||
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
|
||||
* qualifying question that determines whether the report should go through
|
||||
@@ -85,6 +90,10 @@ export type ReportAction =
|
||||
| {
|
||||
type: 'showDetails'
|
||||
}
|
||||
| {
|
||||
type: 'setIncludeVideoTimestamp'
|
||||
include: boolean
|
||||
}
|
||||
|
||||
export const initialState: ReportState = {
|
||||
selectedCategory: undefined,
|
||||
@@ -93,6 +102,7 @@ export const initialState: ReportState = {
|
||||
details: undefined,
|
||||
detailsOpen: false,
|
||||
activeStepIndex1: 1,
|
||||
includeVideoTimestamp: false,
|
||||
}
|
||||
|
||||
export function reducer(state: ReportState, action: ReportAction): ReportState {
|
||||
@@ -114,6 +124,7 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
|
||||
activeStepIndex1: 1,
|
||||
detailsOpen: false,
|
||||
ncii: undefined,
|
||||
includeVideoTimestamp: false,
|
||||
}
|
||||
case 'selectOption': {
|
||||
const isNcii = action.option.reason === OzoneReportDefs.REASONSEXUALNCII
|
||||
@@ -134,6 +145,7 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
|
||||
activeStepIndex1: 2,
|
||||
detailsOpen: false,
|
||||
ncii: undefined,
|
||||
includeVideoTimestamp: false,
|
||||
}
|
||||
case 'answerNciiQuestion': {
|
||||
const ncii = {...state.ncii, [action.question]: action.answer}
|
||||
@@ -163,12 +175,18 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
|
||||
detailsOpen: state.selectedOption
|
||||
? OTHER_REPORT_REASONS.has(state.selectedOption?.reason)
|
||||
: 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':
|
||||
return {
|
||||
...state,
|
||||
selectedLabeler: undefined,
|
||||
activeStepIndex1: 3,
|
||||
includeVideoTimestamp: false,
|
||||
}
|
||||
case 'setDetails':
|
||||
return {
|
||||
@@ -190,5 +208,10 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
|
||||
...state,
|
||||
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 {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||
import {TranslatedPost} from '#/components/Post/Translated'
|
||||
@@ -84,16 +85,16 @@ export function ThreadItemAnchor({
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadItemAnchorInner
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadow.uri}
|
||||
item={item}
|
||||
isRoot={isRoot}
|
||||
postShadow={postShadow}
|
||||
onPostSuccess={onPostSuccess}
|
||||
threadgateRecord={threadgateRecord}
|
||||
postSource={postSource}
|
||||
/>
|
||||
<ReportDialogMetadataContext.Provider key={postShadow.uri}>
|
||||
<ThreadItemAnchorInner
|
||||
item={item}
|
||||
isRoot={isRoot}
|
||||
postShadow={postShadow}
|
||||
onPostSuccess={onPostSuccess}
|
||||
threadgateRecord={threadgateRecord}
|
||||
postSource={postSource}
|
||||
/>
|
||||
</ReportDialogMetadataContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from '#/components/images/Gallery'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {PostHider} from '#/components/moderation/PostHider'
|
||||
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||
@@ -76,13 +77,15 @@ export function ThreadItemPost({
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadItemPostInner
|
||||
item={item}
|
||||
postShadow={postShadow}
|
||||
threadgateRecord={threadgateRecord}
|
||||
overrides={overrides}
|
||||
onPostSuccess={onPostSuccess}
|
||||
/>
|
||||
<ReportDialogMetadataContext.Provider key={postShadow.uri}>
|
||||
<ThreadItemPostInner
|
||||
item={item}
|
||||
postShadow={postShadow}
|
||||
threadgateRecord={threadgateRecord}
|
||||
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 {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {PostHider} from '#/components/moderation/PostHider'
|
||||
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||
@@ -75,15 +76,15 @@ export function ThreadItemTreePost({
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadItemTreePostInner
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadow.uri}
|
||||
item={item}
|
||||
postShadow={postShadow}
|
||||
threadgateRecord={threadgateRecord}
|
||||
overrides={overrides}
|
||||
onPostSuccess={onPostSuccess}
|
||||
/>
|
||||
<ReportDialogMetadataContext.Provider key={postShadow.uri}>
|
||||
<ThreadItemTreePostInner
|
||||
item={item}
|
||||
postShadow={postShadow}
|
||||
threadgateRecord={threadgateRecord}
|
||||
overrides={overrides}
|
||||
onPostSuccess={onPostSuccess}
|
||||
/>
|
||||
</ReportDialogMetadataContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'
|
||||
import {useEventListener} from 'expo'
|
||||
import {type VideoPlayer} from 'expo-video'
|
||||
|
||||
import {formatTime} from '#/lib/media/video/formatTime'
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
// 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 {ListFooter} from '#/components/Lists'
|
||||
import * as Hider from '#/components/moderation/Hider'
|
||||
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
|
||||
import {PostControls} from '#/components/PostControls'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -112,7 +113,7 @@ function createThreeVideoPlayers(
|
||||
const eventInterval = platform({
|
||||
ios: 0.2,
|
||||
android: 0.5,
|
||||
default: 0,
|
||||
default: 0.2,
|
||||
})
|
||||
const p1 = createVideoPlayer(sources?.[0] ?? '')
|
||||
p1.loop = true
|
||||
@@ -528,7 +529,7 @@ let VideoItem = ({
|
||||
// we can't distinguish between them
|
||||
const shouldRenderVideo = active || ios(adjacent)
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<View style={[a.relative, {height, width}]}>
|
||||
{postShadow === POST_TOMBSTONE ? (
|
||||
<View
|
||||
@@ -573,6 +574,12 @@ let VideoItem = ({
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<ReportDialogMetadataContext.Provider key={post.uri}>
|
||||
{content}
|
||||
</ReportDialogMetadataContext.Provider>
|
||||
)
|
||||
}
|
||||
VideoItem = memo(VideoItem)
|
||||
|
||||
@@ -587,6 +594,8 @@ function VideoItemInner({
|
||||
}) {
|
||||
const {bottom} = useSafeAreaInsets()
|
||||
const [isReady, setIsReady] = useState(!IS_ANDROID)
|
||||
const reportDialogMetadata =
|
||||
ReportDialogMetadataContext.useReportDialogMetadataContext()
|
||||
|
||||
usePlaybackTelemetry({player, active, playlist: embed.playlist})
|
||||
|
||||
@@ -594,6 +603,19 @@ function VideoItemInner({
|
||||
if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) {
|
||||
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 (
|
||||
|
||||
+13
-10
@@ -31,6 +31,7 @@ import {
|
||||
} 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 {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
|
||||
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
|
||||
@@ -81,16 +82,18 @@ export function Post({
|
||||
}
|
||||
if (record && richText && moderation) {
|
||||
return (
|
||||
<PostInner
|
||||
post={postShadowed}
|
||||
record={record}
|
||||
richText={richText}
|
||||
moderation={moderation}
|
||||
showReplyLine={showReplyLine}
|
||||
hideTopBorder={hideTopBorder}
|
||||
style={style}
|
||||
onBeforePress={onBeforePress}
|
||||
/>
|
||||
<ReportDialogMetadataContext.Provider key={postShadowed.uri}>
|
||||
<PostInner
|
||||
post={postShadowed}
|
||||
record={record}
|
||||
richText={richText}
|
||||
moderation={moderation}
|
||||
showReplyLine={showReplyLine}
|
||||
hideTopBorder={hideTopBorder}
|
||||
style={style}
|
||||
onBeforePress={onBeforePress}
|
||||
/>
|
||||
</ReportDialogMetadataContext.Provider>
|
||||
)
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} 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 {type AppModerationCause} from '#/components/Pills'
|
||||
import {Embed} from '#/components/Post/Embed'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
@@ -112,27 +113,27 @@ export function PostFeedItem({
|
||||
}
|
||||
if (richText && moderation) {
|
||||
return (
|
||||
<FeedItemInner
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadowed.uri}
|
||||
post={postShadowed}
|
||||
record={record}
|
||||
reason={reason}
|
||||
feedContext={feedContext}
|
||||
reqId={reqId}
|
||||
richText={richText}
|
||||
parentAuthor={parentAuthor}
|
||||
showReplyTo={showReplyTo}
|
||||
moderation={moderation}
|
||||
isThreadChild={isThreadChild}
|
||||
isThreadLastChild={isThreadLastChild}
|
||||
isThreadParent={isThreadParent}
|
||||
hideTopBorder={hideTopBorder}
|
||||
isParentBlocked={isParentBlocked}
|
||||
isParentNotFound={isParentNotFound}
|
||||
rootPost={rootPost}
|
||||
onShowLess={onShowLess}
|
||||
/>
|
||||
<ReportDialogMetadataContext.Provider key={postShadowed.uri}>
|
||||
<FeedItemInner
|
||||
post={postShadowed}
|
||||
record={record}
|
||||
reason={reason}
|
||||
feedContext={feedContext}
|
||||
reqId={reqId}
|
||||
richText={richText}
|
||||
parentAuthor={parentAuthor}
|
||||
showReplyTo={showReplyTo}
|
||||
moderation={moderation}
|
||||
isThreadChild={isThreadChild}
|
||||
isThreadLastChild={isThreadLastChild}
|
||||
isThreadParent={isThreadParent}
|
||||
hideTopBorder={hideTopBorder}
|
||||
isParentBlocked={isParentBlocked}
|
||||
isParentNotFound={isParentNotFound}
|
||||
rootPost={rootPost}
|
||||
onShowLess={onShowLess}
|
||||
/>
|
||||
</ReportDialogMetadataContext.Provider>
|
||||
)
|
||||
}
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user