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