+1
-1
bskyweb/cmd/bskyweb/server.go
+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
+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>
+35
bskyweb/static/iframe/youtube.js
+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
+1
-1
package.json
+21
-2
src/components/Dialog/context.ts
+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
+1
src/components/Dialog/index.web.tsx
-85
src/components/ReportDialog/SelectLabelerView.tsx
-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
-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
-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
src/components/ReportDialog/const.ts
···
1
-
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
···
-97
src/components/ReportDialog/index.tsx
-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
-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
+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
+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
+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
-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
+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
+112
src/components/moderation/ReportDialog/const.ts
···
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
+14
-4
src/components/moderation/ReportDialog/copy.ts
···
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
+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
+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
+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
+1
-1
src/components/moderation/ReportDialog/utils/parseReportSubject.ts
+237
-102
src/components/moderation/ReportDialog/utils/useReportOptions.ts
+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
+1
-1
src/locale/locales/en/messages.po
+24
-10
src/screens/Messages/components/RequestButtons.tsx
+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
+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
+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
-1
src/state/queries/preferences/const.ts
+2
-9
src/state/queries/preferences/types.ts
+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
+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
-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
+1
-1
src/state/queries/usePostThread/types.ts
+1
-1
src/view/com/composer/Composer.tsx
+1
-1
src/view/com/composer/Composer.tsx
+4
-4
yarn.lock
+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"