Bluesky app fork with some witchin' additions 💫

New reporting flow (#7832)

* Add option to align web dialogs to top

* Add new wait util

* Pipe through feed view to feed components

* Reset unneeded change to main

* Copy over fresh report dialog based on old

* Hack in temp testing data

* Swap in new dialog in all cases but chat

* Cleanup

* Add load and initial error state

* Fill in states

* Add copyright link

* Handle single labeler case

* Comment out debug code

* Improve centering of type in circles

* Open details if Other is selected

* Remove debug code

* Tweak colors

* Bump SDK

* Tweak Admonition for better x-platform styles

* Add retry button

* Add close button

* Remove todo not covered in this PR

* Translate Retry

authored by

Eric Bailey and committed by
GitHub
3be9dde9 96f4f635

+1277 -37
+1
.eslintrc.js
··· 32 32 'H6', 33 33 'P', 34 34 'Admonition', 35 + 'Admonition.Admonition', 35 36 ], 36 37 impliedTextProps: [], 37 38 suggestedTextWrappers: {
+1 -1
package.json
··· 57 57 "icons:optimize": "svgo -f ./assets/icons" 58 58 }, 59 59 "dependencies": { 60 - "@atproto/api": "^0.14.0", 60 + "@atproto/api": "^0.14.7", 61 61 "@bitdrift/react-native": "^0.6.8", 62 62 "@braintree/sanitize-url": "^6.0.2", 63 63 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+18 -10
src/components/Admonition.tsx
··· 2 2 import {StyleProp, View, ViewStyle} from 'react-native' 3 3 4 4 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 5 + import {Button as BaseButton, ButtonProps} from '#/components/Button' 5 6 import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' 6 7 import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye' 7 8 import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf' ··· 49 50 return ( 50 51 <BaseText 51 52 {...rest} 52 - style={[ 53 - a.flex_1, 54 - a.text_sm, 55 - a.leading_snug, 56 - { 57 - paddingTop: 1, 58 - }, 59 - style, 60 - ]}> 53 + style={[a.flex_1, a.text_sm, a.leading_snug, a.pr_md, style]}> 61 54 {children} 62 55 </BaseText> 63 56 ) 64 57 } 65 58 59 + export function Button({ 60 + children, 61 + ...props 62 + }: Omit<ButtonProps, 'size' | 'variant' | 'color'>) { 63 + return ( 64 + <BaseButton size="tiny" variant="outline" color="secondary" {...props}> 65 + {children} 66 + </BaseButton> 67 + ) 68 + } 69 + 66 70 export function Row({children}: {children: React.ReactNode}) { 67 - return <View style={[a.flex_row, a.gap_sm]}>{children}</View> 71 + return ( 72 + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 73 + {children} 74 + </View> 75 + ) 68 76 } 69 77 70 78 export function Outer({
+99
src/components/moderation/ReportDialog/action.ts
··· 1 + import { 2 + $Typed, 3 + ChatBskyConvoDefs, 4 + ComAtprotoModerationCreateReport, 5 + } from '@atproto/api' 6 + import {msg} from '@lingui/macro' 7 + import {useLingui} from '@lingui/react' 8 + import {useMutation} from '@tanstack/react-query' 9 + 10 + import {logger} from '#/logger' 11 + import {useAgent} from '#/state/session' 12 + import {ReportState} from './state' 13 + import {ParsedReportSubject} from './types' 14 + 15 + export function useSubmitReportMutation() { 16 + const {_} = useLingui() 17 + const agent = useAgent() 18 + 19 + return useMutation({ 20 + async mutationFn({ 21 + subject, 22 + state, 23 + }: { 24 + subject: ParsedReportSubject 25 + state: ReportState 26 + }) { 27 + if (!state.selectedOption) { 28 + throw new Error(_(msg`Please select a reason for this report`)) 29 + } 30 + if (!state.selectedLabeler) { 31 + throw new Error(_(msg`Please select a moderation service`)) 32 + } 33 + 34 + let report: 35 + | ComAtprotoModerationCreateReport.InputSchema 36 + | (Omit<ComAtprotoModerationCreateReport.InputSchema, 'subject'> & { 37 + subject: $Typed<ChatBskyConvoDefs.MessageRef> 38 + }) 39 + 40 + switch (subject.type) { 41 + case 'account': { 42 + report = { 43 + reasonType: state.selectedOption.reason, 44 + reason: state.details, 45 + subject: { 46 + $type: 'com.atproto.admin.defs#repoRef', 47 + did: subject.did, 48 + }, 49 + } 50 + break 51 + } 52 + case 'post': 53 + case 'list': 54 + case 'feed': 55 + case 'starterPack': { 56 + report = { 57 + reasonType: state.selectedOption.reason, 58 + reason: state.details, 59 + subject: { 60 + $type: 'com.atproto.repo.strongRef', 61 + uri: subject.uri, 62 + cid: subject.cid, 63 + }, 64 + } 65 + break 66 + } 67 + case 'chatMessage': { 68 + report = { 69 + reasonType: state.selectedOption.reason, 70 + reason: state.details, 71 + subject: { 72 + $type: 'chat.bsky.convo.defs#messageRef', 73 + messageId: subject.message.id, 74 + convoId: subject.convoId, 75 + did: subject.message.sender.did, 76 + }, 77 + } 78 + break 79 + } 80 + } 81 + 82 + if (__DEV__) { 83 + logger.info('Submitting report', { 84 + labeler: { 85 + handle: state.selectedLabeler.creator.handle, 86 + }, 87 + report, 88 + }) 89 + } else { 90 + await agent.createModerationReport(report, { 91 + encoding: 'application/json', 92 + headers: { 93 + 'atproto-proxy': `${state.selectedLabeler.creator.did}#atproto_labeler`, 94 + }, 95 + }) 96 + } 97 + }, 98 + }) 99 + }
+1
src/components/moderation/ReportDialog/const.ts
··· 1 + export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
+49
src/components/moderation/ReportDialog/copy.ts
··· 1 + import {useMemo} from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {ParsedReportSubject} from './types' 6 + 7 + export function useCopyForSubject(subject: ParsedReportSubject) { 8 + const {_} = useLingui() 9 + return useMemo(() => { 10 + switch (subject.type) { 11 + case 'account': { 12 + return { 13 + title: _(msg`Report this user`), 14 + subtitle: _(msg`Why should this user be reviewed?`), 15 + } 16 + } 17 + case 'post': { 18 + return { 19 + title: _(msg`Report this post`), 20 + subtitle: _(msg`Why should this post be reviewed?`), 21 + } 22 + } 23 + case 'list': { 24 + return { 25 + title: _(msg`Report this list`), 26 + subtitle: _(msg`Why should this list be reviewed?`), 27 + } 28 + } 29 + case 'feed': { 30 + return { 31 + title: _(msg`Report this feed`), 32 + subtitle: _(msg`Why should this feed be reviewed?`), 33 + } 34 + } 35 + case 'starterPack': { 36 + return { 37 + title: _(msg`Report this starter pack`), 38 + subtitle: _(msg`Why should this starter pack be reviewed?`), 39 + } 40 + } 41 + case 'chatMessage': { 42 + return { 43 + title: _(msg`Report this message`), 44 + subtitle: _(msg`Why should this message be reviewed?`), 45 + } 46 + } 47 + } 48 + }, [_, subject]) 49 + }
+654
src/components/moderation/ReportDialog/index.tsx
··· 1 + import React from 'react' 2 + import {Pressable, View} from 'react-native' 3 + import {ScrollView} from 'react-native-gesture-handler' 4 + import {AppBskyLabelerDefs} from '@atproto/api' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {wait} from '#/lib/async/wait' 9 + import {getLabelingServiceTitle} from '#/lib/moderation' 10 + import {sanitizeHandle} from '#/lib/strings/handles' 11 + import {logger} from '#/logger' 12 + import {isNative} from '#/platform/detection' 13 + import {useMyLabelersQuery} from '#/state/queries/preferences' 14 + import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 15 + import {UserAvatar} from '#/view/com/util/UserAvatar' 16 + import {atoms as a, useGutters, useTheme} from '#/alf' 17 + import * as Admonition from '#/components/Admonition' 18 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 + import * as Dialog from '#/components/Dialog' 20 + import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' 21 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise' 22 + import { 23 + Check_Stroke2_Corner0_Rounded as CheckThin, 24 + CheckThick_Stroke2_Corner0_Rounded as Check, 25 + } from '#/components/icons/Check' 26 + import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' 27 + import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' 28 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 29 + import {createStaticClick, InlineLinkText, Link} from '#/components/Link' 30 + import {Loader} from '#/components/Loader' 31 + import {Text} from '#/components/Typography' 32 + import {useSubmitReportMutation} from './action' 33 + import {DMCA_LINK} from './const' 34 + import {useCopyForSubject} from './copy' 35 + import {initialState, reducer} from './state' 36 + import {ReportDialogProps, ReportSubject} from './types' 37 + import {parseReportSubject} from './utils/parseReportSubject' 38 + import {ReportOption, useReportOptions} from './utils/useReportOptions' 39 + 40 + export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 41 + 42 + export function ReportDialog( 43 + props: Omit<ReportDialogProps, 'subject'> & { 44 + subject: ReportSubject 45 + }, 46 + ) { 47 + const subject = React.useMemo( 48 + () => parseReportSubject(props.subject), 49 + [props.subject], 50 + ) 51 + return ( 52 + <Dialog.Outer control={props.control}> 53 + <Dialog.Handle /> 54 + {subject ? <Inner {...props} subject={subject} /> : <Invalid />} 55 + </Dialog.Outer> 56 + ) 57 + } 58 + 59 + /** 60 + * This should only be shown if the dialog is configured incorrectly by a 61 + * developer, but nevertheless we should have a graceful fallback. 62 + */ 63 + function Invalid() { 64 + const {_} = useLingui() 65 + return ( 66 + <Dialog.ScrollableInner label={_(msg`Report dialog`)}> 67 + <Text style={[a.font_heavy, a.text_xl, a.leading_snug, a.pb_xs]}> 68 + <Trans>Invalid report subject</Trans> 69 + </Text> 70 + <Text style={[a.text_md, a.leading_snug]}> 71 + <Trans> 72 + Something wasn't quite right with the data you're trying to report. 73 + Please contact support. 74 + </Trans> 75 + </Text> 76 + <Dialog.Close /> 77 + </Dialog.ScrollableInner> 78 + ) 79 + } 80 + 81 + function Inner(props: ReportDialogProps) { 82 + const t = useTheme() 83 + const {_} = useLingui() 84 + const ref = React.useRef<ScrollView>(null) 85 + const { 86 + data: allLabelers, 87 + isLoading: isLabelerLoading, 88 + error: labelersLoadError, 89 + refetch: refetchLabelers, 90 + } = useMyLabelersQuery({excludeNonConfigurableLabelers: true}) 91 + const isLoading = useDelayedLoading(500, isLabelerLoading) 92 + const copy = useCopyForSubject(props.subject) 93 + const reportOptions = useReportOptions() 94 + const [state, dispatch] = React.useReducer(reducer, initialState) 95 + 96 + /** 97 + * Submission handling 98 + */ 99 + const {mutateAsync: submitReport} = useSubmitReportMutation() 100 + const [isPending, setPending] = React.useState(false) 101 + const [isSuccess, setSuccess] = React.useState(false) 102 + 103 + /** 104 + * Labelers that support this `subject` and its NSID collection 105 + */ 106 + const supportedLabelers = React.useMemo(() => { 107 + if (!allLabelers) return [] 108 + return allLabelers 109 + .filter(l => { 110 + const subjectTypes: string[] | undefined = l.subjectTypes 111 + if (subjectTypes === undefined) return true 112 + if (props.subject.type === 'account') { 113 + return subjectTypes.includes('account') 114 + } else if (props.subject.type === 'chatMessage') { 115 + return subjectTypes.includes('chat') 116 + } else { 117 + return subjectTypes.includes('record') 118 + } 119 + }) 120 + .filter(l => { 121 + const collections: string[] | undefined = l.subjectCollections 122 + if (collections === undefined) return true 123 + // all chat collections accepted, since only Bluesky handles chats 124 + if (props.subject.type === 'chatMessage') return true 125 + return collections.includes(props.subject.nsid) 126 + }) 127 + .filter(l => { 128 + if (!state.selectedOption) return true 129 + const reasonTypes: string[] | undefined = l.reasonTypes 130 + if (reasonTypes === undefined) return true 131 + return reasonTypes.includes(state.selectedOption.reason) 132 + }) 133 + }, [props, allLabelers, state.selectedOption]) 134 + const hasSupportedLabelers = !!supportedLabelers.length 135 + const hasSingleSupportedLabeler = supportedLabelers.length === 1 136 + 137 + const onSubmit = React.useCallback(async () => { 138 + dispatch({type: 'clearError'}) 139 + 140 + try { 141 + setPending(true) 142 + // wait at least 1s, make it feel substantial 143 + await wait( 144 + 1e3, 145 + submitReport({ 146 + subject: props.subject, 147 + state, 148 + }), 149 + ) 150 + setSuccess(true) 151 + // give time for user feedback 152 + setTimeout(() => { 153 + props.control.close() 154 + }, 1e3) 155 + } catch (e: any) { 156 + logger.error(e, { 157 + source: 'ReportDialog', 158 + }) 159 + dispatch({ 160 + type: 'setError', 161 + error: _(msg`Something went wrong. Please try again.`), 162 + }) 163 + } finally { 164 + setPending(false) 165 + } 166 + }, [_, submitReport, state, dispatch, props, setPending, setSuccess]) 167 + 168 + return ( 169 + <Dialog.ScrollableInner 170 + label={_(msg`Report dialog`)} 171 + ref={ref} 172 + style={[a.w_full, {maxWidth: 500}]}> 173 + <View style={[a.gap_2xl, isNative && a.pt_md]}> 174 + <StepOuter> 175 + <StepTitle 176 + index={1} 177 + title={copy.subtitle} 178 + activeIndex1={state.activeStepIndex1} 179 + /> 180 + {isLoading ? ( 181 + <View style={[a.gap_sm]}> 182 + <OptionCardSkeleton /> 183 + <OptionCardSkeleton /> 184 + <OptionCardSkeleton /> 185 + <OptionCardSkeleton /> 186 + <OptionCardSkeleton /> 187 + {/* Here to capture focus for a hot sec to prevent flash */} 188 + <Pressable accessible={false} /> 189 + </View> 190 + ) : labelersLoadError || !allLabelers ? ( 191 + <Admonition.Outer type="error"> 192 + <Admonition.Row> 193 + <Admonition.Icon /> 194 + <Admonition.Text> 195 + <Trans>Something went wrong, please try again</Trans> 196 + </Admonition.Text> 197 + <Admonition.Button 198 + label={_(msg`Retry loading report options`)} 199 + onPress={() => refetchLabelers()}> 200 + <ButtonText> 201 + <Trans>Retry</Trans> 202 + </ButtonText> 203 + <ButtonIcon icon={Retry} /> 204 + </Admonition.Button> 205 + </Admonition.Row> 206 + </Admonition.Outer> 207 + ) : ( 208 + <> 209 + {state.selectedOption ? ( 210 + <View style={[a.flex_row, a.align_center, a.gap_md]}> 211 + <View style={[a.flex_1]}> 212 + <OptionCard option={state.selectedOption} /> 213 + </View> 214 + <Button 215 + label={_(msg`Change report reason`)} 216 + size="tiny" 217 + variant="solid" 218 + color="secondary" 219 + shape="round" 220 + onPress={() => { 221 + dispatch({type: 'clearOption'}) 222 + }}> 223 + <ButtonIcon icon={X} /> 224 + </Button> 225 + </View> 226 + ) : ( 227 + <View style={[a.gap_sm]}> 228 + {reportOptions[props.subject.type].map(o => ( 229 + <OptionCard 230 + key={o.reason} 231 + option={o} 232 + onSelect={() => { 233 + dispatch({type: 'selectOption', option: o}) 234 + }} 235 + /> 236 + ))} 237 + 238 + {['post', 'account'].includes(props.subject.type) && ( 239 + <Link 240 + to={DMCA_LINK} 241 + label={_( 242 + msg`View details for reporting a copyright violation`, 243 + )}> 244 + {({hovered, pressed}) => ( 245 + <View 246 + style={[ 247 + a.flex_row, 248 + a.align_center, 249 + a.w_full, 250 + a.px_md, 251 + a.py_sm, 252 + a.rounded_sm, 253 + a.border, 254 + hovered || pressed 255 + ? [t.atoms.border_contrast_high] 256 + : [t.atoms.border_contrast_low], 257 + ]}> 258 + <Text style={[a.flex_1, a.italic, a.leading_snug]}> 259 + <Trans>Need to report a copyright violation?</Trans> 260 + </Text> 261 + <SquareArrowTopRight 262 + size="sm" 263 + fill={t.atoms.text.color} 264 + /> 265 + </View> 266 + )} 267 + </Link> 268 + )} 269 + </View> 270 + )} 271 + </> 272 + )} 273 + </StepOuter> 274 + 275 + <StepOuter> 276 + <StepTitle 277 + index={2} 278 + title={_(msg`Select moderation service`)} 279 + activeIndex1={state.activeStepIndex1} 280 + /> 281 + {state.activeStepIndex1 >= 2 && ( 282 + <> 283 + {state.selectedLabeler ? ( 284 + <> 285 + {hasSingleSupportedLabeler ? ( 286 + <LabelerCard labeler={state.selectedLabeler} /> 287 + ) : ( 288 + <View style={[a.flex_row, a.align_center, a.gap_md]}> 289 + <View style={[a.flex_1]}> 290 + <LabelerCard labeler={state.selectedLabeler} /> 291 + </View> 292 + <Button 293 + label={_(msg`Change moderation service`)} 294 + size="tiny" 295 + variant="solid" 296 + color="secondary" 297 + shape="round" 298 + onPress={() => { 299 + dispatch({type: 'clearLabeler'}) 300 + }}> 301 + <ButtonIcon icon={X} /> 302 + </Button> 303 + </View> 304 + )} 305 + </> 306 + ) : ( 307 + <> 308 + {hasSupportedLabelers ? ( 309 + <View style={[a.gap_sm]}> 310 + {hasSingleSupportedLabeler ? ( 311 + <> 312 + <LabelerCard labeler={supportedLabelers[0]} /> 313 + <ActionOnce 314 + check={() => !state.selectedLabeler} 315 + callback={() => { 316 + dispatch({ 317 + type: 'selectLabeler', 318 + labeler: supportedLabelers[0], 319 + }) 320 + }} 321 + /> 322 + </> 323 + ) : ( 324 + <> 325 + {supportedLabelers.map(l => ( 326 + <LabelerCard 327 + key={l.creator.did} 328 + labeler={l} 329 + onSelect={() => { 330 + dispatch({type: 'selectLabeler', labeler: l}) 331 + }} 332 + /> 333 + ))} 334 + </> 335 + )} 336 + </View> 337 + ) : ( 338 + // should never happen in our app 339 + <Admonition.Admonition type="warning"> 340 + <Trans> 341 + Unfortunately, none of your subscribed labelers supports 342 + this report type. 343 + </Trans> 344 + </Admonition.Admonition> 345 + )} 346 + </> 347 + )} 348 + </> 349 + )} 350 + </StepOuter> 351 + 352 + <StepOuter> 353 + <StepTitle 354 + index={3} 355 + title={_(msg`Submit report`)} 356 + activeIndex1={state.activeStepIndex1} 357 + /> 358 + {state.activeStepIndex1 === 3 && ( 359 + <> 360 + <View style={[a.pb_xs, a.gap_xs]}> 361 + <Text style={[a.leading_snug, a.pb_xs]}> 362 + <Trans> 363 + Your report will be sent to{' '} 364 + <Text style={[a.font_bold, a.leading_snug]}> 365 + {state.selectedLabeler?.creator.displayName} 366 + </Text> 367 + . 368 + </Trans>{' '} 369 + {!state.detailsOpen ? ( 370 + <InlineLinkText 371 + label={_(msg`Add more details (optional)`)} 372 + {...createStaticClick(() => { 373 + dispatch({type: 'showDetails'}) 374 + })}> 375 + <Trans>Add more details (optional)</Trans> 376 + </InlineLinkText> 377 + ) : null} 378 + </Text> 379 + 380 + {state.detailsOpen && ( 381 + <View> 382 + <Dialog.Input 383 + multiline 384 + value={state.details} 385 + onChangeText={details => { 386 + dispatch({type: 'setDetails', details}) 387 + }} 388 + label={_(msg`Additional details (limit 300 characters)`)} 389 + style={{paddingRight: 60}} 390 + numberOfLines={4} 391 + /> 392 + <View 393 + style={[ 394 + a.absolute, 395 + a.flex_row, 396 + a.align_center, 397 + a.pr_md, 398 + a.pb_sm, 399 + { 400 + bottom: 0, 401 + right: 0, 402 + }, 403 + ]}> 404 + <CharProgress count={state.details?.length || 0} /> 405 + </View> 406 + </View> 407 + )} 408 + </View> 409 + <Button 410 + label={_(msg`Submit report`)} 411 + size="large" 412 + variant="solid" 413 + color="primary" 414 + disabled={isPending || isSuccess} 415 + onPress={onSubmit}> 416 + <ButtonText> 417 + <Trans>Submit report</Trans> 418 + </ButtonText> 419 + <ButtonIcon 420 + icon={isSuccess ? CheckThin : isPending ? Loader : PaperPlane} 421 + /> 422 + </Button> 423 + 424 + {state.error && ( 425 + <Admonition.Admonition type="error"> 426 + {state.error} 427 + </Admonition.Admonition> 428 + )} 429 + </> 430 + )} 431 + </StepOuter> 432 + </View> 433 + 434 + <Dialog.Close /> 435 + </Dialog.ScrollableInner> 436 + ) 437 + } 438 + 439 + function ActionOnce({ 440 + check, 441 + callback, 442 + }: { 443 + check: () => boolean 444 + callback: () => void 445 + }) { 446 + React.useEffect(() => { 447 + if (check()) { 448 + callback() 449 + } 450 + }, [check, callback]) 451 + return null 452 + } 453 + 454 + function StepOuter({children}: {children: React.ReactNode}) { 455 + return <View style={[a.gap_md, a.w_full]}>{children}</View> 456 + } 457 + 458 + function StepTitle({ 459 + index, 460 + title, 461 + activeIndex1, 462 + }: { 463 + index: number 464 + title: string 465 + activeIndex1: number 466 + }) { 467 + const t = useTheme() 468 + const active = activeIndex1 === index 469 + const completed = activeIndex1 > index 470 + return ( 471 + <View style={[a.flex_row, a.gap_sm, a.pr_3xl]}> 472 + <View 473 + style={[ 474 + a.justify_center, 475 + a.align_center, 476 + a.rounded_full, 477 + a.border, 478 + { 479 + width: 24, 480 + height: 24, 481 + backgroundColor: active 482 + ? t.palette.primary_500 483 + : completed 484 + ? t.palette.primary_100 485 + : t.atoms.bg_contrast_25.backgroundColor, 486 + borderColor: active 487 + ? t.palette.primary_500 488 + : completed 489 + ? t.palette.primary_400 490 + : t.atoms.border_contrast_low.borderColor, 491 + }, 492 + ]}> 493 + {completed ? ( 494 + <Check width={12} /> 495 + ) : ( 496 + <Text 497 + style={[ 498 + a.font_heavy, 499 + a.text_center, 500 + t.atoms.text, 501 + { 502 + color: active 503 + ? 'white' 504 + : completed 505 + ? t.palette.primary_700 506 + : t.atoms.text_contrast_medium.color, 507 + fontVariant: ['tabular-nums'], 508 + width: 24, 509 + height: 24, 510 + lineHeight: 24, 511 + }, 512 + ]}> 513 + {index} 514 + </Text> 515 + )} 516 + </View> 517 + 518 + <Text 519 + style={[ 520 + a.flex_1, 521 + a.font_heavy, 522 + a.text_lg, 523 + a.leading_snug, 524 + active ? t.atoms.text : t.atoms.text_contrast_medium, 525 + { 526 + top: 1, 527 + }, 528 + ]}> 529 + {title} 530 + </Text> 531 + </View> 532 + ) 533 + } 534 + 535 + function OptionCard({ 536 + option, 537 + onSelect, 538 + }: { 539 + option: ReportOption 540 + onSelect?: (option: ReportOption) => void 541 + }) { 542 + const t = useTheme() 543 + const {_} = useLingui() 544 + const gutters = useGutters(['compact']) 545 + const onPress = React.useCallback(() => { 546 + onSelect?.(option) 547 + }, [onSelect, option]) 548 + return ( 549 + <Button 550 + label={_(msg`Create report for ${option.title}`)} 551 + onPress={onPress} 552 + disabled={!onSelect}> 553 + {({hovered, pressed}) => ( 554 + <View 555 + style={[ 556 + a.w_full, 557 + gutters, 558 + a.py_sm, 559 + a.rounded_sm, 560 + a.border, 561 + t.atoms.bg_contrast_25, 562 + hovered || pressed 563 + ? [t.atoms.border_contrast_high] 564 + : [t.atoms.border_contrast_low], 565 + ]}> 566 + <Text style={[a.text_md, a.font_bold, a.leading_snug]}> 567 + {option.title} 568 + </Text> 569 + <Text 570 + style={[a.text_sm, , a.leading_snug, t.atoms.text_contrast_medium]}> 571 + {option.description} 572 + </Text> 573 + </View> 574 + )} 575 + </Button> 576 + ) 577 + } 578 + 579 + function OptionCardSkeleton() { 580 + const t = useTheme() 581 + return ( 582 + <View 583 + style={[ 584 + a.w_full, 585 + a.rounded_sm, 586 + a.border, 587 + t.atoms.bg_contrast_25, 588 + t.atoms.border_contrast_low, 589 + {height: 55}, // magic, based on web 590 + ]} 591 + /> 592 + ) 593 + } 594 + 595 + function LabelerCard({ 596 + labeler, 597 + onSelect, 598 + }: { 599 + labeler: AppBskyLabelerDefs.LabelerViewDetailed 600 + onSelect?: (option: AppBskyLabelerDefs.LabelerViewDetailed) => void 601 + }) { 602 + const t = useTheme() 603 + const {_} = useLingui() 604 + const onPress = React.useCallback(() => { 605 + onSelect?.(labeler) 606 + }, [onSelect, labeler]) 607 + const title = getLabelingServiceTitle({ 608 + displayName: labeler.creator.displayName, 609 + handle: labeler.creator.handle, 610 + }) 611 + return ( 612 + <Button 613 + label={_(msg`Send report to ${title}`)} 614 + onPress={onPress} 615 + disabled={!onSelect}> 616 + {({hovered, pressed}) => ( 617 + <View 618 + style={[ 619 + a.w_full, 620 + a.p_sm, 621 + a.flex_row, 622 + a.align_center, 623 + a.gap_sm, 624 + a.rounded_md, 625 + a.border, 626 + t.atoms.bg_contrast_25, 627 + hovered || pressed 628 + ? [t.atoms.border_contrast_high] 629 + : [t.atoms.border_contrast_low], 630 + ]}> 631 + <UserAvatar 632 + type="labeler" 633 + size={36} 634 + avatar={labeler.creator.avatar} 635 + /> 636 + <View style={[a.flex_1]}> 637 + <Text style={[a.text_md, a.font_bold, a.leading_snug]}> 638 + {title} 639 + </Text> 640 + <Text 641 + style={[ 642 + a.text_sm, 643 + , 644 + a.leading_snug, 645 + t.atoms.text_contrast_medium, 646 + ]}> 647 + <Trans>By {sanitizeHandle(labeler.creator.handle, '@')}</Trans> 648 + </Text> 649 + </View> 650 + </View> 651 + )} 652 + </Button> 653 + ) 654 + }
+109
src/components/moderation/ReportDialog/state.ts
··· 1 + import {AppBskyLabelerDefs, ComAtprotoModerationDefs} from '@atproto/api' 2 + 3 + import {ReportOption} from './utils/useReportOptions' 4 + 5 + export type ReportState = { 6 + selectedOption?: ReportOption 7 + selectedLabeler?: AppBskyLabelerDefs.LabelerViewDetailed 8 + details?: string 9 + detailsOpen: boolean 10 + activeStepIndex1: number 11 + error?: string 12 + } 13 + 14 + export type ReportAction = 15 + | { 16 + type: 'selectOption' 17 + option: ReportOption 18 + } 19 + | { 20 + type: 'clearOption' 21 + } 22 + | { 23 + type: 'selectLabeler' 24 + labeler: AppBskyLabelerDefs.LabelerViewDetailed 25 + } 26 + | { 27 + type: 'clearLabeler' 28 + } 29 + | { 30 + type: 'setDetails' 31 + details: string 32 + } 33 + | { 34 + type: 'setError' 35 + error: string 36 + } 37 + | { 38 + type: 'clearError' 39 + } 40 + | { 41 + type: 'showDetails' 42 + } 43 + 44 + export const initialState: ReportState = { 45 + selectedOption: undefined, 46 + selectedLabeler: undefined, 47 + details: undefined, 48 + detailsOpen: false, 49 + activeStepIndex1: 1, 50 + } 51 + 52 + export function reducer(state: ReportState, action: ReportAction): ReportState { 53 + switch (action.type) { 54 + case 'selectOption': 55 + return { 56 + ...state, 57 + selectedOption: action.option, 58 + activeStepIndex1: 2, 59 + detailsOpen: 60 + !!state.details || 61 + action.option.reason === ComAtprotoModerationDefs.REASONOTHER, 62 + } 63 + case 'clearOption': 64 + return { 65 + ...state, 66 + selectedOption: undefined, 67 + selectedLabeler: undefined, 68 + activeStepIndex1: 1, 69 + detailsOpen: 70 + !!state.details || 71 + state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER, 72 + } 73 + case 'selectLabeler': 74 + return { 75 + ...state, 76 + selectedLabeler: action.labeler, 77 + activeStepIndex1: 3, 78 + } 79 + case 'clearLabeler': 80 + return { 81 + ...state, 82 + selectedLabeler: undefined, 83 + activeStepIndex1: 2, 84 + detailsOpen: 85 + !!state.details || 86 + state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER, 87 + } 88 + case 'setDetails': 89 + return { 90 + ...state, 91 + details: action.details, 92 + } 93 + case 'setError': 94 + return { 95 + ...state, 96 + error: action.error, 97 + } 98 + case 'clearError': 99 + return { 100 + ...state, 101 + error: undefined, 102 + } 103 + case 'showDetails': 104 + return { 105 + ...state, 106 + detailsOpen: true, 107 + } 108 + } 109 + }
+67
src/components/moderation/ReportDialog/types.ts
··· 1 + import { 2 + $Typed, 3 + AppBskyActorDefs, 4 + AppBskyFeedDefs, 5 + AppBskyGraphDefs, 6 + ChatBskyConvoDefs, 7 + } from '@atproto/api' 8 + 9 + import * as Dialog from '#/components/Dialog' 10 + 11 + export type ReportSubject = 12 + | $Typed<AppBskyActorDefs.ProfileViewBasic> 13 + | $Typed<AppBskyActorDefs.ProfileView> 14 + | $Typed<AppBskyActorDefs.ProfileViewDetailed> 15 + | $Typed<AppBskyGraphDefs.ListView> 16 + | $Typed<AppBskyFeedDefs.GeneratorView> 17 + | $Typed<AppBskyGraphDefs.StarterPackView> 18 + | $Typed<AppBskyFeedDefs.PostView> 19 + | {convoId: string; message: ChatBskyConvoDefs.MessageView} 20 + 21 + export type ParsedReportSubject = 22 + | { 23 + type: 'post' 24 + uri: string 25 + cid: string 26 + nsid: string 27 + attributes: { 28 + reply: boolean 29 + image: boolean 30 + video: boolean 31 + link: boolean 32 + quote: boolean 33 + } 34 + } 35 + | { 36 + type: 'list' 37 + uri: string 38 + cid: string 39 + nsid: string 40 + } 41 + | { 42 + type: 'feed' 43 + uri: string 44 + cid: string 45 + nsid: string 46 + } 47 + | { 48 + type: 'starterPack' 49 + uri: string 50 + cid: string 51 + nsid: string 52 + } 53 + | { 54 + type: 'account' 55 + did: string 56 + nsid: string 57 + } 58 + | { 59 + type: 'chatMessage' 60 + convoId: string 61 + message: ChatBskyConvoDefs.MessageView 62 + } 63 + 64 + export type ReportDialogProps = { 65 + control: Dialog.DialogOuterProps['control'] 66 + subject: ParsedReportSubject 67 + }
+91
src/components/moderation/ReportDialog/utils/parseReportSubject.ts
··· 1 + import { 2 + AppBskyActorDefs, 3 + AppBskyFeedDefs, 4 + AppBskyFeedPost, 5 + AppBskyGraphDefs, 6 + } from '@atproto/api' 7 + 8 + import { 9 + ParsedReportSubject, 10 + ReportSubject, 11 + } from '#/components/moderation/ReportDialog/types' 12 + import * as bsky from '#/types/bsky' 13 + 14 + export function parseReportSubject( 15 + subject: ReportSubject, 16 + ): ParsedReportSubject | undefined { 17 + if (!subject) return 18 + 19 + if ('convoId' in subject) { 20 + return { 21 + type: 'chatMessage', 22 + ...subject, 23 + } 24 + } 25 + 26 + if ( 27 + AppBskyActorDefs.isProfileViewBasic(subject) || 28 + AppBskyActorDefs.isProfileView(subject) || 29 + AppBskyActorDefs.isProfileViewDetailed(subject) 30 + ) { 31 + return { 32 + type: 'account', 33 + did: subject.did, 34 + nsid: 'app.bsky.actor.profile', 35 + } 36 + } else if (AppBskyGraphDefs.isListView(subject)) { 37 + return { 38 + type: 'list', 39 + uri: subject.uri, 40 + cid: subject.cid, 41 + nsid: 'app.bsky.graph.list', 42 + } 43 + } else if (AppBskyFeedDefs.isGeneratorView(subject)) { 44 + return { 45 + type: 'feed', 46 + uri: subject.uri, 47 + cid: subject.cid, 48 + nsid: 'app.bsky.feed.generator', 49 + } 50 + } else if (AppBskyGraphDefs.isStarterPackView(subject)) { 51 + return { 52 + type: 'starterPack', 53 + uri: subject.uri, 54 + cid: subject.cid, 55 + nsid: 'app.bsky.graph.starterPack', 56 + } 57 + } else if (AppBskyFeedDefs.isPostView(subject)) { 58 + const record = subject.record 59 + const embed = bsky.post.parseEmbed(subject.embed) 60 + if ( 61 + bsky.dangerousIsType<AppBskyFeedPost.Record>( 62 + record, 63 + AppBskyFeedPost.isRecord, 64 + ) 65 + ) { 66 + return { 67 + type: 'post', 68 + uri: subject.uri, 69 + cid: subject.cid, 70 + nsid: 'app.bsky.feed.post', 71 + attributes: { 72 + reply: !!record.reply, 73 + image: 74 + embed.type === 'images' || 75 + (embed.type === 'post_with_media' && embed.media.type === 'images'), 76 + video: 77 + embed.type === 'video' || 78 + (embed.type === 'post_with_media' && embed.media.type === 'video'), 79 + link: 80 + embed.type === 'link' || 81 + (embed.type === 'post_with_media' && embed.media.type === 'link'), 82 + quote: 83 + embed.type === 'post' || 84 + (embed.type === 'post_with_media' && 85 + (embed.view.type === 'post' || 86 + embed.view.type === 'post_with_media')), 87 + }, 88 + } 89 + } 90 + } 91 + }
+121
src/components/moderation/ReportDialog/utils/useReportOptions.ts
··· 1 + import {useMemo} from 'react' 2 + import {ComAtprotoModerationDefs} from '@atproto/api' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + export interface ReportOption { 7 + reason: string 8 + title: string 9 + description: string 10 + } 11 + 12 + interface ReportOptions { 13 + account: ReportOption[] 14 + post: ReportOption[] 15 + list: ReportOption[] 16 + starterPack: ReportOption[] 17 + feed: ReportOption[] 18 + chatMessage: ReportOption[] 19 + } 20 + 21 + export function useReportOptions(): ReportOptions { 22 + const {_} = useLingui() 23 + 24 + return useMemo(() => { 25 + const other = { 26 + reason: ComAtprotoModerationDefs.REASONOTHER, 27 + title: _(msg`Other`), 28 + description: _(msg`An issue not included in these options`), 29 + } 30 + const common = [ 31 + { 32 + reason: ComAtprotoModerationDefs.REASONRUDE, 33 + title: _(msg`Anti-Social Behavior`), 34 + description: _(msg`Harassment, trolling, or intolerance`), 35 + }, 36 + { 37 + reason: ComAtprotoModerationDefs.REASONVIOLATION, 38 + title: _(msg`Illegal and Urgent`), 39 + description: _(msg`Glaring violations of law or terms of service`), 40 + }, 41 + other, 42 + ] 43 + return { 44 + account: [ 45 + { 46 + reason: ComAtprotoModerationDefs.REASONMISLEADING, 47 + title: _(msg`Misleading Account`), 48 + description: _( 49 + msg`Impersonation or false claims about identity or affiliation`, 50 + ), 51 + }, 52 + { 53 + reason: ComAtprotoModerationDefs.REASONSPAM, 54 + title: _(msg`Frequently Posts Unwanted Content`), 55 + description: _(msg`Spam; excessive mentions or replies`), 56 + }, 57 + { 58 + reason: ComAtprotoModerationDefs.REASONVIOLATION, 59 + title: _(msg`Name or Description Violates Community Standards`), 60 + description: _(msg`Terms used violate community standards`), 61 + }, 62 + other, 63 + ], 64 + post: [ 65 + { 66 + reason: ComAtprotoModerationDefs.REASONMISLEADING, 67 + title: _(msg`Misleading Post`), 68 + description: _(msg`Impersonation, misinformation, or false claims`), 69 + }, 70 + { 71 + reason: ComAtprotoModerationDefs.REASONSPAM, 72 + title: _(msg`Spam`), 73 + description: _(msg`Excessive mentions or replies`), 74 + }, 75 + { 76 + reason: ComAtprotoModerationDefs.REASONSEXUAL, 77 + title: _(msg`Unwanted Sexual Content`), 78 + description: _(msg`Nudity or adult content not labeled as such`), 79 + }, 80 + ...common, 81 + ], 82 + chatMessage: [ 83 + { 84 + reason: ComAtprotoModerationDefs.REASONSPAM, 85 + title: _(msg`Spam`), 86 + description: _(msg`Excessive or unwanted messages`), 87 + }, 88 + { 89 + reason: ComAtprotoModerationDefs.REASONSEXUAL, 90 + title: _(msg`Unwanted Sexual Content`), 91 + description: _(msg`Inappropriate messages or explicit links`), 92 + }, 93 + ...common, 94 + ], 95 + list: [ 96 + { 97 + reason: ComAtprotoModerationDefs.REASONVIOLATION, 98 + title: _(msg`Name or Description Violates Community Standards`), 99 + description: _(msg`Terms used violate community standards`), 100 + }, 101 + ...common, 102 + ], 103 + starterPack: [ 104 + { 105 + reason: ComAtprotoModerationDefs.REASONVIOLATION, 106 + title: _(msg`Name or Description Violates Community Standards`), 107 + description: _(msg`Terms used violate community standards`), 108 + }, 109 + ...common, 110 + ], 111 + feed: [ 112 + { 113 + reason: ComAtprotoModerationDefs.REASONVIOLATION, 114 + title: _(msg`Name or Description Violates Community Standards`), 115 + description: _(msg`Terms used violate community standards`), 116 + }, 117 + ...common, 118 + ], 119 + } 120 + }, [_]) 121 + }
+5
src/lib/async/wait.ts
··· 1 + export async function wait<T>(delay: number, fn: T): Promise<Awaited<T>> { 2 + return await Promise.all([fn, new Promise(y => setTimeout(y, delay))]).then( 3 + arr => arr[0], 4 + ) 5 + }
+13 -9
src/screens/Profile/components/ProfileFeedHeader.tsx
··· 46 46 import * as Layout from '#/components/Layout' 47 47 import {InlineLinkText} from '#/components/Link' 48 48 import * as Menu from '#/components/Menu' 49 - import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 49 + import { 50 + ReportDialog, 51 + useReportDialogControl, 52 + } from '#/components/moderation/ReportDialog' 50 53 import {RichText} from '#/components/RichText' 51 54 import {Text} from '#/components/Typography' 52 55 ··· 551 554 </Button> 552 555 </View> 553 556 554 - <ReportDialog 555 - control={reportDialogControl} 556 - params={{ 557 - type: 'feedgen', 558 - uri: info.uri, 559 - cid: info.cid, 560 - }} 561 - /> 557 + {info.view && ( 558 + <ReportDialog 559 + control={reportDialogControl} 560 + subject={{ 561 + ...info.view, 562 + $type: 'app.bsky.feed.defs#generatorView', 563 + }} 564 + /> 565 + )} 562 566 </View> 563 567 </> 564 568 )}
+7 -5
src/screens/StarterPack/StarterPackScreen.tsx
··· 55 55 import {ListMaybePlaceholder} from '#/components/Lists' 56 56 import {Loader} from '#/components/Loader' 57 57 import * as Menu from '#/components/Menu' 58 + import { 59 + ReportDialog, 60 + useReportDialogControl, 61 + } from '#/components/moderation/ReportDialog' 58 62 import * as Prompt from '#/components/Prompt' 59 - import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 60 63 import {RichText} from '#/components/RichText' 61 64 import {FeedsList} from '#/components/StarterPack/Main/FeedsList' 62 65 import {PostsList} from '#/components/StarterPack/Main/PostsList' ··· 620 623 {starterPack.list && ( 621 624 <ReportDialog 622 625 control={reportDialogControl} 623 - params={{ 624 - type: 'starterpack', 625 - uri: starterPack.uri, 626 - cid: starterPack.cid, 626 + subject={{ 627 + ...starterPack, 628 + $type: 'app.bsky.graph.defs#starterPackView', 627 629 }} 628 630 /> 629 631 )}
+4
src/state/queries/feed.ts
··· 33 33 34 34 export type FeedSourceFeedInfo = { 35 35 type: 'feed' 36 + view?: AppBskyFeedDefs.GeneratorView 36 37 uri: string 37 38 feedDescriptor: FeedDescriptor 38 39 route: { ··· 53 54 54 55 export type FeedSourceListInfo = { 55 56 type: 'list' 57 + view?: AppBskyGraphDefs.ListView 56 58 uri: string 57 59 feedDescriptor: FeedDescriptor 58 60 route: { ··· 93 95 94 96 return { 95 97 type: 'feed', 98 + view, 96 99 uri: view.uri, 97 100 feedDescriptor: `feedgen|${view.uri}`, 98 101 cid: view.cid, ··· 126 129 127 130 return { 128 131 type: 'list', 132 + view, 129 133 uri: view.uri, 130 134 feedDescriptor: `list|${view.uri}`, 131 135 route: {
+1
src/state/queries/preferences/moderation.ts
··· 41 41 isLoading, 42 42 error, 43 43 data: labelers.data, 44 + refetch: labelers.refetch, 44 45 } 45 46 }, [labelers, isLoading, error]) 46 47 }
+8 -2
src/view/com/profile/ProfileMenu.tsx
··· 38 38 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 39 39 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 40 40 import * as Menu from '#/components/Menu' 41 + import { 42 + ReportDialog, 43 + useReportDialogControl, 44 + } from '#/components/moderation/ReportDialog' 41 45 import * as Prompt from '#/components/Prompt' 42 - import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 43 46 44 47 let ProfileMenu = ({ 45 48 profile, ··· 365 368 366 369 <ReportDialog 367 370 control={reportDialogControl} 368 - params={{type: 'account', did: profile.did}} 371 + subject={{ 372 + ...profile, 373 + $type: 'app.bsky.actor.defs#profileViewDetailed', 374 + }} 369 375 /> 370 376 371 377 <Prompt.Basic
+7 -5
src/view/com/util/forms/PostDropdownBtnMenuItems.tsx
··· 80 80 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 81 81 import {Loader} from '#/components/Loader' 82 82 import * as Menu from '#/components/Menu' 83 + import { 84 + ReportDialog, 85 + useReportDialogControl, 86 + } from '#/components/moderation/ReportDialog' 83 87 import * as Prompt from '#/components/Prompt' 84 - import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 85 88 import * as Toast from '../Toast' 86 89 87 90 let PostDropdownMenuItems = ({ ··· 756 759 757 760 <ReportDialog 758 761 control={reportDialogControl} 759 - params={{ 760 - type: 'post', 761 - uri: postUri, 762 - cid: postCid, 762 + subject={{ 763 + ...post, 764 + $type: 'app.bsky.feed.defs#postView', 763 765 }} 764 766 /> 765 767
+7 -5
src/view/screens/ProfileList.tsx
··· 75 75 import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 76 76 import * as Layout from '#/components/Layout' 77 77 import * as Hider from '#/components/moderation/Hider' 78 + import { 79 + ReportDialog, 80 + useReportDialogControl, 81 + } from '#/components/moderation/ReportDialog' 78 82 import * as Prompt from '#/components/Prompt' 79 - import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 80 83 import {RichText} from '#/components/RichText' 81 84 82 85 const SECTION_TITLES_CURATE = ['Posts', 'People'] ··· 672 675 avatarType="list"> 673 676 <ReportDialog 674 677 control={reportDialogControl} 675 - params={{ 676 - type: 'list', 677 - uri: list.uri, 678 - cid: list.cid, 678 + subject={{ 679 + ...list, 680 + $type: 'app.bsky.graph.defs#listView', 679 681 }} 680 682 /> 681 683 {isCurateList ? (
+14
yarn.lock
··· 80 80 tlds "^1.234.0" 81 81 zod "^3.23.8" 82 82 83 + "@atproto/api@^0.14.7": 84 + version "0.14.7" 85 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.7.tgz#3ffa02d6b3baf9e265dab170367ffade08023567" 86 + integrity sha512-YG2kvAtsgtajLlLrorYuHcxGgepG0c/RUB2/iJyBnwKjGqDLG8joOETf38JSNiGzs6NJbNKa9NHG6BQKourxBA== 87 + dependencies: 88 + "@atproto/common-web" "^0.4.0" 89 + "@atproto/lexicon" "^0.4.7" 90 + "@atproto/syntax" "^0.3.3" 91 + "@atproto/xrpc" "^0.6.9" 92 + await-lock "^2.2.2" 93 + multiformats "^9.9.0" 94 + tlds "^1.234.0" 95 + zod "^3.23.8" 96 + 83 97 "@atproto/aws@^0.2.15": 84 98 version "0.2.15" 85 99 resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.15.tgz#edc534a420b4da37e2f049d471bf40df93447a25"