Bluesky app fork with some witchin' additions 💫

Merge remote-tracking branch 'upstream/main'

+1 -1
bskyweb/cmd/bskyweb/server.go
··· 223 e.GET("/robots.txt", echo.WrapHandler(staticHandler)) 224 } 225 226 - e.GET("/iframe/youtube.html", echo.WrapHandler(staticHandler)) 227 e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc { 228 return func(c echo.Context) error { 229 c.Response().Before(func() {
··· 223 e.GET("/robots.txt", echo.WrapHandler(staticHandler)) 224 } 225 226 + e.GET("/iframe/*", echo.WrapHandler(staticHandler)) 227 e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc { 228 return func(c echo.Context) error { 229 c.Response().Before(func() {
+1 -37
bskyweb/static/iframe/youtube.html
··· 16 } 17 </style> 18 <div class="container"><div class="video" id="player"></div></div> 19 - <script> 20 - const url = new URL(window.location) 21 - const viewport = document.querySelector("meta[name=viewport]") 22 - 23 - const tag = document.createElement("script") 24 - tag.src = "https://www.youtube.com/iframe_api" 25 - const firstScriptTag = document.getElementsByTagName('script')[0]; 26 - firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); 27 - 28 - let player 29 - function onYouTubeIframeAPIReady() { 30 - let videoId = url.searchParams.get('videoId') 31 - videoId = decodeURIComponent(videoId) 32 - videoId = videoId.replace(/[^a-zA-Z0-9_-]/g, "") 33 - if (videoId.length !== 11) throw new Error("Invalid video ID") 34 - 35 - let start = url.searchParams.get('start') 36 - start = start.replace(/[^0-9]/g, "") 37 - 38 - player = new YT.Player('player', { 39 - width: "1000", 40 - height: "1000", 41 - videoId, 42 - playerVars: { 43 - autoplay: 1, 44 - start, 45 - rel: 0, 46 - loop: 0, 47 - playsinline: 1, 48 - origin: url.origin 49 - }, 50 - }); 51 - } 52 - function onPlayerReady(event) { 53 - event.target.playVideo(); 54 - } 55 - </script>
··· 16 } 17 </style> 18 <div class="container"><div class="video" id="player"></div></div> 19 + <script src="youtube.js"></script>
+35
bskyweb/static/iframe/youtube.js
···
··· 1 + const url = new URL(window.location) 2 + const viewport = document.querySelector("meta[name=viewport]") 3 + 4 + const tag = document.createElement("script") 5 + tag.src = "https://www.youtube.com/iframe_api" 6 + const firstScriptTag = document.getElementsByTagName('script')[0]; 7 + firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); 8 + 9 + let player 10 + function onYouTubeIframeAPIReady() { 11 + let videoId = url.searchParams.get('videoId') 12 + videoId = decodeURIComponent(videoId) 13 + videoId = videoId.replace(/[^a-zA-Z0-9_-]/g, "") 14 + if (videoId.length !== 11) throw new Error("Invalid video ID") 15 + 16 + let start = url.searchParams.get('start') 17 + start = start.replace(/[^0-9]/g, "") 18 + 19 + player = new YT.Player('player', { 20 + width: "1000", 21 + height: "1000", 22 + videoId, 23 + playerVars: { 24 + autoplay: 1, 25 + start, 26 + rel: 0, 27 + loop: 0, 28 + playsinline: 1, 29 + origin: url.origin 30 + }, 31 + }); 32 + } 33 + function onPlayerReady(event) { 34 + event.target.playVideo(); 35 + }
+1 -1
package.json
··· 72 "icons:optimize": "svgo -f ./assets/icons" 73 }, 74 "dependencies": { 75 - "@atproto/api": "^0.17.6", 76 "@bitdrift/react-native": "^0.6.8", 77 "@braintree/sanitize-url": "^6.0.2", 78 "@bsky.app/alf": "^0.1.5",
··· 72 "icons:optimize": "svgo -f ./assets/icons" 73 }, 74 "dependencies": { 75 + "@atproto/api": "^0.18.0", 76 "@bitdrift/react-native": "^0.6.8", 77 "@braintree/sanitize-url": "^6.0.2", 78 "@bsky.app/alf": "^0.1.5",
+21 -2
src/components/Dialog/context.ts
··· 13 type DialogControlRefProps, 14 type DialogOuterProps, 15 } from '#/components/Dialog/types' 16 import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types' 17 18 export const Context = createContext<DialogContextProps>({ ··· 50 id, 51 ref: control, 52 open: () => { 53 - control.current.open() 54 }, 55 close: cb => { 56 - control.current.close(cb) 57 }, 58 }), 59 [id, control],
··· 13 type DialogControlRefProps, 14 type DialogOuterProps, 15 } from '#/components/Dialog/types' 16 + import {IS_DEV} from '#/env' 17 import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types' 18 19 export const Context = createContext<DialogContextProps>({ ··· 51 id, 52 ref: control, 53 open: () => { 54 + if (control.current) { 55 + control.current.open() 56 + } else { 57 + if (IS_DEV) { 58 + console.warn( 59 + 'Attemped to open a dialog control that was not attached to a dialog!\n' + 60 + 'Please ensure that the Dialog is mounted when calling open/close', 61 + ) 62 + } 63 + } 64 }, 65 close: cb => { 66 + if (control.current) { 67 + control.current.close(cb) 68 + } else { 69 + if (IS_DEV) { 70 + console.warn( 71 + 'Attemped to close a dialog control that was not attached to a dialog!\n' + 72 + 'Please ensure that the Dialog is mounted when calling open/close', 73 + ) 74 + } 75 + } 76 }, 77 }), 78 [id, control],
+1
src/components/Dialog/index.web.tsx
··· 180 onClick={stopPropagation} 181 onStartShouldSetResponder={_ => true} 182 onTouchEnd={stopPropagation} 183 style={flatten([ 184 a.relative, 185 a.rounded_md,
··· 180 onClick={stopPropagation} 181 onStartShouldSetResponder={_ => true} 182 onTouchEnd={stopPropagation} 183 + // note: flatten is required for some reason -sfn 184 style={flatten([ 185 a.relative, 186 a.rounded_md,
-85
src/components/ReportDialog/SelectLabelerView.tsx
··· 1 - import {View} from 'react-native' 2 - import {type AppBskyLabelerDefs} from '@atproto/api' 3 - import {msg, Trans} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - 6 - import {getLabelingServiceTitle} from '#/lib/moderation' 7 - import {atoms as a, useBreakpoints, useTheme} from '#/alf' 8 - import {Button, useButtonContext} from '#/components/Button' 9 - import {Divider} from '#/components/Divider' 10 - import * as LabelingServiceCard from '#/components/LabelingServiceCard' 11 - import {Text} from '#/components/Typography' 12 - import {type ReportDialogProps} from './types' 13 - 14 - export function SelectLabelerView({ 15 - ...props 16 - }: ReportDialogProps & { 17 - labelers: AppBskyLabelerDefs.LabelerViewDetailed[] 18 - onSelectLabeler: (v: string) => void 19 - }) { 20 - const t = useTheme() 21 - const {_} = useLingui() 22 - const {gtMobile} = useBreakpoints() 23 - 24 - return ( 25 - <View style={[a.gap_lg]}> 26 - <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}> 27 - <Text style={[a.text_2xl, a.font_semi_bold]}> 28 - <Trans>Select moderator</Trans> 29 - </Text> 30 - <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 31 - <Trans>To whom would you like to send this report?</Trans> 32 - </Text> 33 - </View> 34 - 35 - <Divider /> 36 - 37 - <View style={[a.gap_sm]}> 38 - {props.labelers.map(labeler => { 39 - return ( 40 - <Button 41 - key={labeler.creator.did} 42 - label={_(msg`Send report to ${labeler.creator.displayName}`)} 43 - onPress={() => props.onSelectLabeler(labeler.creator.did)}> 44 - <LabelerButton labeler={labeler} /> 45 - </Button> 46 - ) 47 - })} 48 - </View> 49 - </View> 50 - ) 51 - } 52 - 53 - function LabelerButton({ 54 - labeler, 55 - }: { 56 - labeler: AppBskyLabelerDefs.LabelerViewDetailed 57 - }) { 58 - const t = useTheme() 59 - const {hovered, pressed} = useButtonContext() 60 - const interacted = hovered || pressed 61 - 62 - return ( 63 - <LabelingServiceCard.Outer 64 - style={[ 65 - a.p_md, 66 - a.rounded_sm, 67 - t.atoms.bg_contrast_25, 68 - interacted && t.atoms.bg_contrast_50, 69 - ]}> 70 - <LabelingServiceCard.Avatar avatar={labeler.creator.avatar} /> 71 - <LabelingServiceCard.Content> 72 - <LabelingServiceCard.Title 73 - value={getLabelingServiceTitle({ 74 - displayName: labeler.creator.displayName, 75 - handle: labeler.creator.handle, 76 - })} 77 - /> 78 - <Text 79 - style={[t.atoms.text_contrast_medium, a.text_sm, a.font_semi_bold]}> 80 - @{labeler.creator.handle} 81 - </Text> 82 - </LabelingServiceCard.Content> 83 - </LabelingServiceCard.Outer> 84 - ) 85 - }
···
-195
src/components/ReportDialog/SelectReportOptionView.tsx
··· 1 - import React from 'react' 2 - import {View} from 'react-native' 3 - import {type AppBskyLabelerDefs} from '@atproto/api' 4 - import {msg, Trans} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 - 7 - import { 8 - type ReportOption, 9 - useReportOptions, 10 - } from '#/lib/moderation/useReportOptions' 11 - import {Link} from '#/components/Link' 12 - import {DMCA_LINK} from '#/components/ReportDialog/const' 13 - export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 14 - 15 - import {atoms as a, useBreakpoints, useTheme} from '#/alf' 16 - import { 17 - Button, 18 - ButtonIcon, 19 - ButtonText, 20 - useButtonContext, 21 - } from '#/components/Button' 22 - import {Divider} from '#/components/Divider' 23 - import { 24 - ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, 25 - ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, 26 - } from '#/components/icons/Chevron' 27 - import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' 28 - import {Text} from '#/components/Typography' 29 - import {type ReportDialogProps} from './types' 30 - 31 - export function SelectReportOptionView(props: { 32 - params: ReportDialogProps['params'] 33 - labelers: AppBskyLabelerDefs.LabelerViewDetailed[] 34 - onSelectReportOption: (reportOption: ReportOption) => void 35 - goBack: () => void 36 - }) { 37 - const t = useTheme() 38 - const {_} = useLingui() 39 - const {gtMobile} = useBreakpoints() 40 - const allReportOptions = useReportOptions() 41 - const reportOptions = allReportOptions[props.params.type] 42 - 43 - const i18n = React.useMemo(() => { 44 - let title = _(msg`Report this content`) 45 - let description = _(msg`Why should this content be reviewed?`) 46 - 47 - if (props.params.type === 'account') { 48 - title = _(msg`Report this user`) 49 - description = _(msg`Why should this user be reviewed?`) 50 - } else if (props.params.type === 'post') { 51 - title = _(msg`Report this post`) 52 - description = _(msg`Why should this post be reviewed?`) 53 - } else if (props.params.type === 'list') { 54 - title = _(msg`Report this list`) 55 - description = _(msg`Why should this list be reviewed?`) 56 - } else if (props.params.type === 'feedgen') { 57 - title = _(msg`Report this feed`) 58 - description = _(msg`Why should this feed be reviewed?`) 59 - } else if (props.params.type === 'starterpack') { 60 - title = _(msg`Report this starter pack`) 61 - description = _(msg`Why should this starter pack be reviewed?`) 62 - } else if (props.params.type === 'convoMessage') { 63 - title = _(msg`Report this message`) 64 - description = _(msg`Why should this message be reviewed?`) 65 - } 66 - 67 - return { 68 - title, 69 - description, 70 - } 71 - }, [_, props.params.type]) 72 - 73 - return ( 74 - <View style={[a.gap_lg]}> 75 - {props.labelers?.length > 1 ? ( 76 - <Button 77 - size="small" 78 - variant="solid" 79 - color="secondary" 80 - shape="round" 81 - label={_(msg`Go back to previous step`)} 82 - onPress={props.goBack}> 83 - <ButtonIcon icon={ChevronLeft} /> 84 - </Button> 85 - ) : null} 86 - 87 - <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}> 88 - <Text style={[a.text_2xl, a.font_semi_bold]}>{i18n.title}</Text> 89 - <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 90 - {i18n.description} 91 - </Text> 92 - </View> 93 - 94 - <Divider /> 95 - 96 - <View style={[a.gap_sm]}> 97 - {reportOptions.map(reportOption => { 98 - return ( 99 - <Button 100 - key={reportOption.reason} 101 - testID={reportOption.reason} 102 - label={_(msg`Create report for ${reportOption.title}`)} 103 - onPress={() => props.onSelectReportOption(reportOption)}> 104 - <ReportOptionButton 105 - title={reportOption.title} 106 - description={reportOption.description} 107 - /> 108 - </Button> 109 - ) 110 - })} 111 - 112 - {(props.params.type === 'post' || props.params.type === 'account') && ( 113 - <View 114 - style={[ 115 - a.flex_row, 116 - a.align_center, 117 - a.justify_between, 118 - a.gap_lg, 119 - a.p_md, 120 - a.pl_lg, 121 - a.rounded_md, 122 - t.atoms.bg_contrast_900, 123 - ]}> 124 - <Text 125 - style={[ 126 - a.flex_1, 127 - t.atoms.text_inverted, 128 - a.italic, 129 - a.leading_snug, 130 - ]}> 131 - <Trans>Need to report a copyright violation?</Trans> 132 - </Text> 133 - <Link 134 - to={DMCA_LINK} 135 - label={_(msg`View details for reporting a copyright violation`)} 136 - size="small" 137 - variant="solid" 138 - color="secondary"> 139 - <ButtonText> 140 - <Trans>View details</Trans> 141 - </ButtonText> 142 - <ButtonIcon position="right" icon={SquareArrowTopRight} /> 143 - </Link> 144 - </View> 145 - )} 146 - </View> 147 - </View> 148 - ) 149 - } 150 - 151 - function ReportOptionButton({ 152 - title, 153 - description, 154 - }: { 155 - title: string 156 - description: string 157 - }) { 158 - const t = useTheme() 159 - const {hovered, pressed} = useButtonContext() 160 - const interacted = hovered || pressed 161 - 162 - return ( 163 - <View 164 - style={[ 165 - a.w_full, 166 - a.flex_row, 167 - a.align_center, 168 - a.justify_between, 169 - a.p_md, 170 - a.rounded_md, 171 - {paddingRight: 70}, 172 - t.atoms.bg_contrast_25, 173 - interacted && t.atoms.bg_contrast_50, 174 - ]}> 175 - <View style={[a.flex_1, a.gap_xs]}> 176 - <Text 177 - style={[a.text_md, a.font_semi_bold, t.atoms.text_contrast_medium]}> 178 - {title} 179 - </Text> 180 - <Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text> 181 - </View> 182 - 183 - <View 184 - style={[ 185 - a.absolute, 186 - a.inset_0, 187 - a.justify_center, 188 - a.pr_md, 189 - {left: 'auto'}, 190 - ]}> 191 - <ChevronRight size="md" fill={t.atoms.text_contrast_low.color} /> 192 - </View> 193 - </View> 194 - ) 195 - }
···
-274
src/components/ReportDialog/SubmitView.tsx
··· 1 - import React from 'react' 2 - import {View} from 'react-native' 3 - import {type AppBskyLabelerDefs} from '@atproto/api' 4 - import {msg, Trans} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 - 7 - import {getLabelingServiceTitle} from '#/lib/moderation' 8 - import {type ReportOption} from '#/lib/moderation/useReportOptions' 9 - import {isAndroid} from '#/platform/detection' 10 - import {useAgent} from '#/state/session' 11 - import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 12 - import * as Toast from '#/view/com/util/Toast' 13 - import {atoms as a, native, useTheme} from '#/alf' 14 - import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 - import * as Dialog from '#/components/Dialog' 16 - import * as Toggle from '#/components/forms/Toggle' 17 - import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 18 - import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' 19 - import {PaperPlane_Stroke2_Corner0_Rounded as SendIcon} from '#/components/icons/PaperPlane' 20 - import {Loader} from '#/components/Loader' 21 - import {Text} from '#/components/Typography' 22 - import {type ReportDialogProps} from './types' 23 - 24 - export function SubmitView({ 25 - params, 26 - labelers, 27 - selectedLabeler, 28 - selectedReportOption, 29 - goBack, 30 - onSubmitComplete, 31 - }: ReportDialogProps & { 32 - labelers: AppBskyLabelerDefs.LabelerViewDetailed[] 33 - selectedLabeler: string 34 - selectedReportOption: ReportOption 35 - goBack: () => void 36 - onSubmitComplete: () => void 37 - }) { 38 - const t = useTheme() 39 - const {_} = useLingui() 40 - const agent = useAgent() 41 - const [details, setDetails] = React.useState<string>('') 42 - const [submitting, setSubmitting] = React.useState<boolean>(false) 43 - const [selectedServices, setSelectedServices] = React.useState<string[]>([ 44 - selectedLabeler, 45 - ]) 46 - const [error, setError] = React.useState('') 47 - 48 - const submit = React.useCallback(async () => { 49 - setSubmitting(true) 50 - setError('') 51 - 52 - const $type = 53 - params.type === 'account' 54 - ? 'com.atproto.admin.defs#repoRef' 55 - : 'com.atproto.repo.strongRef' 56 - const report = { 57 - reasonType: selectedReportOption.reason, 58 - subject: { 59 - $type, 60 - ...params, 61 - }, 62 - reason: details, 63 - } 64 - const results = await Promise.all( 65 - selectedServices.map(did => { 66 - return agent 67 - .createModerationReport(report, { 68 - encoding: 'application/json', 69 - headers: { 70 - 'atproto-proxy': `${did}#atproto_labeler`, 71 - }, 72 - }) 73 - .then( 74 - _ => true, 75 - _ => false, 76 - ) 77 - }), 78 - ) 79 - 80 - setSubmitting(false) 81 - 82 - if (results.includes(true)) { 83 - Toast.show(_(msg`Thank you. Your report has been sent.`)) 84 - onSubmitComplete() 85 - } else { 86 - setError( 87 - _( 88 - msg`There was an issue sending your report. Please check your internet connection.`, 89 - ), 90 - ) 91 - } 92 - }, [ 93 - _, 94 - params, 95 - details, 96 - selectedReportOption, 97 - selectedServices, 98 - onSubmitComplete, 99 - setError, 100 - agent, 101 - ]) 102 - 103 - return ( 104 - <View style={[a.gap_2xl]}> 105 - <Button 106 - size="small" 107 - variant="solid" 108 - color="secondary" 109 - shape="round" 110 - label={_(msg`Go back to previous step`)} 111 - onPress={goBack}> 112 - <ButtonIcon icon={ChevronLeft} /> 113 - </Button> 114 - 115 - <View 116 - style={[ 117 - a.w_full, 118 - a.flex_row, 119 - a.align_center, 120 - a.justify_between, 121 - a.gap_lg, 122 - a.p_md, 123 - a.rounded_md, 124 - a.border, 125 - t.atoms.border_contrast_low, 126 - ]}> 127 - <View style={[a.flex_1, a.gap_xs]}> 128 - <Text style={[a.text_md, a.font_semi_bold]}> 129 - {selectedReportOption.title} 130 - </Text> 131 - <Text style={[a.leading_tight, {maxWidth: 400}]}> 132 - {selectedReportOption.description} 133 - </Text> 134 - </View> 135 - 136 - <Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} /> 137 - </View> 138 - 139 - <View style={[a.gap_md]}> 140 - <Text style={[t.atoms.text_contrast_medium]}> 141 - <Trans>Select the moderation service(s) to report to</Trans> 142 - </Text> 143 - 144 - <Toggle.Group 145 - label="Select mod services" 146 - values={selectedServices} 147 - onChange={setSelectedServices}> 148 - <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> 149 - {labelers.map(labeler => { 150 - const title = getLabelingServiceTitle({ 151 - displayName: labeler.creator.displayName, 152 - handle: labeler.creator.handle, 153 - }) 154 - return ( 155 - <Toggle.Item 156 - key={labeler.creator.did} 157 - name={labeler.creator.did} 158 - label={title}> 159 - <LabelerToggle title={title} /> 160 - </Toggle.Item> 161 - ) 162 - })} 163 - </View> 164 - </Toggle.Group> 165 - </View> 166 - <View style={[a.gap_md]}> 167 - <Text style={[t.atoms.text_contrast_medium]}> 168 - <Trans>Optionally provide additional information below:</Trans> 169 - </Text> 170 - 171 - <View style={[a.relative, a.w_full]}> 172 - <Dialog.Input 173 - multiline 174 - value={details} 175 - onChangeText={setDetails} 176 - label="Text field" 177 - style={{paddingRight: 60}} 178 - numberOfLines={6} 179 - /> 180 - 181 - <View 182 - style={[ 183 - a.absolute, 184 - a.flex_row, 185 - a.align_center, 186 - a.pr_md, 187 - a.pb_sm, 188 - { 189 - bottom: 0, 190 - right: 0, 191 - }, 192 - ]}> 193 - <CharProgress count={details?.length || 0} /> 194 - </View> 195 - </View> 196 - </View> 197 - 198 - <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}> 199 - {!selectedServices.length || 200 - (error && ( 201 - <Text 202 - style={[ 203 - a.flex_1, 204 - a.italic, 205 - a.leading_snug, 206 - t.atoms.text_contrast_medium, 207 - ]}> 208 - {error ? ( 209 - error 210 - ) : ( 211 - <Trans>You must select at least one labeler for a report</Trans> 212 - )} 213 - </Text> 214 - ))} 215 - 216 - <Button 217 - testID="sendReportBtn" 218 - size="large" 219 - variant="solid" 220 - color="negative" 221 - label={_(msg`Send report`)} 222 - onPress={submit} 223 - disabled={!selectedServices.length}> 224 - <ButtonText> 225 - <Trans>Send report</Trans> 226 - </ButtonText> 227 - <ButtonIcon icon={submitting ? Loader : SendIcon} /> 228 - </Button> 229 - </View> 230 - {/* Maybe fix this later -h */} 231 - {isAndroid ? <View style={{height: 300}} /> : null} 232 - </View> 233 - ) 234 - } 235 - 236 - function LabelerToggle({title}: {title: string}) { 237 - const t = useTheme() 238 - const ctx = Toggle.useItemContext() 239 - 240 - return ( 241 - <View 242 - style={[ 243 - a.flex_row, 244 - a.align_center, 245 - a.gap_md, 246 - a.p_md, 247 - a.pr_lg, 248 - a.rounded_sm, 249 - a.overflow_hidden, 250 - t.atoms.bg_contrast_25, 251 - ctx.selected && [t.atoms.bg_contrast_50], 252 - ]}> 253 - <Toggle.Checkbox /> 254 - <View 255 - style={[ 256 - a.flex_row, 257 - a.align_center, 258 - a.justify_between, 259 - a.gap_lg, 260 - a.z_10, 261 - ]}> 262 - <Text 263 - emoji 264 - style={[ 265 - native({marginTop: 2}), 266 - t.atoms.text_contrast_medium, 267 - ctx.selected && t.atoms.text, 268 - ]}> 269 - {title} 270 - </Text> 271 - </View> 272 - </View> 273 - ) 274 - }
···
-1
src/components/ReportDialog/const.ts
··· 1 - export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
···
-97
src/components/ReportDialog/index.tsx
··· 1 - import React from 'react' 2 - import {Pressable, View} from 'react-native' 3 - import {type ScrollView} from 'react-native-gesture-handler' 4 - import {msg, Trans} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 - 7 - import {type ReportOption} from '#/lib/moderation/useReportOptions' 8 - import {useMyLabelersQuery} from '#/state/queries/preferences' 9 - export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 10 - 11 - import {type AppBskyLabelerDefs} from '@atproto/api' 12 - 13 - import {atoms as a} from '#/alf' 14 - import * as Dialog from '#/components/Dialog' 15 - import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' 16 - import {Loader} from '#/components/Loader' 17 - import {Text} from '#/components/Typography' 18 - import {SelectLabelerView} from './SelectLabelerView' 19 - import {SelectReportOptionView} from './SelectReportOptionView' 20 - import {SubmitView} from './SubmitView' 21 - import {type ReportDialogProps} from './types' 22 - 23 - export function ReportDialog(props: ReportDialogProps) { 24 - return ( 25 - <Dialog.Outer control={props.control}> 26 - <Dialog.Handle /> 27 - <ReportDialogInner {...props} /> 28 - </Dialog.Outer> 29 - ) 30 - } 31 - 32 - function ReportDialogInner(props: ReportDialogProps) { 33 - const {_} = useLingui() 34 - const { 35 - isLoading: isLabelerLoading, 36 - data: labelers, 37 - error, 38 - } = useMyLabelersQuery({excludeNonConfigurableLabelers: true}) 39 - const isLoading = useDelayedLoading(500, isLabelerLoading) 40 - 41 - const ref = React.useRef<ScrollView>(null) 42 - 43 - return ( 44 - <Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}> 45 - {isLoading ? ( 46 - <View style={[a.align_center, {height: 100}]}> 47 - <Loader size="xl" /> 48 - {/* Here to capture focus for a hot sec to prevent flash */} 49 - <Pressable accessible={false} /> 50 - </View> 51 - ) : error || !labelers ? ( 52 - <View> 53 - <Text style={[a.text_md]}> 54 - <Trans>Something went wrong, please try again.</Trans> 55 - </Text> 56 - </View> 57 - ) : ( 58 - <ReportDialogLoaded labelers={labelers} {...props} /> 59 - )} 60 - </Dialog.ScrollableInner> 61 - ) 62 - } 63 - 64 - function ReportDialogLoaded( 65 - props: ReportDialogProps & { 66 - labelers: AppBskyLabelerDefs.LabelerViewDetailed[] 67 - }, 68 - ) { 69 - const [selectedLabeler, setSelectedLabeler] = React.useState< 70 - string | undefined 71 - >(props.labelers.length === 1 ? props.labelers[0].creator.did : undefined) 72 - const [selectedReportOption, setSelectedReportOption] = React.useState< 73 - ReportOption | undefined 74 - >() 75 - 76 - if (selectedReportOption && selectedLabeler) { 77 - return ( 78 - <SubmitView 79 - {...props} 80 - selectedLabeler={selectedLabeler} 81 - selectedReportOption={selectedReportOption} 82 - goBack={() => setSelectedReportOption(undefined)} 83 - onSubmitComplete={() => props.control.close()} 84 - /> 85 - ) 86 - } 87 - if (selectedLabeler) { 88 - return ( 89 - <SelectReportOptionView 90 - {...props} 91 - goBack={() => setSelectedLabeler(undefined)} 92 - onSelectReportOption={setSelectedReportOption} 93 - /> 94 - ) 95 - } 96 - return <SelectLabelerView {...props} onSelectLabeler={setSelectedLabeler} /> 97 - }
···
-16
src/components/ReportDialog/types.ts
··· 1 - import type * as Dialog from '#/components/Dialog' 2 - 3 - export type ReportDialogProps = { 4 - control: Dialog.DialogOuterProps['control'] 5 - params: 6 - | { 7 - type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other' 8 - uri: string 9 - cid: string 10 - } 11 - | { 12 - type: 'account' 13 - did: string 14 - } 15 - | {type: 'convoMessage'} 16 - }
···
+219
src/components/dms/AfterReportDialog.tsx
···
··· 1 + import {memo, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {type AppBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {StackActions, useNavigation} from '@react-navigation/native' 7 + import type React from 'react' 8 + 9 + import {type NavigationProp} from '#/lib/routes/types' 10 + import {isNative} from '#/platform/detection' 11 + import {useProfileShadow} from '#/state/cache/profile-shadow' 12 + import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' 13 + import { 14 + useProfileBlockMutationQueue, 15 + useProfileQuery, 16 + } from '#/state/queries/profile' 17 + import * as Toast from '#/view/com/util/Toast' 18 + import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' 19 + import {Button, ButtonText} from '#/components/Button' 20 + import * as Dialog from '#/components/Dialog' 21 + import * as Toggle from '#/components/forms/Toggle' 22 + import {Loader} from '#/components/Loader' 23 + import {Text} from '#/components/Typography' 24 + 25 + type ReportDialogParams = { 26 + convoId: string 27 + message: ChatBskyConvoDefs.MessageView 28 + } 29 + 30 + /** 31 + * Dialog shown after a report is submitted, allowing the user to block the 32 + * reporter and/or leave the conversation. 33 + */ 34 + export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({ 35 + control, 36 + params, 37 + currentScreen, 38 + }: { 39 + control: Dialog.DialogControlProps 40 + params: ReportDialogParams 41 + currentScreen: 'list' | 'conversation' 42 + }): React.ReactNode { 43 + const {_} = useLingui() 44 + return ( 45 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 46 + <Dialog.Handle /> 47 + <Dialog.ScrollableInner 48 + label={_( 49 + msg`Would you like to block this account or delete this conversation?`, 50 + )} 51 + style={[web({maxWidth: 400})]}> 52 + <DialogInner params={params} currentScreen={currentScreen} /> 53 + <Dialog.Close /> 54 + </Dialog.ScrollableInner> 55 + </Dialog.Outer> 56 + ) 57 + }) 58 + 59 + function DialogInner({ 60 + params, 61 + currentScreen, 62 + }: { 63 + params: ReportDialogParams 64 + currentScreen: 'list' | 'conversation' 65 + }) { 66 + const t = useTheme() 67 + const {_} = useLingui() 68 + const control = Dialog.useDialogContext() 69 + const { 70 + data: profile, 71 + isLoading, 72 + isError, 73 + } = useProfileQuery({ 74 + did: params.message.sender.did, 75 + }) 76 + 77 + return isLoading ? ( 78 + <View style={[a.w_full, a.py_5xl, a.align_center]}> 79 + <Loader size="lg" /> 80 + </View> 81 + ) : isError || !profile ? ( 82 + <View style={[a.w_full, a.gap_lg]}> 83 + <View style={[a.justify_center, a.gap_sm]}> 84 + <Text style={[a.text_2xl, a.font_semi_bold]}> 85 + <Trans>Report submitted</Trans> 86 + </Text> 87 + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 88 + <Trans>Our moderation team has received your report.</Trans> 89 + </Text> 90 + </View> 91 + 92 + <Button 93 + label={_(msg`Close`)} 94 + onPress={() => control.close()} 95 + size={platform({native: 'small', web: 'large'})} 96 + color="secondary"> 97 + <ButtonText> 98 + <Trans>Close</Trans> 99 + </ButtonText> 100 + </Button> 101 + </View> 102 + ) : ( 103 + <DoneStep 104 + convoId={params.convoId} 105 + currentScreen={currentScreen} 106 + profile={profile} 107 + /> 108 + ) 109 + } 110 + 111 + function DoneStep({ 112 + convoId, 113 + currentScreen, 114 + profile, 115 + }: { 116 + convoId: string 117 + currentScreen: 'list' | 'conversation' 118 + profile: AppBskyActorDefs.ProfileViewDetailed 119 + }) { 120 + const {_} = useLingui() 121 + const navigation = useNavigation<NavigationProp>() 122 + const control = Dialog.useDialogContext() 123 + const {gtMobile} = useBreakpoints() 124 + const t = useTheme() 125 + const [actions, setActions] = useState<string[]>(['block', 'leave']) 126 + const shadow = useProfileShadow(profile) 127 + const [queueBlock] = useProfileBlockMutationQueue(shadow) 128 + 129 + const {mutate: leaveConvo} = useLeaveConvo(convoId, { 130 + onMutate: () => { 131 + if (currentScreen === 'conversation') { 132 + navigation.dispatch( 133 + StackActions.replace('Messages', isNative ? {animation: 'pop'} : {}), 134 + ) 135 + } 136 + }, 137 + onError: () => { 138 + Toast.show(_(msg`Could not leave chat`), 'xmark') 139 + }, 140 + }) 141 + 142 + let btnText = _(msg`Done`) 143 + let toastMsg: string | undefined 144 + if (actions.includes('leave') && actions.includes('block')) { 145 + btnText = _(msg`Block and Delete`) 146 + toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'})) 147 + } else if (actions.includes('leave')) { 148 + btnText = _(msg`Delete Conversation`) 149 + toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'})) 150 + } else if (actions.includes('block')) { 151 + btnText = _(msg`Block User`) 152 + toastMsg = _(msg({message: 'User blocked', context: 'toast'})) 153 + } 154 + 155 + const onPressPrimaryAction = () => { 156 + control.close(() => { 157 + if (actions.includes('block')) { 158 + queueBlock() 159 + } 160 + if (actions.includes('leave')) { 161 + leaveConvo() 162 + } 163 + if (toastMsg) { 164 + Toast.show(toastMsg, 'check') 165 + } 166 + }) 167 + } 168 + 169 + return ( 170 + <View style={a.gap_2xl}> 171 + <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}> 172 + <Text style={[a.text_2xl, a.font_semi_bold]}> 173 + <Trans>Report submitted</Trans> 174 + </Text> 175 + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 176 + <Trans>Our moderation team has received your report.</Trans> 177 + </Text> 178 + </View> 179 + <Toggle.Group 180 + label={_(msg`Block and/or delete this conversation`)} 181 + values={actions} 182 + onChange={setActions}> 183 + <View style={[a.gap_md]}> 184 + <Toggle.Item name="block" label={_(msg`Block user`)}> 185 + <Toggle.Checkbox /> 186 + <Toggle.LabelText style={[a.text_md]}> 187 + <Trans>Block user</Trans> 188 + </Toggle.LabelText> 189 + </Toggle.Item> 190 + <Toggle.Item name="leave" label={_(msg`Delete conversation`)}> 191 + <Toggle.Checkbox /> 192 + <Toggle.LabelText style={[a.text_md]}> 193 + <Trans>Delete conversation</Trans> 194 + </Toggle.LabelText> 195 + </Toggle.Item> 196 + </View> 197 + </Toggle.Group> 198 + 199 + <View style={[a.gap_sm]}> 200 + <Button 201 + label={btnText} 202 + onPress={onPressPrimaryAction} 203 + size="large" 204 + color={actions.length > 0 ? 'negative' : 'primary'}> 205 + <ButtonText>{btnText}</ButtonText> 206 + </Button> 207 + <Button 208 + label={_(msg`Close`)} 209 + onPress={() => control.close()} 210 + size="large" 211 + color="secondary"> 212 + <ButtonText> 213 + <Trans>Close</Trans> 214 + </ButtonText> 215 + </Button> 216 + </View> 217 + </View> 218 + ) 219 + }
+24 -10
src/components/dms/ConvoMenu.tsx
··· 17 import {type ViewStyleProp} from '#/alf' 18 import {atoms as a} from '#/alf' 19 import {Button, ButtonIcon} from '#/components/Button' 20 import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog' 21 import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' 22 import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' 23 - import {ReportDialog} from '#/components/dms/ReportDialog' 24 import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' 25 import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' 26 import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' ··· 33 } from '#/components/icons/Person' 34 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 35 import * as Menu from '#/components/Menu' 36 import * as Prompt from '#/components/Prompt' 37 import type * as bsky from '#/types/bsky' 38 ··· 65 const leaveConvoControl = Prompt.usePromptControl() 66 const reportControl = Prompt.usePromptControl() 67 const blockedByListControl = Prompt.usePromptControl() 68 69 const {listBlocks} = blockInfo 70 ··· 113 currentScreen={currentScreen} 114 /> 115 {latestReportableMessage ? ( 116 - <ReportDialog 117 - currentScreen={currentScreen} 118 - params={{ 119 - type: 'convoMessage', 120 - convoId: convo.id, 121 - message: latestReportableMessage, 122 - }} 123 - control={reportControl} 124 - /> 125 ) : ( 126 <ReportConversationPrompt control={reportControl} /> 127 )}
··· 17 import {type ViewStyleProp} from '#/alf' 18 import {atoms as a} from '#/alf' 19 import {Button, ButtonIcon} from '#/components/Button' 20 + import {AfterReportDialog} from '#/components/dms/AfterReportDialog' 21 import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog' 22 import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' 23 import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' 24 import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' 25 import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' 26 import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' ··· 33 } from '#/components/icons/Person' 34 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 35 import * as Menu from '#/components/Menu' 36 + import {ReportDialog} from '#/components/moderation/ReportDialog' 37 import * as Prompt from '#/components/Prompt' 38 import type * as bsky from '#/types/bsky' 39 ··· 66 const leaveConvoControl = Prompt.usePromptControl() 67 const reportControl = Prompt.usePromptControl() 68 const blockedByListControl = Prompt.usePromptControl() 69 + const blockOrDeleteControl = Prompt.usePromptControl() 70 71 const {listBlocks} = blockInfo 72 ··· 115 currentScreen={currentScreen} 116 /> 117 {latestReportableMessage ? ( 118 + <> 119 + <ReportDialog 120 + subject={{ 121 + view: 'convo', 122 + convoId: convo.id, 123 + message: latestReportableMessage, 124 + }} 125 + control={reportControl} 126 + onAfterSubmit={() => { 127 + blockOrDeleteControl.open() 128 + }} 129 + /> 130 + <AfterReportDialog 131 + control={blockOrDeleteControl} 132 + currentScreen={currentScreen} 133 + params={{ 134 + convoId: convo.id, 135 + message: latestReportableMessage, 136 + }} 137 + /> 138 + </> 139 ) : ( 140 <ReportConversationPrompt control={reportControl} /> 141 )}
+20 -3
src/components/dms/MessageContextMenu.tsx
··· 15 import * as Toast from '#/view/com/util/Toast' 16 import * as ContextMenu from '#/components/ContextMenu' 17 import {type TriggerProps} from '#/components/ContextMenu/types' 18 - import {ReportDialog} from '#/components/dms/ReportDialog' 19 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 20 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 21 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 22 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 23 import * as Prompt from '#/components/Prompt' 24 import {usePromptControl} from '#/components/Prompt' 25 import {EmojiReactionPicker} from './EmojiReactionPicker' ··· 37 const convo = useConvoActive() 38 const deleteControl = usePromptControl() 39 const reportControl = usePromptControl() 40 const langPrefs = useLanguagePrefs() 41 const translate = useTranslate() 42 ··· 171 </ContextMenu.Root> 172 173 <ReportDialog 174 currentScreen="conversation" 175 - params={{type: 'convoMessage', convoId: convo.convo.id, message}} 176 - control={reportControl} 177 /> 178 179 <Prompt.Basic
··· 15 import * as Toast from '#/view/com/util/Toast' 16 import * as ContextMenu from '#/components/ContextMenu' 17 import {type TriggerProps} from '#/components/ContextMenu/types' 18 + import {AfterReportDialog} from '#/components/dms/AfterReportDialog' 19 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 20 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 21 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 22 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 23 + import {ReportDialog} from '#/components/moderation/ReportDialog' 24 import * as Prompt from '#/components/Prompt' 25 import {usePromptControl} from '#/components/Prompt' 26 import {EmojiReactionPicker} from './EmojiReactionPicker' ··· 38 const convo = useConvoActive() 39 const deleteControl = usePromptControl() 40 const reportControl = usePromptControl() 41 + const blockOrDeleteControl = usePromptControl() 42 const langPrefs = useLanguagePrefs() 43 const translate = useTranslate() 44 ··· 173 </ContextMenu.Root> 174 175 <ReportDialog 176 + // currentScreen="conversation" 177 + control={reportControl} 178 + subject={{ 179 + view: 'message', 180 + convoId: convo.convo.id, 181 + message, 182 + }} 183 + onAfterSubmit={() => { 184 + blockOrDeleteControl.open() 185 + }} 186 + /> 187 + <AfterReportDialog 188 + control={blockOrDeleteControl} 189 currentScreen="conversation" 190 + params={{ 191 + convoId: convo.convo.id, 192 + message, 193 + }} 194 /> 195 196 <Prompt.Basic
-444
src/components/dms/ReportDialog.tsx
··· 1 - import {memo, useMemo, useState} from 'react' 2 - import {View} from 'react-native' 3 - import { 4 - type $Typed, 5 - type AppBskyActorDefs, 6 - type ChatBskyConvoDefs, 7 - type ComAtprotoModerationCreateReport, 8 - RichText as RichTextAPI, 9 - } from '@atproto/api' 10 - import {msg, Trans} from '@lingui/macro' 11 - import {useLingui} from '@lingui/react' 12 - import {StackActions, useNavigation} from '@react-navigation/native' 13 - import {useMutation} from '@tanstack/react-query' 14 - import type React from 'react' 15 - 16 - import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants' 17 - import {type ReportOption} from '#/lib/moderation/useReportOptions' 18 - import {type NavigationProp} from '#/lib/routes/types' 19 - import {isNative} from '#/platform/detection' 20 - import {useProfileShadow} from '#/state/cache/profile-shadow' 21 - import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' 22 - import { 23 - useProfileBlockMutationQueue, 24 - useProfileQuery, 25 - } from '#/state/queries/profile' 26 - import {useAgent} from '#/state/session' 27 - import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 28 - import * as Toast from '#/view/com/util/Toast' 29 - import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' 30 - import {Button, ButtonIcon, ButtonText} from '#/components/Button' 31 - import * as Dialog from '#/components/Dialog' 32 - import {Divider} from '#/components/Divider' 33 - import * as Toggle from '#/components/forms/Toggle' 34 - import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron' 35 - import {PaperPlane_Stroke2_Corner0_Rounded as SendIcon} from '#/components/icons/PaperPlane' 36 - import {Loader} from '#/components/Loader' 37 - import {SelectReportOptionView} from '#/components/ReportDialog/SelectReportOptionView' 38 - import {RichText} from '#/components/RichText' 39 - import {Text} from '#/components/Typography' 40 - import {MessageItemMetadata} from './MessageItem' 41 - 42 - type ReportDialogParams = { 43 - type: 'convoMessage' 44 - convoId: string 45 - message: ChatBskyConvoDefs.MessageView 46 - } 47 - 48 - let ReportDialog = ({ 49 - control, 50 - params, 51 - currentScreen, 52 - }: { 53 - control: Dialog.DialogControlProps 54 - params: ReportDialogParams 55 - currentScreen: 'list' | 'conversation' 56 - }): React.ReactNode => { 57 - const {_} = useLingui() 58 - return ( 59 - <Dialog.Outer control={control}> 60 - <Dialog.Handle /> 61 - <Dialog.ScrollableInner label={_(msg`Report this message`)}> 62 - <DialogInner params={params} currentScreen={currentScreen} /> 63 - <Dialog.Close /> 64 - </Dialog.ScrollableInner> 65 - </Dialog.Outer> 66 - ) 67 - } 68 - ReportDialog = memo(ReportDialog) 69 - export {ReportDialog} 70 - 71 - function DialogInner({ 72 - params, 73 - currentScreen, 74 - }: { 75 - params: ReportDialogParams 76 - currentScreen: 'list' | 'conversation' 77 - }) { 78 - const {data: profile, isError} = useProfileQuery({ 79 - did: params.message.sender.did, 80 - }) 81 - const [reportOption, setReportOption] = useState<ReportOption | null>(null) 82 - const [done, setDone] = useState(false) 83 - const control = Dialog.useDialogContext() 84 - 85 - return done ? ( 86 - profile ? ( 87 - <DoneStep 88 - convoId={params.convoId} 89 - currentScreen={currentScreen} 90 - profile={profile} 91 - /> 92 - ) : ( 93 - <View style={[a.w_full, a.py_5xl, a.align_center]}> 94 - <Loader /> 95 - </View> 96 - ) 97 - ) : reportOption ? ( 98 - <SubmitStep 99 - params={params} 100 - reportOption={reportOption} 101 - goBack={() => setReportOption(null)} 102 - onComplete={() => { 103 - if (isError) { 104 - control.close() 105 - } else { 106 - setDone(true) 107 - } 108 - }} 109 - /> 110 - ) : ( 111 - <ReasonStep params={params} setReportOption={setReportOption} /> 112 - ) 113 - } 114 - 115 - function ReasonStep({ 116 - setReportOption, 117 - }: { 118 - setReportOption: (reportOption: ReportOption) => void 119 - params: ReportDialogParams 120 - }) { 121 - const control = Dialog.useDialogContext() 122 - 123 - return ( 124 - <SelectReportOptionView 125 - labelers={[]} 126 - goBack={control.close} 127 - params={{ 128 - type: 'convoMessage', 129 - }} 130 - onSelectReportOption={setReportOption} 131 - /> 132 - ) 133 - } 134 - 135 - function SubmitStep({ 136 - params, 137 - reportOption, 138 - goBack, 139 - onComplete, 140 - }: { 141 - params: ReportDialogParams 142 - reportOption: ReportOption 143 - goBack: () => void 144 - onComplete: () => void 145 - }) { 146 - const {_} = useLingui() 147 - const {gtMobile} = useBreakpoints() 148 - const t = useTheme() 149 - const [details, setDetails] = useState('') 150 - const agent = useAgent() 151 - 152 - const { 153 - mutate: submit, 154 - error, 155 - isPending: submitting, 156 - } = useMutation({ 157 - mutationFn: async () => { 158 - if (params.type === 'convoMessage') { 159 - const {convoId, message} = params 160 - const subject: $Typed<ChatBskyConvoDefs.MessageRef> = { 161 - $type: 'chat.bsky.convo.defs#messageRef', 162 - messageId: message.id, 163 - convoId, 164 - did: message.sender.did, 165 - } 166 - 167 - const report = { 168 - reasonType: reportOption.reason, 169 - subject, 170 - reason: details, 171 - } satisfies ComAtprotoModerationCreateReport.InputSchema 172 - 173 - await agent.createModerationReport(report, { 174 - encoding: 'application/json', 175 - headers: BLUESKY_MOD_SERVICE_HEADERS, 176 - }) 177 - } 178 - }, 179 - onSuccess: onComplete, 180 - }) 181 - 182 - const copy = useMemo(() => { 183 - return { 184 - convoMessage: { 185 - title: _(msg`Report this message`), 186 - }, 187 - }[params.type] 188 - }, [_, params]) 189 - 190 - return ( 191 - <View style={a.gap_lg}> 192 - <Button 193 - size="small" 194 - variant="solid" 195 - color="secondary" 196 - shape="round" 197 - label={_(msg`Go back to previous step`)} 198 - onPress={goBack}> 199 - <ButtonIcon icon={Chevron} /> 200 - </Button> 201 - 202 - <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}> 203 - <Text style={[a.text_2xl, a.font_semi_bold]}>{copy.title}</Text> 204 - <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 205 - <Trans> 206 - Your report will be sent to the Bluesky Moderation Service 207 - </Trans> 208 - </Text> 209 - </View> 210 - 211 - {params.type === 'convoMessage' && ( 212 - <PreviewMessage message={params.message} /> 213 - )} 214 - 215 - <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 216 - <Text 217 - style={[a.font_semi_bold, a.text_md, t.atoms.text_contrast_medium]}> 218 - <Trans>Reason:</Trans> 219 - </Text>{' '} 220 - <Text style={[a.font_semi_bold, a.text_md]}>{reportOption.title}</Text> 221 - </Text> 222 - 223 - <Divider /> 224 - 225 - <View style={[a.gap_md]}> 226 - <Text style={[t.atoms.text_contrast_medium]}> 227 - <Trans>Optionally provide additional information below:</Trans> 228 - </Text> 229 - 230 - <View style={[a.relative, a.w_full]}> 231 - <Dialog.Input 232 - multiline 233 - defaultValue={details} 234 - onChangeText={setDetails} 235 - label={_(msg`Text field`)} 236 - style={{paddingRight: 60}} 237 - numberOfLines={5} 238 - /> 239 - <View 240 - style={[ 241 - a.absolute, 242 - a.flex_row, 243 - a.align_center, 244 - a.pr_md, 245 - a.pb_sm, 246 - { 247 - bottom: 0, 248 - right: 0, 249 - }, 250 - ]}> 251 - <CharProgress count={details?.length || 0} /> 252 - </View> 253 - </View> 254 - </View> 255 - 256 - <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}> 257 - {error && ( 258 - <Text 259 - style={[ 260 - a.flex_1, 261 - a.italic, 262 - a.leading_snug, 263 - t.atoms.text_contrast_medium, 264 - ]}> 265 - <Trans> 266 - There was an issue sending your report. Please check your internet 267 - connection. 268 - </Trans> 269 - </Text> 270 - )} 271 - 272 - <Button 273 - testID="sendReportBtn" 274 - size="large" 275 - variant="solid" 276 - color="negative" 277 - label={_(msg`Send report`)} 278 - onPress={() => submit()}> 279 - <ButtonText> 280 - <Trans>Send report</Trans> 281 - </ButtonText> 282 - <ButtonIcon icon={submitting ? Loader : SendIcon} /> 283 - </Button> 284 - </View> 285 - </View> 286 - ) 287 - } 288 - 289 - function DoneStep({ 290 - convoId, 291 - currentScreen, 292 - profile, 293 - }: { 294 - convoId: string 295 - currentScreen: 'list' | 'conversation' 296 - profile: AppBskyActorDefs.ProfileViewDetailed 297 - }) { 298 - const {_} = useLingui() 299 - const navigation = useNavigation<NavigationProp>() 300 - const control = Dialog.useDialogContext() 301 - const {gtMobile} = useBreakpoints() 302 - const t = useTheme() 303 - const [actions, setActions] = useState<string[]>(['block', 'leave']) 304 - const shadow = useProfileShadow(profile) 305 - const [queueBlock] = useProfileBlockMutationQueue(shadow) 306 - 307 - const {mutate: leaveConvo} = useLeaveConvo(convoId, { 308 - onMutate: () => { 309 - if (currentScreen === 'conversation') { 310 - navigation.dispatch( 311 - StackActions.replace('Messages', isNative ? {animation: 'pop'} : {}), 312 - ) 313 - } 314 - }, 315 - onError: () => { 316 - Toast.show(_(msg`Could not leave chat`), 'xmark') 317 - }, 318 - }) 319 - 320 - let btnText = _(msg`Done`) 321 - let toastMsg: string | undefined 322 - if (actions.includes('leave') && actions.includes('block')) { 323 - btnText = _(msg`Block and Delete`) 324 - toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'})) 325 - } else if (actions.includes('leave')) { 326 - btnText = _(msg`Delete Conversation`) 327 - toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'})) 328 - } else if (actions.includes('block')) { 329 - btnText = _(msg`Block User`) 330 - toastMsg = _(msg({message: 'User blocked', context: 'toast'})) 331 - } 332 - 333 - const onPressPrimaryAction = () => { 334 - control.close(() => { 335 - if (actions.includes('block')) { 336 - queueBlock() 337 - } 338 - if (actions.includes('leave')) { 339 - leaveConvo() 340 - } 341 - if (toastMsg) { 342 - Toast.show(toastMsg, 'check') 343 - } 344 - }) 345 - } 346 - 347 - return ( 348 - <View style={a.gap_2xl}> 349 - <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}> 350 - <Text style={[a.text_2xl, a.font_semi_bold]}> 351 - <Trans>Report submitted</Trans> 352 - </Text> 353 - <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 354 - <Trans>Our moderation team has received your report.</Trans> 355 - </Text> 356 - </View> 357 - <Toggle.Group 358 - label={_(msg`Block and/or delete this conversation`)} 359 - values={actions} 360 - onChange={setActions}> 361 - <View style={[a.gap_md]}> 362 - <Toggle.Item name="block" label={_(msg`Block user`)}> 363 - <Toggle.Checkbox /> 364 - <Toggle.LabelText style={[a.text_md]}> 365 - <Trans>Block user</Trans> 366 - </Toggle.LabelText> 367 - </Toggle.Item> 368 - <Toggle.Item name="leave" label={_(msg`Delete conversation`)}> 369 - <Toggle.Checkbox /> 370 - <Toggle.LabelText style={[a.text_md]}> 371 - <Trans>Delete conversation</Trans> 372 - </Toggle.LabelText> 373 - </Toggle.Item> 374 - </View> 375 - </Toggle.Group> 376 - 377 - <View style={[a.gap_md, web([a.flex_row_reverse])]}> 378 - <Button 379 - label={btnText} 380 - onPress={onPressPrimaryAction} 381 - size="large" 382 - variant="solid" 383 - color={actions.length > 0 ? 'negative' : 'primary'}> 384 - <ButtonText>{btnText}</ButtonText> 385 - </Button> 386 - <Button 387 - label={_(msg`Close`)} 388 - onPress={() => control.close()} 389 - size={platform({native: 'small', web: 'large'})} 390 - variant={platform({ 391 - native: 'ghost', 392 - web: 'solid', 393 - })} 394 - color="secondary"> 395 - <ButtonText> 396 - <Trans>Close</Trans> 397 - </ButtonText> 398 - </Button> 399 - </View> 400 - </View> 401 - ) 402 - } 403 - 404 - function PreviewMessage({message}: {message: ChatBskyConvoDefs.MessageView}) { 405 - const t = useTheme() 406 - const rt = useMemo(() => { 407 - return new RichTextAPI({text: message.text, facets: message.facets}) 408 - }, [message.text, message.facets]) 409 - 410 - return ( 411 - <View style={a.align_start}> 412 - <View 413 - style={[ 414 - a.py_sm, 415 - a.my_2xs, 416 - a.rounded_md, 417 - { 418 - paddingLeft: 14, 419 - paddingRight: 14, 420 - backgroundColor: t.palette.contrast_50, 421 - borderRadius: 17, 422 - }, 423 - {borderBottomLeftRadius: 2}, 424 - ]}> 425 - <RichText 426 - value={rt} 427 - style={[a.text_md]} 428 - interactiveStyle={a.underline} 429 - enableTags 430 - /> 431 - </View> 432 - <MessageItemMetadata 433 - item={{ 434 - type: 'message', 435 - message, 436 - key: '', 437 - nextMessage: null, 438 - prevMessage: null, 439 - }} 440 - style={[a.text_left, a.mb_0]} 441 - /> 442 - </View> 443 - ) 444 - }
···
+27 -6
src/components/moderation/ReportDialog/action.ts
··· 9 10 import {logger} from '#/logger' 11 import {useAgent} from '#/state/session' 12 import {type ReportState} from './state' 13 import {type ParsedReportSubject} from './types' 14 ··· 31 throw new Error(_(msg`Please select a moderation service`)) 32 } 33 34 let report: 35 | ComAtprotoModerationCreateReport.InputSchema 36 | (Omit<ComAtprotoModerationCreateReport.InputSchema, 'subject'> & { ··· 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', ··· 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', ··· 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', ··· 82 if (__DEV__) { 83 logger.info('Submitting report', { 84 labeler: { 85 - handle: state.selectedLabeler.creator.handle, 86 }, 87 report, 88 }) ··· 90 await agent.createModerationReport(report, { 91 encoding: 'application/json', 92 headers: { 93 - 'atproto-proxy': `${state.selectedLabeler.creator.did}#atproto_labeler`, 94 }, 95 }) 96 }
··· 9 10 import {logger} from '#/logger' 11 import {useAgent} from '#/state/session' 12 + import {NEW_TO_OLD_REASONS_MAP} from './const' 13 import {type ReportState} from './state' 14 import {type ParsedReportSubject} from './types' 15 ··· 32 throw new Error(_(msg`Please select a moderation service`)) 33 } 34 35 + const labeler = state.selectedLabeler 36 + const labelerSupportedReasonTypes = labeler.reasonTypes || [] 37 + 38 + let reasonType = state.selectedOption.reason 39 + const backwardsCompatibleReasonType = NEW_TO_OLD_REASONS_MAP[reasonType] 40 + const supportsNewReasonType = 41 + labelerSupportedReasonTypes.includes(reasonType) 42 + const supportsOldReasonType = labelerSupportedReasonTypes.includes( 43 + backwardsCompatibleReasonType, 44 + ) 45 + 46 + /* 47 + * Only fall back for backwards compatibility if the labeler 48 + * does not support the new reason type. If the labeler does not declare 49 + * supported reason types, send the new version. 50 + */ 51 + if (supportsOldReasonType && !supportsNewReasonType) { 52 + reasonType = backwardsCompatibleReasonType 53 + } 54 + 55 let report: 56 | ComAtprotoModerationCreateReport.InputSchema 57 | (Omit<ComAtprotoModerationCreateReport.InputSchema, 'subject'> & { ··· 61 switch (subject.type) { 62 case 'account': { 63 report = { 64 + reasonType, 65 reason: state.details, 66 subject: { 67 $type: 'com.atproto.admin.defs#repoRef', ··· 75 case 'feed': 76 case 'starterPack': { 77 report = { 78 + reasonType, 79 reason: state.details, 80 subject: { 81 $type: 'com.atproto.repo.strongRef', ··· 85 } 86 break 87 } 88 + case 'convoMessage': { 89 report = { 90 + reasonType, 91 reason: state.details, 92 subject: { 93 $type: 'chat.bsky.convo.defs#messageRef', ··· 103 if (__DEV__) { 104 logger.info('Submitting report', { 105 labeler: { 106 + handle: labeler.creator.handle, 107 }, 108 report, 109 }) ··· 111 await agent.createModerationReport(report, { 112 encoding: 'application/json', 113 headers: { 114 + 'atproto-proxy': `${labeler.creator.did}#atproto_labeler`, 115 }, 116 }) 117 }
+112
src/components/moderation/ReportDialog/const.ts
··· 1 export const DMCA_LINK = 'https://bsky.social/about/support/copyright' 2 export const SUPPORT_PAGE = 'https://bsky.social/about/support'
··· 1 + import { 2 + ComAtprotoModerationDefs as RootReportDefs, 3 + ToolsOzoneReportDefs as OzoneReportDefs, 4 + } from '@atproto/api' 5 + 6 export const DMCA_LINK = 'https://bsky.social/about/support/copyright' 7 export const SUPPORT_PAGE = 'https://bsky.social/about/support' 8 + 9 + export const NEW_TO_OLD_REASON_MAPPING: Record<string, string> = {} 10 + 11 + /** 12 + * Mapping of new (Ozone namespace) reason types to old reason types. 13 + * 14 + * Matches the mapping defined in the Ozone codebase: 15 + * @see https://github.com/bluesky-social/atproto/blob/4c15fb47cec26060bff2e710e95869a90c9d7fdd/packages/ozone/src/mod-service/profile.ts#L16-L64 16 + */ 17 + export const NEW_TO_OLD_REASONS_MAP: Record< 18 + OzoneReportDefs.ReasonType, 19 + RootReportDefs.ReasonType 20 + > = { 21 + [OzoneReportDefs.REASONAPPEAL]: RootReportDefs.REASONAPPEAL, 22 + [OzoneReportDefs.REASONOTHER]: RootReportDefs.REASONOTHER, 23 + 24 + [OzoneReportDefs.REASONVIOLENCEANIMAL]: RootReportDefs.REASONVIOLATION, 25 + [OzoneReportDefs.REASONVIOLENCETHREATS]: RootReportDefs.REASONVIOLATION, 26 + [OzoneReportDefs.REASONVIOLENCEGRAPHICCONTENT]: 27 + RootReportDefs.REASONVIOLATION, 28 + [OzoneReportDefs.REASONVIOLENCEGLORIFICATION]: RootReportDefs.REASONVIOLATION, 29 + [OzoneReportDefs.REASONVIOLENCEEXTREMISTCONTENT]: 30 + RootReportDefs.REASONVIOLATION, 31 + [OzoneReportDefs.REASONVIOLENCETRAFFICKING]: RootReportDefs.REASONVIOLATION, 32 + [OzoneReportDefs.REASONVIOLENCEOTHER]: RootReportDefs.REASONVIOLATION, 33 + 34 + [OzoneReportDefs.REASONSEXUALABUSECONTENT]: RootReportDefs.REASONSEXUAL, 35 + [OzoneReportDefs.REASONSEXUALNCII]: RootReportDefs.REASONSEXUAL, 36 + [OzoneReportDefs.REASONSEXUALDEEPFAKE]: RootReportDefs.REASONSEXUAL, 37 + [OzoneReportDefs.REASONSEXUALANIMAL]: RootReportDefs.REASONSEXUAL, 38 + [OzoneReportDefs.REASONSEXUALUNLABELED]: RootReportDefs.REASONSEXUAL, 39 + [OzoneReportDefs.REASONSEXUALOTHER]: RootReportDefs.REASONSEXUAL, 40 + 41 + [OzoneReportDefs.REASONCHILDSAFETYCSAM]: RootReportDefs.REASONVIOLATION, 42 + [OzoneReportDefs.REASONCHILDSAFETYGROOM]: RootReportDefs.REASONVIOLATION, 43 + [OzoneReportDefs.REASONCHILDSAFETYPRIVACY]: RootReportDefs.REASONVIOLATION, 44 + [OzoneReportDefs.REASONCHILDSAFETYHARASSMENT]: RootReportDefs.REASONVIOLATION, 45 + [OzoneReportDefs.REASONCHILDSAFETYOTHER]: RootReportDefs.REASONVIOLATION, 46 + 47 + [OzoneReportDefs.REASONHARASSMENTTROLL]: RootReportDefs.REASONRUDE, 48 + [OzoneReportDefs.REASONHARASSMENTTARGETED]: RootReportDefs.REASONRUDE, 49 + [OzoneReportDefs.REASONHARASSMENTHATESPEECH]: RootReportDefs.REASONRUDE, 50 + [OzoneReportDefs.REASONHARASSMENTDOXXING]: RootReportDefs.REASONRUDE, 51 + [OzoneReportDefs.REASONHARASSMENTOTHER]: RootReportDefs.REASONRUDE, 52 + 53 + [OzoneReportDefs.REASONMISLEADINGBOT]: RootReportDefs.REASONMISLEADING, 54 + [OzoneReportDefs.REASONMISLEADINGIMPERSONATION]: 55 + RootReportDefs.REASONMISLEADING, 56 + [OzoneReportDefs.REASONMISLEADINGSPAM]: RootReportDefs.REASONSPAM, 57 + [OzoneReportDefs.REASONMISLEADINGSCAM]: RootReportDefs.REASONMISLEADING, 58 + [OzoneReportDefs.REASONMISLEADINGELECTIONS]: RootReportDefs.REASONMISLEADING, 59 + [OzoneReportDefs.REASONMISLEADINGOTHER]: RootReportDefs.REASONMISLEADING, 60 + 61 + [OzoneReportDefs.REASONRULESITESECURITY]: RootReportDefs.REASONVIOLATION, 62 + [OzoneReportDefs.REASONRULEPROHIBITEDSALES]: RootReportDefs.REASONVIOLATION, 63 + [OzoneReportDefs.REASONRULEBANEVASION]: RootReportDefs.REASONVIOLATION, 64 + [OzoneReportDefs.REASONRULEOTHER]: RootReportDefs.REASONVIOLATION, 65 + 66 + [OzoneReportDefs.REASONSELFHARMCONTENT]: RootReportDefs.REASONVIOLATION, 67 + [OzoneReportDefs.REASONSELFHARMED]: RootReportDefs.REASONVIOLATION, 68 + [OzoneReportDefs.REASONSELFHARMSTUNTS]: RootReportDefs.REASONVIOLATION, 69 + [OzoneReportDefs.REASONSELFHARMSUBSTANCES]: RootReportDefs.REASONVIOLATION, 70 + [OzoneReportDefs.REASONSELFHARMOTHER]: RootReportDefs.REASONVIOLATION, 71 + } 72 + 73 + /** 74 + * Mapping of old reason types to new (Ozone namespace) reason types. 75 + * @see https://github.com/bluesky-social/proposals/tree/main/0009-mod-report-granularity#backwards-compatibility 76 + */ 77 + export const OLD_TO_NEW_REASONS_MAP: Record< 78 + Exclude<RootReportDefs.ReasonType, OzoneReportDefs.ReasonType>, 79 + OzoneReportDefs.ReasonType 80 + > = { 81 + [RootReportDefs.REASONSPAM]: [OzoneReportDefs.REASONMISLEADINGSPAM], 82 + [RootReportDefs.REASONVIOLATION]: [OzoneReportDefs.REASONRULEOTHER], 83 + [RootReportDefs.REASONMISLEADING]: [OzoneReportDefs.REASONMISLEADINGOTHER], 84 + [RootReportDefs.REASONSEXUAL]: [OzoneReportDefs.REASONSEXUALUNLABELED], 85 + [RootReportDefs.REASONRUDE]: [OzoneReportDefs.REASONHARASSMENTOTHER], 86 + [RootReportDefs.REASONOTHER]: [OzoneReportDefs.REASONOTHER], 87 + [RootReportDefs.REASONAPPEAL]: [OzoneReportDefs.REASONAPPEAL], 88 + } 89 + 90 + /** 91 + * Set of report reasons that should optionally include additional details from 92 + * the reporter. 93 + */ 94 + export const OTHER_REPORT_REASONS: Set<OzoneReportDefs.ReasonType> = new Set([ 95 + OzoneReportDefs.REASONVIOLENCEOTHER, 96 + OzoneReportDefs.REASONSEXUALOTHER, 97 + OzoneReportDefs.REASONCHILDSAFETYOTHER, 98 + OzoneReportDefs.REASONHARASSMENTOTHER, 99 + OzoneReportDefs.REASONMISLEADINGOTHER, 100 + OzoneReportDefs.REASONRULEOTHER, 101 + OzoneReportDefs.REASONSELFHARMOTHER, 102 + OzoneReportDefs.REASONOTHER, 103 + ]) 104 + 105 + /** 106 + * Set of report reasons that should only be sent to Bluesky's moderation service. 107 + */ 108 + export const BSKY_LABELER_ONLY_REPORT_REASONS: Set<OzoneReportDefs.ReasonType> = 109 + new Set([ 110 + OzoneReportDefs.REASONCHILDSAFETYCSAM, 111 + OzoneReportDefs.REASONCHILDSAFETYGROOM, 112 + OzoneReportDefs.REASONCHILDSAFETYOTHER, 113 + OzoneReportDefs.REASONVIOLENCEEXTREMISTCONTENT, 114 + ])
+14 -4
src/components/moderation/ReportDialog/copy.ts
··· 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 }
··· 38 subtitle: _(msg`Why should this starter pack be reviewed?`), 39 } 40 } 41 + case 'convoMessage': { 42 + switch (subject.view) { 43 + case 'convo': { 44 + return { 45 + title: _(msg`Report this conversation`), 46 + subtitle: _(msg`Why should this conversation be reviewed?`), 47 + } 48 + } 49 + case 'message': { 50 + return { 51 + title: _(msg`Report this message`), 52 + subtitle: _(msg`Why should this message be reviewed?`), 53 + } 54 + } 55 } 56 } 57 }
+244 -96
src/components/moderation/ReportDialog/index.tsx
··· 1 import React from 'react' 2 import {Pressable, View} from 'react-native' 3 import {type ScrollView} from 'react-native-gesture-handler' 4 - import {type AppBskyLabelerDefs} from '@atproto/api' 5 import {msg, Trans} from '@lingui/macro' 6 import {useLingui} from '@lingui/react' 7 ··· 30 import {Loader} from '#/components/Loader' 31 import {Text} from '#/components/Typography' 32 import {useSubmitReportMutation} from './action' 33 - import {SUPPORT_PAGE} from './const' 34 import {useCopyForSubject} from './copy' 35 import {initialState, reducer} from './state' 36 import {type ReportDialogProps, type ReportSubject} from './types' 37 import {parseReportSubject} from './utils/parseReportSubject' 38 - import {type ReportOption, useReportOptions} from './utils/useReportOptions' 39 40 export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 41 ··· 95 } = useMyLabelersQuery({excludeNonConfigurableLabelers: true}) 96 const isLoading = useDelayedLoading(500, isLabelerLoading) 97 const copy = useCopyForSubject(props.subject) 98 - const reportOptions = useReportOptions() 99 const [state, dispatch] = React.useReducer(reducer, initialState) 100 101 /** ··· 105 const [isPending, setPending] = React.useState(false) 106 const [isSuccess, setSuccess] = React.useState(false) 107 108 /** 109 * Labelers that support this `subject` and its NSID collection 110 */ ··· 116 if (subjectTypes === undefined) return true 117 if (props.subject.type === 'account') { 118 return subjectTypes.includes('account') 119 - } else if (props.subject.type === 'chatMessage') { 120 return subjectTypes.includes('chat') 121 } else { 122 return subjectTypes.includes('record') ··· 126 const collections: string[] | undefined = l.subjectCollections 127 if (collections === undefined) return true 128 // all chat collections accepted, since only Bluesky handles chats 129 - if (props.subject.type === 'chatMessage') return true 130 return collections.includes(props.subject.nsid) 131 }) 132 .filter(l => { 133 - if (!state.selectedOption) return true 134 - const reasonTypes: string[] | undefined = l.reasonTypes 135 - if (reasonTypes === undefined) return true 136 - return reasonTypes.includes(state.selectedOption.reason) 137 }) 138 - }, [props, allLabelers, state.selectedOption]) 139 const hasSupportedLabelers = !!supportedLabelers.length 140 const hasSingleSupportedLabeler = supportedLabelers.length === 1 141 142 const onSubmit = React.useCallback(async () => { 143 dispatch({type: 'clearError'}) ··· 166 ) 167 // give time for user feedback 168 setTimeout(() => { 169 - props.control.close() 170 }, 1e3) 171 } catch (e: any) { 172 logger.metric('reportDialog:failure', {}, {statsig: false}) ··· 237 </Admonition.Outer> 238 ) : ( 239 <> 240 - {state.selectedOption ? ( 241 <View style={[a.flex_row, a.align_center, a.gap_md]}> 242 <View style={[a.flex_1]}> 243 - <OptionCard option={state.selectedOption} /> 244 </View> 245 <Button 246 - testID="report:clearOption" 247 - label={_(msg`Change report reason`)} 248 size="tiny" 249 variant="solid" 250 color="secondary" 251 shape="round" 252 onPress={() => { 253 - dispatch({type: 'clearOption'}) 254 }}> 255 <ButtonIcon icon={X} /> 256 </Button> 257 </View> 258 ) : ( 259 <View style={[a.gap_sm]}> 260 - {reportOptions[props.subject.type].map(o => ( 261 - <OptionCard 262 - key={o.reason} 263 option={o} 264 onSelect={() => { 265 - dispatch({type: 'selectOption', option: o}) 266 }} 267 /> 268 ))} ··· 310 <StepOuter> 311 <StepTitle 312 index={2} 313 - title={_(msg`Select moderation service`)} 314 activeIndex1={state.activeStepIndex1} 315 /> 316 - {state.activeStepIndex1 >= 2 && ( 317 - <> 318 - {state.selectedLabeler ? ( 319 - <> 320 - {hasSingleSupportedLabeler ? ( 321 - <LabelerCard labeler={state.selectedLabeler} /> 322 - ) : ( 323 - <View style={[a.flex_row, a.align_center, a.gap_md]}> 324 - <View style={[a.flex_1]}> 325 - <LabelerCard labeler={state.selectedLabeler} /> 326 </View> 327 - <Button 328 - label={_(msg`Change moderation service`)} 329 - size="tiny" 330 - variant="solid" 331 - color="secondary" 332 - shape="round" 333 - onPress={() => { 334 - dispatch({type: 'clearLabeler'}) 335 - }}> 336 - <ButtonIcon icon={X} /> 337 - </Button> 338 - </View> 339 - )} 340 - </> 341 - ) : ( 342 - <> 343 - {hasSupportedLabelers ? ( 344 - <View style={[a.gap_sm]}> 345 - {hasSingleSupportedLabeler ? ( 346 - <> 347 - <LabelerCard labeler={supportedLabelers[0]} /> 348 - <ActionOnce 349 - check={() => !state.selectedLabeler} 350 - callback={() => { 351 - dispatch({ 352 - type: 'selectLabeler', 353 - labeler: supportedLabelers[0], 354 - }) 355 - }} 356 - /> 357 - </> 358 - ) : ( 359 - <> 360 - {supportedLabelers.map(l => ( 361 - <LabelerCard 362 - key={l.creator.did} 363 - labeler={l} 364 - onSelect={() => { 365 - dispatch({type: 'selectLabeler', labeler: l}) 366 }} 367 /> 368 - ))} 369 - </> 370 - )} 371 - </View> 372 - ) : ( 373 - // should never happen in our app 374 - <Admonition.Admonition type="warning"> 375 - <Trans> 376 - Unfortunately, none of your subscribed labelers supports 377 - this report type. 378 - </Trans> 379 - </Admonition.Admonition> 380 - )} 381 - </> 382 - )} 383 - </> 384 - )} 385 - </StepOuter> 386 387 <StepOuter> 388 <StepTitle 389 - index={3} 390 title={_(msg`Submit report`)} 391 - activeIndex1={state.activeStepIndex1} 392 /> 393 - {state.activeStepIndex1 === 3 && ( 394 <> 395 <View style={[a.pb_xs, a.gap_xs]}> 396 <Text style={[a.leading_snug, a.pb_xs]}> ··· 569 ) 570 } 571 572 - function OptionCard({ 573 option, 574 onSelect, 575 }: { 576 - option: ReportOption 577 - onSelect?: (option: ReportOption) => void 578 }) { 579 const t = useTheme() 580 const {_} = useLingui() ··· 584 }, [onSelect, option]) 585 return ( 586 <Button 587 - testID={`report:option:${option.reason}`} 588 label={_(msg`Create report for ${option.title}`)} 589 onPress={onPress} 590 disabled={!onSelect}> ··· 607 <Text 608 style={[a.text_sm, , a.leading_snug, t.atoms.text_contrast_medium]}> 609 {option.description} 610 </Text> 611 </View> 612 )}
··· 1 import React from 'react' 2 import {Pressable, View} from 'react-native' 3 import {type ScrollView} from 'react-native-gesture-handler' 4 + import {type AppBskyLabelerDefs, BSKY_LABELER_DID} from '@atproto/api' 5 import {msg, Trans} from '@lingui/macro' 6 import {useLingui} from '@lingui/react' 7 ··· 30 import {Loader} from '#/components/Loader' 31 import {Text} from '#/components/Typography' 32 import {useSubmitReportMutation} from './action' 33 + import { 34 + BSKY_LABELER_ONLY_REPORT_REASONS, 35 + NEW_TO_OLD_REASONS_MAP, 36 + SUPPORT_PAGE, 37 + } from './const' 38 import {useCopyForSubject} from './copy' 39 import {initialState, reducer} from './state' 40 import {type ReportDialogProps, type ReportSubject} from './types' 41 import {parseReportSubject} from './utils/parseReportSubject' 42 + import { 43 + type ReportCategoryConfig, 44 + type ReportOption, 45 + useReportOptions, 46 + } from './utils/useReportOptions' 47 48 export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 49 ··· 103 } = useMyLabelersQuery({excludeNonConfigurableLabelers: true}) 104 const isLoading = useDelayedLoading(500, isLabelerLoading) 105 const copy = useCopyForSubject(props.subject) 106 + const {categories, getCategory} = useReportOptions() 107 const [state, dispatch] = React.useReducer(reducer, initialState) 108 109 /** ··· 113 const [isPending, setPending] = React.useState(false) 114 const [isSuccess, setSuccess] = React.useState(false) 115 116 + // some reasons ONLY go to Bluesky 117 + const isBskyOnlyReason = state?.selectedOption?.reason 118 + ? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason) 119 + : false 120 + // some subjects (chats) only go to Bluesky 121 + const isBskyOnlySubject = props.subject.type === 'convoMessage' 122 + 123 /** 124 * Labelers that support this `subject` and its NSID collection 125 */ ··· 131 if (subjectTypes === undefined) return true 132 if (props.subject.type === 'account') { 133 return subjectTypes.includes('account') 134 + } else if (props.subject.type === 'convoMessage') { 135 return subjectTypes.includes('chat') 136 } else { 137 return subjectTypes.includes('record') ··· 141 const collections: string[] | undefined = l.subjectCollections 142 if (collections === undefined) return true 143 // all chat collections accepted, since only Bluesky handles chats 144 + if (props.subject.type === 'convoMessage') return true 145 return collections.includes(props.subject.nsid) 146 }) 147 .filter(l => { 148 + if (!state.selectedOption) return false 149 + if (isBskyOnlyReason || isBskyOnlySubject) { 150 + return l.creator.did === BSKY_LABELER_DID 151 + } 152 + const supportedReasonTypes: string[] | undefined = l.reasonTypes 153 + if (supportedReasonTypes === undefined) return true 154 + return ( 155 + // supports new reason type 156 + supportedReasonTypes.includes(state.selectedOption.reason) || 157 + // supports old reason type (backwards compat) 158 + supportedReasonTypes.includes( 159 + NEW_TO_OLD_REASONS_MAP[state.selectedOption.reason], 160 + ) 161 + ) 162 }) 163 + }, [ 164 + props, 165 + allLabelers, 166 + state.selectedOption, 167 + isBskyOnlyReason, 168 + isBskyOnlySubject, 169 + ]) 170 const hasSupportedLabelers = !!supportedLabelers.length 171 const hasSingleSupportedLabeler = supportedLabelers.length === 1 172 + 173 + /** 174 + * We skip the select labeler step if there's only one possible labeler, and 175 + * that labeler is Bluesky (which is the case for chat reports and certain 176 + * reason types). We'll use this below to adjust the indexing and skip the 177 + * step in the UI. 178 + */ 179 + const isAlwaysBskyLabeler = 180 + hasSingleSupportedLabeler && (isBskyOnlyReason || isBskyOnlySubject) 181 182 const onSubmit = React.useCallback(async () => { 183 dispatch({type: 'clearError'}) ··· 206 ) 207 // give time for user feedback 208 setTimeout(() => { 209 + props.control.close(() => { 210 + props.onAfterSubmit?.() 211 + }) 212 }, 1e3) 213 } catch (e: any) { 214 logger.metric('reportDialog:failure', {}, {statsig: false}) ··· 279 </Admonition.Outer> 280 ) : ( 281 <> 282 + {state.selectedCategory ? ( 283 <View style={[a.flex_row, a.align_center, a.gap_md]}> 284 <View style={[a.flex_1]}> 285 + <CategoryCard option={state.selectedCategory} /> 286 </View> 287 <Button 288 + testID="report:clearCategory" 289 + label={_(msg`Change report category`)} 290 size="tiny" 291 variant="solid" 292 color="secondary" 293 shape="round" 294 onPress={() => { 295 + dispatch({type: 'clearCategory'}) 296 }}> 297 <ButtonIcon icon={X} /> 298 </Button> 299 </View> 300 ) : ( 301 <View style={[a.gap_sm]}> 302 + {categories.map(o => ( 303 + <CategoryCard 304 + key={o.key} 305 option={o} 306 onSelect={() => { 307 + dispatch({ 308 + type: 'selectCategory', 309 + option: o, 310 + otherOption: getCategory('other').options[0], 311 + }) 312 }} 313 /> 314 ))} ··· 356 <StepOuter> 357 <StepTitle 358 index={2} 359 + title={_(msg`Select a reason`)} 360 activeIndex1={state.activeStepIndex1} 361 /> 362 + {state.selectedOption ? ( 363 + <View style={[a.flex_row, a.align_center, a.gap_md]}> 364 + <View style={[a.flex_1]}> 365 + <OptionCard option={state.selectedOption} /> 366 + </View> 367 + <Button 368 + testID="report:clearReportOption" 369 + label={_(msg`Change report reason`)} 370 + size="tiny" 371 + variant="solid" 372 + color="secondary" 373 + shape="round" 374 + onPress={() => { 375 + dispatch({type: 'clearOption'}) 376 + }}> 377 + <ButtonIcon icon={X} /> 378 + </Button> 379 + </View> 380 + ) : state.selectedCategory ? ( 381 + <View style={[a.gap_sm]}> 382 + {getCategory(state.selectedCategory.key).options.map(o => ( 383 + <OptionCard 384 + key={o.reason} 385 + option={o} 386 + onSelect={() => { 387 + dispatch({type: 'selectOption', option: o}) 388 + }} 389 + /> 390 + ))} 391 + </View> 392 + ) : null} 393 + </StepOuter> 394 + 395 + {isAlwaysBskyLabeler ? ( 396 + <ActionOnce 397 + check={() => !state.selectedLabeler} 398 + callback={() => { 399 + dispatch({ 400 + type: 'selectLabeler', 401 + labeler: supportedLabelers[0], 402 + }) 403 + }} 404 + /> 405 + ) : ( 406 + <StepOuter> 407 + <StepTitle 408 + index={3} 409 + title={_(msg`Select moderation service`)} 410 + activeIndex1={state.activeStepIndex1} 411 + /> 412 + {state.activeStepIndex1 >= 3 && ( 413 + <> 414 + {state.selectedLabeler ? ( 415 + <> 416 + {hasSingleSupportedLabeler ? ( 417 + <LabelerCard labeler={state.selectedLabeler} /> 418 + ) : ( 419 + <View style={[a.flex_row, a.align_center, a.gap_md]}> 420 + <View style={[a.flex_1]}> 421 + <LabelerCard labeler={state.selectedLabeler} /> 422 + </View> 423 + <Button 424 + label={_(msg`Change moderation service`)} 425 + size="tiny" 426 + variant="solid" 427 + color="secondary" 428 + shape="round" 429 + onPress={() => { 430 + dispatch({type: 'clearLabeler'}) 431 + }}> 432 + <ButtonIcon icon={X} /> 433 + </Button> 434 </View> 435 + )} 436 + </> 437 + ) : ( 438 + <> 439 + {hasSupportedLabelers ? ( 440 + <View style={[a.gap_sm]}> 441 + {hasSingleSupportedLabeler ? ( 442 + <> 443 + <LabelerCard labeler={supportedLabelers[0]} /> 444 + <ActionOnce 445 + check={() => !state.selectedLabeler} 446 + callback={() => { 447 + dispatch({ 448 + type: 'selectLabeler', 449 + labeler: supportedLabelers[0], 450 + }) 451 }} 452 /> 453 + </> 454 + ) : ( 455 + <> 456 + {supportedLabelers.map(l => ( 457 + <LabelerCard 458 + key={l.creator.did} 459 + labeler={l} 460 + onSelect={() => { 461 + dispatch({type: 'selectLabeler', labeler: l}) 462 + }} 463 + /> 464 + ))} 465 + </> 466 + )} 467 + </View> 468 + ) : ( 469 + // should never happen in our app 470 + <Admonition.Admonition type="warning"> 471 + <Trans> 472 + Unfortunately, none of your subscribed labelers 473 + supports this report type. 474 + </Trans> 475 + </Admonition.Admonition> 476 + )} 477 + </> 478 + )} 479 + </> 480 + )} 481 + </StepOuter> 482 + )} 483 484 <StepOuter> 485 <StepTitle 486 + index={isAlwaysBskyLabeler ? 3 : 4} 487 title={_(msg`Submit report`)} 488 + activeIndex1={ 489 + isAlwaysBskyLabeler 490 + ? state.activeStepIndex1 - 1 491 + : state.activeStepIndex1 492 + } 493 /> 494 + {state.activeStepIndex1 === 4 && ( 495 <> 496 <View style={[a.pb_xs, a.gap_xs]}> 497 <Text style={[a.leading_snug, a.pb_xs]}> ··· 670 ) 671 } 672 673 + function CategoryCard({ 674 option, 675 onSelect, 676 }: { 677 + option: ReportCategoryConfig 678 + onSelect?: (option: ReportCategoryConfig) => void 679 }) { 680 const t = useTheme() 681 const {_} = useLingui() ··· 685 }, [onSelect, option]) 686 return ( 687 <Button 688 + testID={`report:option:${option.title}`} 689 label={_(msg`Create report for ${option.title}`)} 690 onPress={onPress} 691 disabled={!onSelect}> ··· 708 <Text 709 style={[a.text_sm, , a.leading_snug, t.atoms.text_contrast_medium]}> 710 {option.description} 711 + </Text> 712 + </View> 713 + )} 714 + </Button> 715 + ) 716 + } 717 + 718 + function OptionCard({ 719 + option, 720 + onSelect, 721 + }: { 722 + option: ReportOption 723 + onSelect?: (option: ReportOption) => void 724 + }) { 725 + const t = useTheme() 726 + const {_} = useLingui() 727 + const gutters = useGutters(['compact']) 728 + const onPress = React.useCallback(() => { 729 + onSelect?.(option) 730 + }, [onSelect, option]) 731 + return ( 732 + <Button 733 + testID={`report:option:${option.title}`} 734 + label={_( 735 + msg({ 736 + message: `Create report for ${option.title}`, 737 + comment: 738 + 'Accessibility label for button to create a moderation report for the selected option', 739 + }), 740 + )} 741 + onPress={onPress} 742 + disabled={!onSelect}> 743 + {({hovered, pressed}) => ( 744 + <View 745 + style={[ 746 + a.w_full, 747 + gutters, 748 + a.py_sm, 749 + a.rounded_sm, 750 + a.border, 751 + t.atoms.bg_contrast_25, 752 + hovered || pressed 753 + ? [t.atoms.border_contrast_high] 754 + : [t.atoms.border_contrast_low], 755 + ]}> 756 + <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 757 + {option.title} 758 </Text> 759 </View> 760 )}
+42 -15
src/components/moderation/ReportDialog/state.ts
··· 1 - import {type AppBskyLabelerDefs, ComAtprotoModerationDefs} from '@atproto/api' 2 3 - import {type ReportOption} from './utils/useReportOptions' 4 5 export type ReportState = { 6 selectedOption?: ReportOption 7 selectedLabeler?: AppBskyLabelerDefs.LabelerViewDetailed 8 details?: string ··· 13 14 export type ReportAction = 15 | { 16 type: 'selectOption' 17 option: ReportOption 18 } ··· 42 } 43 44 export const initialState: ReportState = { 45 selectedOption: undefined, 46 selectedLabeler: undefined, 47 details: undefined, ··· 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 {
··· 1 + import {type AppBskyLabelerDefs} from '@atproto/api' 2 3 + import {OTHER_REPORT_REASONS} from '#/components/moderation/ReportDialog/const' 4 + import { 5 + type ReportCategoryConfig, 6 + type ReportOption, 7 + } from '#/components/moderation/ReportDialog/utils/useReportOptions' 8 9 export type ReportState = { 10 + selectedCategory?: ReportCategoryConfig 11 selectedOption?: ReportOption 12 selectedLabeler?: AppBskyLabelerDefs.LabelerViewDetailed 13 details?: string ··· 18 19 export type ReportAction = 20 | { 21 + type: 'selectCategory' 22 + option: ReportCategoryConfig 23 + otherOption: ReportOption 24 + } 25 + | { 26 + type: 'clearCategory' 27 + } 28 + | { 29 type: 'selectOption' 30 option: ReportOption 31 } ··· 55 } 56 57 export const initialState: ReportState = { 58 + selectedCategory: undefined, 59 selectedOption: undefined, 60 selectedLabeler: undefined, 61 details: undefined, ··· 65 66 export function reducer(state: ReportState, action: ReportAction): ReportState { 67 switch (action.type) { 68 + case 'selectCategory': 69 + return { 70 + ...state, 71 + selectedCategory: action.option, 72 + activeStepIndex1: action.option.key === 'other' ? 3 : 2, 73 + selectedOption: 74 + action.option.key === 'other' ? action.otherOption : undefined, 75 + } 76 + case 'clearCategory': 77 + return { 78 + ...state, 79 + selectedCategory: undefined, 80 + selectedOption: undefined, 81 + selectedLabeler: undefined, 82 + activeStepIndex1: 1, 83 + detailsOpen: false, 84 + } 85 case 'selectOption': 86 return { 87 ...state, 88 selectedOption: action.option, 89 + activeStepIndex1: 3, 90 + detailsOpen: OTHER_REPORT_REASONS.has(action.option.reason), 91 } 92 case 'clearOption': 93 return { 94 ...state, 95 selectedOption: undefined, 96 selectedLabeler: undefined, 97 + activeStepIndex1: 2, 98 + detailsOpen: false, 99 } 100 case 'selectLabeler': 101 return { 102 ...state, 103 selectedLabeler: action.labeler, 104 + activeStepIndex1: 4, 105 + detailsOpen: state.selectedOption 106 + ? OTHER_REPORT_REASONS.has(state.selectedOption?.reason) 107 + : false, 108 } 109 case 'clearLabeler': 110 return { 111 ...state, 112 selectedLabeler: undefined, 113 + activeStepIndex1: 3, 114 } 115 case 'setDetails': 116 return {
+14 -6
src/components/moderation/ReportDialog/types.ts
··· 8 9 import type * as Dialog from '#/components/Dialog' 10 11 export type ReportSubject = 12 | $Typed<AppBskyActorDefs.ProfileViewBasic> 13 | $Typed<AppBskyActorDefs.ProfileView> ··· 16 | $Typed<AppBskyFeedDefs.GeneratorView> 17 | $Typed<AppBskyGraphDefs.StarterPackView> 18 | $Typed<AppBskyFeedDefs.PostView> 19 - | {convoId: string; message: ChatBskyConvoDefs.MessageView} 20 21 export type ParsedReportSubject = 22 | { ··· 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 }
··· 8 9 import type * as Dialog from '#/components/Dialog' 10 11 + export type ReportSubjectConvo = { 12 + view: 'convo' | 'message' 13 + convoId: string 14 + message: ChatBskyConvoDefs.MessageView 15 + } 16 + 17 export type ReportSubject = 18 | $Typed<AppBskyActorDefs.ProfileViewBasic> 19 | $Typed<AppBskyActorDefs.ProfileView> ··· 22 | $Typed<AppBskyFeedDefs.GeneratorView> 23 | $Typed<AppBskyGraphDefs.StarterPackView> 24 | $Typed<AppBskyFeedDefs.PostView> 25 + | ReportSubjectConvo 26 27 export type ParsedReportSubject = 28 | { ··· 61 did: string 62 nsid: string 63 } 64 + | ({ 65 + type: 'convoMessage' 66 + } & ReportSubjectConvo) 67 68 export type ReportDialogProps = { 69 control: Dialog.DialogOuterProps['control'] 70 subject: ParsedReportSubject 71 + /** 72 + * Called if the report was successfully submitted. 73 + */ 74 + onAfterSubmit?: () => void 75 }
+1 -1
src/components/moderation/ReportDialog/utils/parseReportSubject.ts
··· 18 19 if ('convoId' in subject) { 20 return { 21 - type: 'chatMessage', 22 ...subject, 23 } 24 }
··· 18 19 if ('convoId' in subject) { 20 return { 21 + type: 'convoMessage', 22 ...subject, 23 } 24 }
+237 -102
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 }
··· 1 import {useMemo} from 'react' 2 + import {ToolsOzoneReportDefs as OzoneReportDefs} from '@atproto/api' 3 import {msg} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 + export type ReportCategory = 7 + | 'childSafety' 8 + | 'violencePhysicalHarm' 9 + | 'sexualAdultContent' 10 + | 'harassmentHate' 11 + | 'misleading' 12 + | 'ruleBreaking' 13 + | 'selfHarm' 14 + | 'other' 15 + 16 + export type ReportCategoryConfig = { 17 + key: ReportCategory 18 title: string 19 description: string 20 + options: ReportOption[] 21 } 22 23 + export type ReportOption = { 24 + title: string 25 + reason: OzoneReportDefs.ReasonType 26 } 27 28 + export function useReportOptions() { 29 const {_} = useLingui() 30 31 return useMemo(() => { 32 + const categories: Record<ReportCategory, ReportCategoryConfig> = { 33 + misleading: { 34 + key: 'misleading', 35 + title: _(msg`Misleading`), 36 + description: _(msg`Spam or other inauthentic behavior or deception`), 37 + options: [ 38 + { 39 + title: _(msg`Spam`), 40 + reason: OzoneReportDefs.REASONMISLEADINGSPAM, 41 + }, 42 + { 43 + title: _(msg`Scam`), 44 + reason: OzoneReportDefs.REASONMISLEADINGSCAM, 45 + }, 46 + { 47 + title: _(msg`Fake account or bot`), 48 + reason: OzoneReportDefs.REASONMISLEADINGBOT, 49 + }, 50 + { 51 + title: _(msg`Impersonation`), 52 + reason: OzoneReportDefs.REASONMISLEADINGIMPERSONATION, 53 + }, 54 + { 55 + title: _(msg`False information about elections`), 56 + reason: OzoneReportDefs.REASONMISLEADINGELECTIONS, 57 + }, 58 + { 59 + title: _(msg`Other misleading content`), 60 + reason: OzoneReportDefs.REASONMISLEADINGOTHER, 61 + }, 62 + ], 63 + }, 64 + sexualAdultContent: { 65 + key: 'sexualAdultContent', 66 + title: _(msg`Adult content`), 67 + description: _( 68 + msg`Unlabeled, abusive, or non-consensual adult content`, 69 + ), 70 + options: [ 71 + { 72 + title: _(msg`Unlabeled adult content`), 73 + reason: OzoneReportDefs.REASONSEXUALUNLABELED, 74 + }, 75 + { 76 + title: _(msg`Adult sexual abuse content`), 77 + reason: OzoneReportDefs.REASONSEXUALABUSECONTENT, 78 + }, 79 + { 80 + title: _(msg`Non-consensual intimate imagery`), 81 + reason: OzoneReportDefs.REASONSEXUALNCII, 82 + }, 83 + { 84 + title: _(msg`Deepfake adult content`), 85 + reason: OzoneReportDefs.REASONSEXUALDEEPFAKE, 86 + }, 87 + { 88 + title: _(msg`Animal sexual abuse`), 89 + reason: OzoneReportDefs.REASONSEXUALANIMAL, 90 + }, 91 + { 92 + title: _(msg`Other sexual violence content`), 93 + reason: OzoneReportDefs.REASONSEXUALOTHER, 94 + }, 95 + ], 96 + }, 97 + harassmentHate: { 98 + key: 'harassmentHate', 99 + title: _(msg`Harassment or hate`), 100 + description: _(msg`Abusive or discriminatory behavior`), 101 + options: [ 102 + { 103 + title: _(msg`Trolling`), 104 + reason: OzoneReportDefs.REASONHARASSMENTTROLL, 105 + }, 106 + { 107 + title: _(msg`Targeted harassment`), 108 + reason: OzoneReportDefs.REASONHARASSMENTTARGETED, 109 + }, 110 + { 111 + title: _(msg`Hate speech`), 112 + reason: OzoneReportDefs.REASONHARASSMENTHATESPEECH, 113 + }, 114 + { 115 + title: _(msg`Doxxing`), 116 + reason: OzoneReportDefs.REASONHARASSMENTDOXXING, 117 + }, 118 + { 119 + title: _(msg`Other harassing or hateful content`), 120 + reason: OzoneReportDefs.REASONHARASSMENTOTHER, 121 + }, 122 + ], 123 + }, 124 + violencePhysicalHarm: { 125 + key: 'violencePhysicalHarm', 126 + title: _(msg`Violence`), 127 + description: _(msg`Violent or threatening content`), 128 + options: [ 129 + { 130 + title: _(msg`Animal welfare`), 131 + reason: OzoneReportDefs.REASONVIOLENCEANIMAL, 132 + }, 133 + { 134 + title: _(msg`Threats or incitement`), 135 + reason: OzoneReportDefs.REASONVIOLENCETHREATS, 136 + }, 137 + { 138 + title: _(msg`Graphic violent content`), 139 + reason: OzoneReportDefs.REASONVIOLENCEGRAPHICCONTENT, 140 + }, 141 + { 142 + title: _(msg`Glorification of violence`), 143 + reason: OzoneReportDefs.REASONVIOLENCEGLORIFICATION, 144 + }, 145 + { 146 + title: _(msg`Extremist content`), 147 + reason: OzoneReportDefs.REASONVIOLENCEEXTREMISTCONTENT, 148 + }, 149 + { 150 + title: _(msg`Human trafficking`), 151 + reason: OzoneReportDefs.REASONVIOLENCETRAFFICKING, 152 + }, 153 + { 154 + title: _(msg`Other violent content`), 155 + reason: OzoneReportDefs.REASONVIOLENCEOTHER, 156 + }, 157 + ], 158 + }, 159 + childSafety: { 160 + key: 'childSafety', 161 + title: _(msg`Child safety`), 162 + description: _(msg`Harming or endangering minors`), 163 + options: [ 164 + { 165 + title: _(msg`Child Sexual Abuse Material (CSAM)`), 166 + reason: OzoneReportDefs.REASONCHILDSAFETYCSAM, 167 + }, 168 + { 169 + title: _(msg`Grooming or predatory behavior`), 170 + reason: OzoneReportDefs.REASONCHILDSAFETYGROOM, 171 + }, 172 + { 173 + title: _(msg`Privacy violation of a minor`), 174 + reason: OzoneReportDefs.REASONCHILDSAFETYPRIVACY, 175 + }, 176 + { 177 + title: _(msg`Minor harassment or bullying`), 178 + reason: OzoneReportDefs.REASONCHILDSAFETYHARASSMENT, 179 + }, 180 + { 181 + title: _(msg`Other child safety issue`), 182 + reason: OzoneReportDefs.REASONCHILDSAFETYOTHER, 183 + }, 184 + ], 185 + }, 186 + selfHarm: { 187 + key: 'selfHarm', 188 + title: _(msg`Self-harm or dangerous behaviors`), 189 + description: _(msg`Harmful or high-risk activities`), 190 + options: [ 191 + { 192 + title: _(msg`Content promoting or depicting self-harm`), 193 + reason: OzoneReportDefs.REASONSELFHARMCONTENT, 194 + }, 195 + { 196 + title: _(msg`Eating disorders`), 197 + reason: OzoneReportDefs.REASONSELFHARMED, 198 + }, 199 + { 200 + title: _(msg`Dangerous challenges or activities`), 201 + reason: OzoneReportDefs.REASONSELFHARMSTUNTS, 202 + }, 203 + { 204 + title: _(msg`Dangerous substances or drug abuse`), 205 + reason: OzoneReportDefs.REASONSELFHARMSUBSTANCES, 206 + }, 207 + { 208 + title: _(msg`Other dangerous content`), 209 + reason: OzoneReportDefs.REASONSELFHARMOTHER, 210 + }, 211 + ], 212 + }, 213 + ruleBreaking: { 214 + key: 'ruleBreaking', 215 + title: _(msg`Breaking site rules`), 216 + description: _(msg`Banned activities or security violations`), 217 + options: [ 218 + { 219 + title: _(msg`Hacking or system attacks`), 220 + reason: OzoneReportDefs.REASONRULESITESECURITY, 221 + }, 222 + { 223 + title: _(msg`Promoting or selling prohibited items or services`), 224 + reason: OzoneReportDefs.REASONRULEPROHIBITEDSALES, 225 + }, 226 + { 227 + title: _(msg`Banned user returning`), 228 + reason: OzoneReportDefs.REASONRULEBANEVASION, 229 + }, 230 + { 231 + title: _(msg`Other network rule-breaking`), 232 + reason: OzoneReportDefs.REASONRULEOTHER, 233 + }, 234 + ], 235 }, 236 + other: { 237 + key: 'other', 238 + title: _(msg`Other`), 239 + description: _(msg`An issue not included in these options`), 240 + options: [ 241 + { 242 + title: _(msg`Other`), 243 + reason: OzoneReportDefs.REASONOTHER, 244 + }, 245 + ], 246 }, 247 + } 248 + 249 return { 250 + categories: Object.values(categories) as ReportCategoryConfig[], 251 + getCategory(reasonName: ReportCategory) { 252 + return categories[reasonName] 253 + }, 254 } 255 }, [_]) 256 }
+1 -1
src/locale/locales/en/messages.po
··· 1942 msgstr "" 1943 1944 #: src/components/Dialog/index.web.tsx:118 1945 - #: src/components/Dialog/index.web.tsx:295 1946 msgid "Close active dialog" 1947 msgstr "" 1948
··· 1942 msgstr "" 1943 1944 #: src/components/Dialog/index.web.tsx:118 1945 + #: src/components/Dialog/index.web.tsx:296 1946 msgid "Close active dialog" 1947 msgstr "" 1948
+24 -10
src/screens/Messages/components/RequestButtons.tsx
··· 25 EmailDialogScreenID, 26 useEmailDialogControl, 27 } from '#/components/dialogs/EmailDialog' 28 - import {ReportDialog} from '#/components/dms/ReportDialog' 29 import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX' 30 import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' 31 import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' 32 import {Loader} from '#/components/Loader' 33 import * as Menu from '#/components/Menu' 34 35 export function RejectMenu({ 36 convo, ··· 100 }, [queueBlock, leaveConvo, _]) 101 102 const reportControl = useDialogControl() 103 104 const lastMessage = ChatBskyConvoDefs.isMessageView(convo.lastMessage) 105 ? convo.lastMessage ··· 162 </Menu.Outer> 163 </Menu.Root> 164 {lastMessage && ( 165 - <ReportDialog 166 - currentScreen={currentScreen} 167 - params={{ 168 - type: 'convoMessage', 169 - convoId: convo.id, 170 - message: lastMessage, 171 - }} 172 - control={reportControl} 173 - /> 174 )} 175 </> 176 )
··· 25 EmailDialogScreenID, 26 useEmailDialogControl, 27 } from '#/components/dialogs/EmailDialog' 28 + import {AfterReportDialog} from '#/components/dms/AfterReportDialog' 29 import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX' 30 import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' 31 import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' 32 import {Loader} from '#/components/Loader' 33 import * as Menu from '#/components/Menu' 34 + import {ReportDialog} from '#/components/moderation/ReportDialog' 35 36 export function RejectMenu({ 37 convo, ··· 101 }, [queueBlock, leaveConvo, _]) 102 103 const reportControl = useDialogControl() 104 + const blockOrDeleteControl = useDialogControl() 105 106 const lastMessage = ChatBskyConvoDefs.isMessageView(convo.lastMessage) 107 ? convo.lastMessage ··· 164 </Menu.Outer> 165 </Menu.Root> 166 {lastMessage && ( 167 + <> 168 + <ReportDialog 169 + subject={{ 170 + view: 'convo', 171 + convoId: convo.id, 172 + message: lastMessage, 173 + }} 174 + control={reportControl} 175 + onAfterSubmit={() => { 176 + blockOrDeleteControl.open() 177 + }} 178 + /> 179 + <AfterReportDialog 180 + control={blockOrDeleteControl} 181 + currentScreen={currentScreen} 182 + params={{ 183 + convoId: convo.id, 184 + message: lastMessage, 185 + }} 186 + /> 187 + </> 188 )} 189 </> 190 )
+6 -3
src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
··· 1 import {useMemo} from 'react' 2 import {View} from 'react-native' 3 import {type AppBskyNotificationDefs} from '@atproto/api' 4 - import {type FilterablePreference} from '@atproto/api/dist/client/types/app/bsky/notification/defs' 5 import {msg, Trans} from '@lingui/macro' 6 import {useLingui} from '@lingui/react' 7 ··· 25 * which groups starterpack joins + verified + unverified notifications into a single toggle. 26 */ 27 syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[] 28 - preference?: AppBskyNotificationDefs.Preference | FilterablePreference 29 allowDisableInApp?: boolean 30 }) { 31 if (!preference) ··· 53 }: { 54 name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'> 55 syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[] 56 - preference: AppBskyNotificationDefs.Preference | FilterablePreference 57 allowDisableInApp: boolean 58 }) { 59 const t = useTheme()
··· 1 import {useMemo} from 'react' 2 import {View} from 'react-native' 3 import {type AppBskyNotificationDefs} from '@atproto/api' 4 import {msg, Trans} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' 6 ··· 24 * which groups starterpack joins + verified + unverified notifications into a single toggle. 25 */ 26 syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[] 27 + preference?: 28 + | AppBskyNotificationDefs.Preference 29 + | AppBskyNotificationDefs.FilterablePreference 30 allowDisableInApp?: boolean 31 }) { 32 if (!preference) ··· 54 }: { 55 name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'> 56 syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[] 57 + preference: 58 + | AppBskyNotificationDefs.Preference 59 + | AppBskyNotificationDefs.FilterablePreference 60 allowDisableInApp: boolean 61 }) { 62 const t = useTheme()
+1 -30
src/screens/Settings/ThreadPreferences.tsx
··· 14 import {atoms as a, useTheme} from '#/alf' 15 import * as Toggle from '#/components/forms/Toggle' 16 import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' 17 - import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 18 import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree' 19 import * as Layout from '#/components/Layout' 20 import {Text} from '#/components/Typography' ··· 24 export function ThreadPreferencesScreen({}: Props) { 25 const t = useTheme() 26 const {_} = useLingui() 27 - const { 28 - sort, 29 - setSort, 30 - view, 31 - setView, 32 - prioritizeFollowedUsers, 33 - setPrioritizeFollowedUsers, 34 - } = useThreadPreferences({save: true}) 35 36 return ( 37 <Layout.Screen testID="threadPreferencesScreen"> ··· 86 </View> 87 </Toggle.Group> 88 </View> 89 - </SettingsList.Group> 90 - 91 - <SettingsList.Group contentContainerStyle={{minHeight: 0}}> 92 - <SettingsList.ItemIcon icon={PersonGroupIcon} /> 93 - <SettingsList.ItemText> 94 - <Trans>Prioritize your Follows</Trans> 95 - </SettingsList.ItemText> 96 - <Toggle.Item 97 - type="checkbox" 98 - name="prioritize-follows" 99 - label={_(msg`Prioritize your Follows`)} 100 - value={prioritizeFollowedUsers} 101 - onChange={value => setPrioritizeFollowedUsers(value)} 102 - style={[a.w_full, a.gap_md]}> 103 - <Toggle.LabelText style={[a.flex_1]}> 104 - <Trans> 105 - Show replies by people you follow before all other replies 106 - </Trans> 107 - </Toggle.LabelText> 108 - <Toggle.Platform /> 109 - </Toggle.Item> 110 </SettingsList.Group> 111 112 <SettingsList.Group>
··· 14 import {atoms as a, useTheme} from '#/alf' 15 import * as Toggle from '#/components/forms/Toggle' 16 import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' 17 import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree' 18 import * as Layout from '#/components/Layout' 19 import {Text} from '#/components/Typography' ··· 23 export function ThreadPreferencesScreen({}: Props) { 24 const t = useTheme() 25 const {_} = useLingui() 26 + const {sort, setSort, view, setView} = useThreadPreferences({save: true}) 27 28 return ( 29 <Layout.Screen testID="threadPreferencesScreen"> ··· 78 </View> 79 </Toggle.Group> 80 </View> 81 </SettingsList.Group> 82 83 <SettingsList.Group>
-1
src/state/queries/preferences/const.ts
··· 16 17 export const DEFAULT_THREAD_VIEW_PREFS: ThreadViewPreferences = { 18 sort: 'hotness', 19 - prioritizeFollowedUsers: true, 20 lab_treeViewEnabled: false, 21 } 22
··· 16 17 export const DEFAULT_THREAD_VIEW_PREFS: ThreadViewPreferences = { 18 sort: 'hotness', 19 lab_treeViewEnabled: false, 20 } 21
+2 -9
src/state/queries/preferences/types.ts
··· 1 - import { 2 - type BskyFeedViewPreference, 3 - type BskyPreferences, 4 - type BskyThreadViewPreference, 5 - } from '@atproto/api' 6 7 export type UsePreferencesQueryResponse = Omit< 8 BskyPreferences, ··· 18 userAge: number | undefined 19 } 20 21 - export type ThreadViewPreferences = Pick< 22 - BskyThreadViewPreference, 23 - 'prioritizeFollowedUsers' 24 - > & { 25 sort: 'hotness' | 'oldest' | 'newest' | 'most-likes' | 'random' | string 26 lab_treeViewEnabled?: boolean 27 }
··· 1 + import {type BskyFeedViewPreference, type BskyPreferences} from '@atproto/api' 2 3 export type UsePreferencesQueryResponse = Omit< 4 BskyPreferences, ··· 14 userAge: number | undefined 15 } 16 17 + export type ThreadViewPreferences = { 18 sort: 'hotness' | 'oldest' | 'newest' | 'most-likes' | 'random' | string 19 lab_treeViewEnabled?: boolean 20 }
+1 -28
src/state/queries/preferences/useThreadPreferences.ts
··· 23 setSort: (sort: string) => void 24 view: ThreadViewOption 25 setView: (view: ThreadViewOption) => void 26 - prioritizeFollowedUsers: boolean 27 - setPrioritizeFollowedUsers: (prioritize: boolean) => void 28 } 29 30 export function useThreadPreferences({ ··· 43 treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled, 44 }), 45 ) 46 - const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState( 47 - !!serverPrefs?.prioritizeFollowedUsers, 48 - ) 49 50 /** 51 * If we get a server update, update local state ··· 59 * Update 60 */ 61 setSort(normalizeSort(serverPrefs.sort)) 62 - setPrioritizeFollowedUsers(serverPrefs.prioritizeFollowedUsers) 63 setView( 64 normalizeView({ 65 treeViewEnabled: !!serverPrefs.lab_treeViewEnabled, ··· 70 logger.metric('thread:preferences:load', { 71 sort: serverPrefs.sort, 72 view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear', 73 - prioritizeFollowedUsers: serverPrefs.prioritizeFollowedUsers, 74 }) 75 }) 76 } ··· 86 logger.metric('thread:preferences:update', { 87 sort: prefs.sort, 88 view: prefs.lab_treeViewEnabled ? 'tree' : 'linear', 89 - prioritizeFollowedUsers: prefs.prioritizeFollowedUsers, 90 }) 91 } catch (e) { 92 logger.error('useThreadPreferences failed to save', { ··· 101 if (save && userUpdatedPrefs.current) { 102 savePrefs({ 103 sort, 104 - prioritizeFollowedUsers, 105 lab_treeViewEnabled: view === 'tree', 106 }) 107 userUpdatedPrefs.current = false ··· 121 }, 122 [setView], 123 ) 124 - const setPrioritizeFollowedUsersWrapped = useCallback( 125 - (next: boolean) => { 126 - userUpdatedPrefs.current = true 127 - setPrioritizeFollowedUsers(next) 128 - }, 129 - [setPrioritizeFollowedUsers], 130 - ) 131 132 return useMemo( 133 () => ({ ··· 137 setSort: setSortWrapped, 138 view, 139 setView: setViewWrapped, 140 - prioritizeFollowedUsers, 141 - setPrioritizeFollowedUsers: setPrioritizeFollowedUsersWrapped, 142 }), 143 - [ 144 - isLoaded, 145 - isSaving, 146 - sort, 147 - setSortWrapped, 148 - view, 149 - setViewWrapped, 150 - prioritizeFollowedUsers, 151 - setPrioritizeFollowedUsersWrapped, 152 - ], 153 ) 154 } 155
··· 23 setSort: (sort: string) => void 24 view: ThreadViewOption 25 setView: (view: ThreadViewOption) => void 26 } 27 28 export function useThreadPreferences({ ··· 41 treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled, 42 }), 43 ) 44 45 /** 46 * If we get a server update, update local state ··· 54 * Update 55 */ 56 setSort(normalizeSort(serverPrefs.sort)) 57 setView( 58 normalizeView({ 59 treeViewEnabled: !!serverPrefs.lab_treeViewEnabled, ··· 64 logger.metric('thread:preferences:load', { 65 sort: serverPrefs.sort, 66 view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear', 67 }) 68 }) 69 } ··· 79 logger.metric('thread:preferences:update', { 80 sort: prefs.sort, 81 view: prefs.lab_treeViewEnabled ? 'tree' : 'linear', 82 }) 83 } catch (e) { 84 logger.error('useThreadPreferences failed to save', { ··· 93 if (save && userUpdatedPrefs.current) { 94 savePrefs({ 95 sort, 96 lab_treeViewEnabled: view === 'tree', 97 }) 98 userUpdatedPrefs.current = false ··· 112 }, 113 [setView], 114 ) 115 116 return useMemo( 117 () => ({ ··· 121 setSort: setSortWrapped, 122 view, 123 setView: setViewWrapped, 124 }), 125 + [isLoaded, isSaving, sort, setSortWrapped, view, setViewWrapped], 126 ) 127 } 128
-5
src/state/queries/usePostThread/index.ts
··· 49 setSort: baseSetSort, 50 view, 51 setView: baseSetView, 52 - prioritizeFollowedUsers, 53 } = useThreadPreferences() 54 const below = useMemo(() => { 55 return view === 'linear' ··· 63 anchor, 64 sort, 65 view, 66 - prioritizeFollowedUsers, 67 }) 68 const postThreadOtherQueryKey = createPostThreadOtherQueryKey({ 69 anchor, 70 - prioritizeFollowedUsers, 71 }) 72 73 const query = useQuery<UsePostThreadQueryResult>({ ··· 79 branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF, 80 below, 81 sort: sort, 82 - prioritizeFollowedUsers: prioritizeFollowedUsers, 83 }) 84 85 /* ··· 167 async queryFn() { 168 const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({ 169 anchor: anchor!, 170 - prioritizeFollowedUsers, 171 }) 172 return data 173 },
··· 49 setSort: baseSetSort, 50 view, 51 setView: baseSetView, 52 } = useThreadPreferences() 53 const below = useMemo(() => { 54 return view === 'linear' ··· 62 anchor, 63 sort, 64 view, 65 }) 66 const postThreadOtherQueryKey = createPostThreadOtherQueryKey({ 67 anchor, 68 }) 69 70 const query = useQuery<UsePostThreadQueryResult>({ ··· 76 branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF, 77 below, 78 sort: sort, 79 }) 80 81 /* ··· 163 async queryFn() { 164 const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({ 165 anchor: anchor!, 166 }) 167 return data 168 },
+1 -1
src/state/queries/usePostThread/types.ts
··· 25 26 export type PostThreadParams = Pick< 27 AppBskyUnspeccedGetPostThreadV2.QueryParams, 28 - 'sort' | 'prioritizeFollowedUsers' 29 > & { 30 anchor?: string 31 view: 'tree' | 'linear'
··· 25 26 export type PostThreadParams = Pick< 27 AppBskyUnspeccedGetPostThreadV2.QueryParams, 28 + 'sort' 29 > & { 30 anchor?: string 31 view: 'tree' | 'linear'
+1 -1
src/view/com/composer/Composer.tsx
··· 580 label={_(msg`View post`)} 581 onPress={() => { 582 const {host: name, rkey} = new AtUri(postUri) 583 - navigation.push('PostThread', {name, rkey}) 584 }}> 585 <Trans context="Action to view the post the user just created"> 586 View
··· 580 label={_(msg`View post`)} 581 onPress={() => { 582 const {host: name, rkey} = new AtUri(postUri) 583 + navigation.navigate('PostThread', {name, rkey}) 584 }}> 585 <Trans context="Action to view the post the user just created"> 586 View
+4 -4
yarn.lock
··· 84 tlds "^1.234.0" 85 zod "^3.23.8" 86 87 - "@atproto/api@^0.17.6": 88 - version "0.17.6" 89 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.17.6.tgz#1fccd939f5f1010397c4d57110b1a0d8673058a6" 90 - integrity sha512-0iYCD8+LOsHjHjwJcqGPfJN/h4b+IpU3GjOV0TSLk0XdCaxpHBKNu3wgCJVst4DhVjXcgsr2qQoRZ3Jja2LupA== 91 dependencies: 92 "@atproto/common-web" "^0.4.3" 93 "@atproto/lexicon" "^0.5.1"
··· 84 tlds "^1.234.0" 85 zod "^3.23.8" 86 87 + "@atproto/api@^0.18.0": 88 + version "0.18.0" 89 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.0.tgz#d8c54ddc4521d915f0af238a4bfebd119e18197f" 90 + integrity sha512-2GxKPhhvMocDjRU7VpNj+cvCdmCHVAmRwyfNgRLMrJtPZvrosFoi9VATX+7eKN0FZvYvy8KdLSkCcpP2owH3IA== 91 dependencies: 92 "@atproto/common-web" "^0.4.3" 93 "@atproto/lexicon" "^0.5.1"