diff --git a/package.json b/package.json index 92b0374720..185e530bd3 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.18.11", + "@atproto/api": "^0.18.13", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.6", diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 58b30358c9..2f8e3105cc 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -389,6 +389,7 @@ let Card = ({ {data && moderationOpts ? ( status.isActive ? ( ageAssuranceRedirectDialogControl: StatefulControl + reportDialogControl: StatefulControl<{subject: ReportSubject}> } const ControlsContext = createContext(null) @@ -51,6 +53,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }>() const ageAssuranceRedirectDialogControl = useStatefulDialogControl() + const reportDialogControl = useStatefulDialogControl<{ + subject: ReportSubject + }>() const ctx = useMemo( () => ({ @@ -60,6 +65,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { emailDialogControl, linkWarningDialogControl, ageAssuranceRedirectDialogControl, + reportDialogControl, }), [ mutedWordsDialogControl, @@ -68,6 +74,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { emailDialogControl, linkWarningDialogControl, ageAssuranceRedirectDialogControl, + reportDialogControl, ], ) diff --git a/src/components/live/GoLiveDisabledDialog.tsx b/src/components/live/GoLiveDisabledDialog.tsx new file mode 100644 index 0000000000..3d548bbb8f --- /dev/null +++ b/src/components/live/GoLiveDisabledDialog.tsx @@ -0,0 +1,147 @@ +import {useCallback, useState} from 'react' +import {View} from 'react-native' +import {type AppBskyActorDefs, ToolsOzoneReportDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation} from '@tanstack/react-query' + +import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants' +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {atoms as a, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' + +export function GoLiveDisabledDialog({ + control, + status, +}: { + control: Dialog.DialogControlProps + status: AppBskyActorDefs.StatusView +}) { + return ( + + + + + ) +} + +export function DialogInner({ + control, + status, +}: { + control: Dialog.DialogControlProps + status: AppBskyActorDefs.StatusView +}) { + const {_} = useLingui() + const agent = useAgent() + const [details, setDetails] = useState('') + + const {mutate, isPending} = useMutation({ + mutationFn: async () => { + if (!agent.session?.did) { + throw new Error('Not logged in') + } + if (!status.uri || !status.cid) { + throw new Error('Status is missing uri or cid') + } + + if (__DEV__) { + logger.info('Submitting go live appeal', { + details, + }) + } else { + await agent.createModerationReport( + { + reasonType: ToolsOzoneReportDefs.REASONAPPEAL, + subject: { + $type: 'com.atproto.repo.strongRef', + uri: status.uri, + cid: status.cid, + }, + reason: details, + }, + { + encoding: 'application/json', + headers: BLUESKY_MOD_SERVICE_HEADERS, + }, + ) + } + }, + onError: () => { + Toast.show(_(msg`Failed to submit appeal, please try again.`), { + type: 'error', + }) + }, + onSuccess: () => { + control.close() + Toast.show(_(msg({message: 'Appeal submitted', context: 'toast'})), { + type: 'success', + }) + }, + }) + + const onSubmit = useCallback(() => mutate(), [mutate]) + + return ( + + + + + Going live is currently disabled for your account + + + + You are currently blocked from using the Go Live feature. To + appeal this moderation decision, please submit the form below. + + + + + This appeal will be sent to Bluesky's moderation service. + + + + + + + + + + + + ) +} diff --git a/src/components/live/LiveStatusDialog.tsx b/src/components/live/LiveStatusDialog.tsx index f15650e485..086f0759c7 100644 --- a/src/components/live/LiveStatusDialog.tsx +++ b/src/components/live/LiveStatusDialog.tsx @@ -17,6 +17,9 @@ import {unstableCacheProfileView} from '#/state/queries/profile' import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' +import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' +import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' @@ -28,6 +31,7 @@ export function LiveStatusDialog({ control, profile, embed, + status, }: { control: Dialog.DialogControlProps profile: bsky.profile.AnyProfileView @@ -38,7 +42,12 @@ export function LiveStatusDialog({ return ( - + ) } @@ -47,10 +56,12 @@ function DialogInner({ profile, embed, navigation, + status, }: { profile: bsky.profile.AnyProfileView embed: AppBskyEmbedExternal.View navigation: NavigationProp + status: AppBskyActorDefs.StatusView }) { const {_} = useLingui() const control = Dialog.useDialogContext() @@ -69,6 +80,7 @@ function DialogInner({ contentContainerStyle={[a.pt_0, a.px_0]} style={[web({maxWidth: 420}), a.overflow_hidden]}> @@ -205,15 +221,43 @@ export function LiveStatus({ )} - - Live feature is in beta testing - + + + + Live feature is in beta + + + {status && ( + { + function open() { + reportDialogControl.open({ + subject: { + ...status, + $type: 'app.bsky.actor.defs#statusView', + }, + }) + } + if (dialogContext.isWithinDialog) { + dialogContext.close(open) + } else { + open() + } + })} + style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}> + Report + + )} + ) diff --git a/src/components/moderation/ReportDialog/action.ts b/src/components/moderation/ReportDialog/action.ts index 1c35bd0494..e7e88ac265 100644 --- a/src/components/moderation/ReportDialog/action.ts +++ b/src/components/moderation/ReportDialog/action.ts @@ -70,6 +70,7 @@ export function useSubmitReportMutation() { } break } + case 'status': case 'post': case 'list': case 'feed': diff --git a/src/components/moderation/ReportDialog/const.ts b/src/components/moderation/ReportDialog/const.ts index 14ff397955..7e5075cce2 100644 --- a/src/components/moderation/ReportDialog/const.ts +++ b/src/components/moderation/ReportDialog/const.ts @@ -3,6 +3,8 @@ import { ToolsOzoneReportDefs as OzoneReportDefs, } from '@atproto/api' +import {type ParsedReportSubject} from '#/components/moderation/ReportDialog/types' + export const DMCA_LINK = 'https://bsky.social/about/support/copyright' export const SUPPORT_PAGE = 'https://bsky.social/about/support' @@ -112,3 +114,10 @@ export const BSKY_LABELER_ONLY_REPORT_REASONS: Set = OzoneReportDefs.REASONCHILDSAFETYOTHER, OzoneReportDefs.REASONVIOLENCEEXTREMISTCONTENT, ]) + +/** + * Set of _parsed_ subject types that should only be sent to Bluesky's + * moderation service. + */ +export const BSKY_LABELER_ONLY_SUBJECT_TYPES: Set = + new Set(['convoMessage', 'status']) diff --git a/src/components/moderation/ReportDialog/copy.ts b/src/components/moderation/ReportDialog/copy.ts index 1027673b12..5fb2ad326d 100644 --- a/src/components/moderation/ReportDialog/copy.ts +++ b/src/components/moderation/ReportDialog/copy.ts @@ -14,6 +14,12 @@ export function useCopyForSubject(subject: ParsedReportSubject) { subtitle: _(msg`Why should this user be reviewed?`), } } + case 'status': { + return { + title: _(msg`Report this livestream`), + subtitle: _(msg`Why should this livestream be reviewed?`), + } + } case 'post': { return { title: _(msg`Report this post`), diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx index 157ad18fb4..14354332cb 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -16,6 +16,7 @@ import {atoms as a, useGutters, useTheme} from '#/alf' 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 {useDelayedLoading} from '#/components/hooks/useDelayedLoading' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate' import { @@ -31,6 +32,7 @@ import {Text} from '#/components/Typography' import {useSubmitReportMutation} from './action' import { BSKY_LABELER_ONLY_REPORT_REASONS, + BSKY_LABELER_ONLY_SUBJECT_TYPES, NEW_TO_OLD_REASONS_MAP, SUPPORT_PAGE, } from './const' @@ -44,17 +46,27 @@ import { useReportOptions, } from './utils/useReportOptions' +export {type ReportSubject} from './types' export {useDialogControl as useReportDialogControl} from '#/components/Dialog' +export function useGlobalReportDialogControl() { + return useGlobalDialogsControlContext().reportDialogControl +} + const logger = Logger.create(Logger.Context.ReportDialog) +export function GlobalReportDialog() { + const {value, control} = useGlobalReportDialogControl() + return +} + export function ReportDialog( props: Omit & { - subject: ReportSubject + subject?: ReportSubject }, ) { const subject = React.useMemo( - () => parseReportSubject(props.subject), + () => (props.subject ? parseReportSubject(props.subject) : undefined), [props.subject], ) const onClose = React.useCallback(() => { @@ -116,8 +128,10 @@ function Inner(props: ReportDialogProps) { const isBskyOnlyReason = state?.selectedOption?.reason ? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason) : false - // some subjects (chats) only go to Bluesky - const isBskyOnlySubject = props.subject.type === 'convoMessage' + // some subjects ONLY go to Bluesky + const isBskyOnlySubject = BSKY_LABELER_ONLY_SUBJECT_TYPES.has( + props.subject.type, + ) /** * Labelers that support this `subject` and its NSID collection @@ -824,12 +838,7 @@ function LabelerCard({ {title} + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> By {sanitizeHandle(labeler.creator.handle, '@')} diff --git a/src/components/moderation/ReportDialog/types.ts b/src/components/moderation/ReportDialog/types.ts index 6df3a82ff6..42fcf7c25e 100644 --- a/src/components/moderation/ReportDialog/types.ts +++ b/src/components/moderation/ReportDialog/types.ts @@ -18,6 +18,7 @@ export type ReportSubject = | $Typed | $Typed | $Typed + | $Typed | $Typed | $Typed | $Typed @@ -38,6 +39,12 @@ export type ParsedReportSubject = quote: boolean } } + | { + type: 'status' + uri: string + cid: string + nsid: string + } | { type: 'list' uri: string diff --git a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts index 0abfe1d9fd..950536199c 100644 --- a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts +++ b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts @@ -33,6 +33,14 @@ export function parseReportSubject( did: subject.did, nsid: 'app.bsky.actor.profile', } + } else if (AppBskyActorDefs.isStatusView(subject)) { + if (!subject.uri || !subject.cid) return + return { + type: 'status', + uri: subject.uri, + cid: subject.cid, + nsid: 'app.bsky.actor.status', + } } else if (AppBskyGraphDefs.isListView(subject)) { return { type: 'list', diff --git a/src/lib/actor-status.ts b/src/lib/actor-status.ts index 7e023be44e..31e532eaa0 100644 --- a/src/lib/actor-status.ts +++ b/src/lib/actor-status.ts @@ -19,15 +19,27 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) { return useMemo(() => { tick! // revalidate every minute - if ( - shadowed && - 'status' in shadowed && - shadowed.status && - validateStatus(shadowed.did, shadowed.status, config) && - isStatusStillActive(shadowed.status.expiresAt) - ) { + if (shadowed && 'status' in shadowed && shadowed.status) { + const isValid = validateStatus(shadowed.did, shadowed.status, config) + const isDisabled = shadowed.status.isDisabled || false + const isActive = isStatusStillActive(shadowed.status.expiresAt) + if (isValid && !isDisabled && isActive) { + return { + uri: shadowed.status.uri, + cid: shadowed.status.cid, + isDisabled: false, + isActive: true, + status: 'app.bsky.actor.status#live', + embed: shadowed.status.embed as $Typed, // temp_isStatusValid asserts this + expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this + record: shadowed.status.record, + } satisfies AppBskyActorDefs.StatusView + } return { - isActive: true, + uri: shadowed.status.uri, + cid: shadowed.status.cid, + isDisabled, + isActive: false, status: 'app.bsky.actor.status#live', embed: shadowed.status.embed as $Typed, // temp_isStatusValid asserts this expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this @@ -36,6 +48,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) { } else { return { status: '', + isDisabled: false, isActive: false, record: {}, } satisfies AppBskyActorDefs.StatusView diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 5fc976ecc2..8cf6d2c968 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -49,6 +49,7 @@ import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/ import {StarterPack} from '#/components/icons/StarterPack' import {EditLiveDialog} from '#/components/live/EditLiveDialog' import {GoLiveDialog} from '#/components/live/GoLiveDialog' +import {GoLiveDisabledDialog} from '#/components/live/GoLiveDisabledDialog' import * as Menu from '#/components/Menu' import { ReportDialog, @@ -79,6 +80,7 @@ let ProfileMenu = ({ const [devModeEnabled] = useDevMode() const verification = useFullVerificationState({profile}) const canGoLive = useCanGoLive(currentAccount?.did) + const status = useActorStatus(profile) const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) @@ -90,6 +92,7 @@ let ProfileMenu = ({ const blockPromptControl = Prompt.usePromptControl() const loggedOutWarningPromptControl = Prompt.usePromptControl() const goLiveDialogControl = useDialogControl() + const goLiveDisabledDialogControl = useDialogControl() const addToStarterPacksDialogControl = useDialogControl() const showLoggedOutWarning = React.useMemo(() => { @@ -220,8 +223,6 @@ let ProfileMenu = ({ return v.issuer === currentAccount?.did }) ?? [] - const status = useActorStatus(profile) - return ( @@ -330,13 +331,21 @@ let ProfileMenu = ({ + onPress={ + status.isDisabled + ? goLiveDisabledDialogControl.open + : goLiveDialogControl.open + }> - {status.isActive ? ( + {status.isDisabled ? ( + Go live (disabled) + ) : status.isActive ? ( Edit live status ) : ( Go live @@ -517,7 +526,12 @@ let ProfileMenu = ({ verifications={currentAccountVerifications} /> - {status.isActive ? ( + {status.isDisabled ? ( + + ) : status.isActive ? ( + {/* Until policy update has been completed by the user, don't render anything that is portaled */} {policyUpdateState.completed && ( diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 4dee3d17bd..1f687a1b98 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -24,6 +24,7 @@ import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {NuxDialogs} from '#/components/dialogs/nuxs' import {SigninDialog} from '#/components/dialogs/Signin' import {useWelcomeModal} from '#/components/hooks/useWelcomeModal' +import {GlobalReportDialog} from '#/components/moderation/ReportDialog' import { Outlet as PolicyUpdateOverlayPortalOutlet, usePolicyUpdateContext, @@ -73,6 +74,7 @@ function ShellInner() { + {welcomeModalControl.isOpen && ( diff --git a/yarn.lock b/yarn.lock index 3fea12b360..a010c615be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -82,12 +82,12 @@ "@atproto/xrpc" "^0.7.6" "@atproto/xrpc-server" "^0.10.0" -"@atproto/api@^0.18.11": - version "0.18.11" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.11.tgz#38b8bacecaae4c24bc29bd98b34f270d8be675b3" - integrity sha512-YhgyL4rOBFOIs+e/8s5S+EjwM3T5q65IKh30ZoIrWcQS/2uteiIzg1xB+iXmexZq4Cwug0NLfRUJDViDK/ut0w== +"@atproto/api@^0.18.13": + version "0.18.13" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.13.tgz#63eee310e6715752eb87748323cf9ab57dd91e4b" + integrity sha512-CULZ01pSJDltLS/Gc9MMrhFzB6OM3ezyZw7KoeLT/sBfwgA1ddA4mWdTh7DIRosPRigXtA05bnoiCutZbQDo+Q== dependencies: - "@atproto/common-web" "^0.4.10" + "@atproto/common-web" "^0.4.11" "@atproto/lexicon" "^0.6.0" "@atproto/syntax" "^0.4.2" "@atproto/xrpc" "^0.7.7" @@ -208,13 +208,13 @@ pino-http "^8.2.1" typed-emitter "^2.1.0" -"@atproto/common-web@^0.4.10": - version "0.4.10" - resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.10.tgz#3d29df4dc3ea8f5c149161209f55e146f27867c1" - integrity sha512-TLDZSgSKzT8ZgOrBrTGK87J1CXve9TEuY6NVVUBRkOMzRRtQzpFb9/ih5WVS/hnaWVvE30CfuyaetRoma+WKNw== +"@atproto/common-web@^0.4.11": + version "0.4.11" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.11.tgz#eb41dc02c1ea4221388630e193d181fb098186e0" + integrity sha512-VHejNmSABU8/03VrQ3e36AmT5U3UIeio+qSUqCrO1oNgrJcWfGy1rpj0FVtUugWF8Un29+yzkukzWGZfXL70rQ== dependencies: - "@atproto/lex-data" "0.0.6" - "@atproto/lex-json" "0.0.6" + "@atproto/lex-data" "0.0.7" + "@atproto/lex-json" "0.0.7" zod "^3.23.8" "@atproto/common-web@^0.4.4", "@atproto/common-web@^0.4.6": @@ -415,10 +415,10 @@ uint8arrays "3.0.0" unicode-segmenter "^0.14.0" -"@atproto/lex-data@0.0.6": - version "0.0.6" - resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.6.tgz#280d05ec9579ab091dc4a8b696ebbeddcb8ee37d" - integrity sha512-MBNB4ghRJQzuXK1zlUPljpPbQcF1LZ5dzxy274KqPt4p3uPuRw0mHjgcCoWzRUNBQC685WMQR4IN9DHtsnG57A== +"@atproto/lex-data@0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.7.tgz#6aa87423f6d47849bec8ff3ca0b00ce93964adc8" + integrity sha512-W/Q5o9o7n2Sv3UywckChu01X5lwQUtaiiOkGJLnRsdkQTyC6813nPgY+p2sG7NwwM+82lu+FUV9fE/Ul3VqaJw== dependencies: "@atproto/syntax" "0.4.2" multiformats "^9.9.0" @@ -451,12 +451,12 @@ "@atproto/lex-data" "0.0.3" tslib "^2.8.1" -"@atproto/lex-json@0.0.6": - version "0.0.6" - resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.6.tgz#50618efb1cc11708b3897de14dcd3bd9c06e064d" - integrity sha512-EILnN5cditPvf+PCNjXt7reMuzjugxAL1fpSzmzJbEMGMUwxOf5pPWxRsaA/M3Boip4NQZ+6DVrPOGUMlnqceg== +"@atproto/lex-json@0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.7.tgz#c06e1fc3e06d739bbb74694f5d846055bed37866" + integrity sha512-bjNPD5M/MhLfjNM7tcxuls80UgXpHqxdOxDXEUouAtZQV/nIDhGjmNUvKxOmOgnDsiZRnT2g5y3onrnjH3a44g== dependencies: - "@atproto/lex-data" "0.0.6" + "@atproto/lex-data" "0.0.7" tslib "^2.8.1" "@atproto/lex-resolver@0.0.5":