Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 351 lines 10 kB view raw
1import React, {useState} from 'react' 2import {View} from 'react-native' 3import {type ComAtprotoLabelDefs, ToolsOzoneReportDefs} from '@atproto/api' 4import {XRPCError} from '@atproto/xrpc' 5import {msg} from '@lingui/core/macro' 6import {useLingui} from '@lingui/react' 7import {Trans} from '@lingui/react/macro' 8import {useMutation} from '@tanstack/react-query' 9 10import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 11import {useLabelSubject} from '#/lib/moderation' 12import {useLabelInfo} from '#/lib/moderation/useLabelInfo' 13import {makeProfileLink} from '#/lib/routes/links' 14import {sanitizeHandle} from '#/lib/strings/handles' 15import {logger} from '#/logger' 16import {useAgent, useSession} from '#/state/session' 17import * as Toast from '#/view/com/util/Toast' 18import {atoms as a, useBreakpoints, useTheme} from '#/alf' 19import {Button, ButtonIcon, ButtonText} from '#/components/Button' 20import * as Dialog from '#/components/Dialog' 21import {InlineLinkText} from '#/components/Link' 22import {Text} from '#/components/Typography' 23import {IS_ANDROID} from '#/env' 24import {Admonition} from '../Admonition' 25import {Divider} from '../Divider' 26import {Loader} from '../Loader' 27 28export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog' 29 30export interface LabelsOnMeDialogProps { 31 control: Dialog.DialogOuterProps['control'] 32 labels: ComAtprotoLabelDefs.Label[] 33 type: 'account' | 'content' 34} 35 36export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) { 37 return ( 38 <Dialog.Outer 39 control={props.control} 40 nativeOptions={{preventExpansion: true}}> 41 <Dialog.Handle /> 42 <LabelsOnMeDialogInner {...props} /> 43 </Dialog.Outer> 44 ) 45} 46 47function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { 48 const {_} = useLingui() 49 const {currentAccount} = useSession() 50 const [appealingLabel, setAppealingLabel] = React.useState< 51 ComAtprotoLabelDefs.Label | undefined 52 >(undefined) 53 const {labels} = props 54 const isAccount = props.type === 'account' 55 const containsSelfLabel = React.useMemo( 56 () => labels.some(l => l.src === currentAccount?.did), 57 [currentAccount?.did, labels], 58 ) 59 60 return ( 61 <Dialog.ScrollableInner 62 label={ 63 isAccount 64 ? _(msg`The following labels were applied to your account.`) 65 : _(msg`The following labels were applied to your content.`) 66 }> 67 {appealingLabel ? ( 68 <AppealForm 69 label={appealingLabel} 70 control={props.control} 71 onPressBack={() => setAppealingLabel(undefined)} 72 /> 73 ) : ( 74 <> 75 <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> 76 {isAccount ? ( 77 <Trans>Labels on your account</Trans> 78 ) : ( 79 <Trans>Labels on your content</Trans> 80 )} 81 </Text> 82 <Text style={[a.text_md, a.leading_snug]}> 83 {containsSelfLabel ? ( 84 <Trans> 85 You may appeal non-self labels if you feel they were placed in 86 error. 87 </Trans> 88 ) : ( 89 <Trans> 90 You may appeal these labels if you feel they were placed in 91 error. 92 </Trans> 93 )} 94 </Text> 95 96 <View style={[a.py_lg, a.gap_md]}> 97 {labels.map(label => ( 98 <Label 99 key={`${label.val}-${label.src}`} 100 label={label} 101 isSelfLabel={label.src === currentAccount?.did} 102 control={props.control} 103 onPressAppeal={setAppealingLabel} 104 /> 105 ))} 106 </View> 107 </> 108 )} 109 <Dialog.Close /> 110 </Dialog.ScrollableInner> 111 ) 112} 113 114function Label({ 115 label, 116 isSelfLabel, 117 control, 118 onPressAppeal, 119}: { 120 label: ComAtprotoLabelDefs.Label 121 isSelfLabel: boolean 122 control: Dialog.DialogOuterProps['control'] 123 onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void 124}) { 125 const t = useTheme() 126 const {_} = useLingui() 127 const {labeler, strings} = useLabelInfo(label) 128 const sourceName = labeler 129 ? sanitizeHandle(labeler.creator.handle, '@') 130 : label.src 131 const timeDiff = useGetTimeAgo({future: true}) 132 return ( 133 <View 134 style={[ 135 a.border, 136 t.atoms.border_contrast_low, 137 a.rounded_sm, 138 a.overflow_hidden, 139 ]}> 140 <View style={[a.p_md, a.gap_sm, a.flex_row]}> 141 <View style={[a.flex_1, a.gap_xs]}> 142 <Text emoji style={[a.font_semi_bold, a.text_md]}> 143 {strings.name} 144 </Text> 145 <Text emoji style={[t.atoms.text_contrast_medium, a.leading_snug]}> 146 {strings.description} 147 </Text> 148 </View> 149 {!isSelfLabel && ( 150 <View> 151 <Button 152 variant="solid" 153 color="secondary" 154 size="small" 155 label={_(msg`Appeal`)} 156 onPress={() => onPressAppeal(label)}> 157 <ButtonText> 158 <Trans>Appeal</Trans> 159 </ButtonText> 160 </Button> 161 </View> 162 )} 163 </View> 164 165 <Divider /> 166 167 <View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}> 168 {isSelfLabel ? ( 169 <Text style={[t.atoms.text_contrast_medium]}> 170 <Trans>This label was applied by you.</Trans> 171 </Text> 172 ) : ( 173 <View 174 style={[ 175 a.flex_row, 176 a.justify_between, 177 a.gap_xl, 178 {paddingBottom: 1}, 179 ]}> 180 <Text 181 style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]} 182 numberOfLines={1}> 183 <Trans> 184 Source:{' '} 185 <InlineLinkText 186 label={sourceName} 187 to={makeProfileLink( 188 labeler ? labeler.creator : {did: label.src, handle: ''}, 189 )} 190 onPress={() => control.close()}> 191 {sourceName} 192 </InlineLinkText> 193 </Trans> 194 </Text> 195 {label.exp && ( 196 <View> 197 <Text 198 style={[ 199 a.leading_snug, 200 a.text_sm, 201 a.italic, 202 t.atoms.text_contrast_medium, 203 ]}> 204 <Trans>Expires in {timeDiff(Date.now(), label.exp)}</Trans> 205 </Text> 206 </View> 207 )} 208 </View> 209 )} 210 </View> 211 </View> 212 ) 213} 214 215function AppealForm({ 216 label, 217 control, 218 onPressBack, 219}: { 220 label: ComAtprotoLabelDefs.Label 221 control: Dialog.DialogOuterProps['control'] 222 onPressBack: () => void 223}) { 224 const {_} = useLingui() 225 const {labeler, strings} = useLabelInfo(label) 226 const {gtMobile} = useBreakpoints() 227 const [details, setDetails] = React.useState('') 228 const {subject} = useLabelSubject({label}) 229 const isAccountReport = 'did' in subject 230 const agent = useAgent() 231 const sourceName = labeler 232 ? sanitizeHandle(labeler.creator.handle, '@') 233 : label.src 234 const [error, setError] = useState<string | null>(null) 235 236 const {mutate, isPending} = useMutation({ 237 mutationFn: async () => { 238 const $type = !isAccountReport 239 ? 'com.atproto.repo.strongRef' 240 : 'com.atproto.admin.defs#repoRef' 241 await agent.createModerationReport( 242 { 243 reasonType: ToolsOzoneReportDefs.REASONAPPEAL, 244 subject: { 245 $type, 246 ...subject, 247 }, 248 reason: details, 249 }, 250 { 251 encoding: 'application/json', 252 headers: { 253 'atproto-proxy': `${label.src}#atproto_labeler`, 254 }, 255 }, 256 ) 257 }, 258 onError: err => { 259 if (err instanceof XRPCError && err.error === 'AlreadyAppealed') { 260 setError( 261 _( 262 msg`You've already appealed this label and it's being reviewed by our moderation team.`, 263 ), 264 ) 265 } else { 266 setError(_(msg`Failed to submit appeal, please try again.`)) 267 } 268 logger.error('Failed to submit label appeal', {message: err}) 269 }, 270 onSuccess: () => { 271 control.close() 272 Toast.show(_(msg({message: 'Appeal submitted', context: 'toast'}))) 273 }, 274 }) 275 276 const onSubmit = React.useCallback(() => mutate(), [mutate]) 277 278 return ( 279 <> 280 <View> 281 <Text style={[a.text_2xl, a.font_semi_bold, a.pb_xs, a.leading_tight]}> 282 <Trans>Appeal "{strings.name}" label</Trans> 283 </Text> 284 <Text style={[a.text_md, a.leading_snug]}> 285 <Trans> 286 This appeal will be sent to{' '} 287 <InlineLinkText 288 label={sourceName} 289 to={makeProfileLink( 290 labeler ? labeler.creator : {did: label.src, handle: ''}, 291 )} 292 onPress={() => control.close()} 293 style={[a.text_md, a.leading_snug]}> 294 {sourceName} 295 </InlineLinkText> 296 . 297 </Trans> 298 </Text> 299 </View> 300 {error && ( 301 <Admonition type="error" style={[a.mt_sm]}> 302 {error} 303 </Admonition> 304 )} 305 <View style={[a.my_md]}> 306 <Dialog.Input 307 label={_(msg`Text input field`)} 308 placeholder={_( 309 msg`Please explain why you think this label was incorrectly applied by ${ 310 labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src 311 }`, 312 )} 313 value={details} 314 onChangeText={setDetails} 315 autoFocus={true} 316 numberOfLines={3} 317 multiline 318 maxLength={300} 319 /> 320 </View> 321 322 <View 323 style={ 324 gtMobile 325 ? [a.flex_row, a.justify_between] 326 : [{flexDirection: 'column-reverse'}, a.gap_sm] 327 }> 328 <Button 329 testID="backBtn" 330 variant="solid" 331 color="secondary" 332 size="large" 333 onPress={onPressBack} 334 label={_(msg`Back`)}> 335 <ButtonText>{_(msg`Back`)}</ButtonText> 336 </Button> 337 <Button 338 testID="submitBtn" 339 variant="solid" 340 color="primary" 341 size="large" 342 onPress={onSubmit} 343 label={_(msg`Submit`)}> 344 <ButtonText>{_(msg`Submit`)}</ButtonText> 345 {isPending && <ButtonIcon icon={Loader} />} 346 </Button> 347 </View> 348 {IS_ANDROID && <View style={{height: 300}} />} 349 </> 350 ) 351}