Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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}