my fork of the bluesky client
1import React from 'react'
2import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
3import {
4 AppBskyFeedDefs,
5 AppBskyFeedPost,
6 AppBskyGraphDefs,
7 AtUri,
8} from '@atproto/api'
9import {msg, Trans} from '@lingui/macro'
10import {useLingui} from '@lingui/react'
11
12import {HITSLOP_10} from '#/lib/constants'
13import {makeListLink, makeProfileLink} from '#/lib/routes/links'
14import {isNative} from '#/platform/detection'
15import {
16 ThreadgateAllowUISetting,
17 threadgateViewToAllowUISetting,
18} from '#/state/queries/threadgate'
19import {atoms as a, useTheme} from '#/alf'
20import {Button} from '#/components/Button'
21import * as Dialog from '#/components/Dialog'
22import {useDialogControl} from '#/components/Dialog'
23import {
24 PostInteractionSettingsDialog,
25 usePrefetchPostInteractionSettings,
26} from '#/components/dialogs/PostInteractionSettingsDialog'
27import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
28import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
29import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
30import {InlineLinkText} from '#/components/Link'
31import {Text} from '#/components/Typography'
32import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
33
34interface WhoCanReplyProps {
35 post: AppBskyFeedDefs.PostView
36 isThreadAuthor: boolean
37 style?: StyleProp<ViewStyle>
38}
39
40export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
41 const {_} = useLingui()
42 const t = useTheme()
43 const infoDialogControl = useDialogControl()
44 const editDialogControl = useDialogControl()
45
46 /*
47 * `WhoCanReply` is only used for root posts atm, in case this changes
48 * unexpectedly, we should check to make sure it's for sure the root URI.
49 */
50 const rootUri =
51 AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
52 ? post.record.reply.root.uri
53 : post.uri
54 const settings = React.useMemo(() => {
55 return threadgateViewToAllowUISetting(post.threadgate)
56 }, [post.threadgate])
57
58 const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
59 postUri: post.uri,
60 rootPostUri: rootUri,
61 })
62
63 const anyoneCanReply =
64 settings.length === 1 && settings[0].type === 'everybody'
65 const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
66 const description = anyoneCanReply
67 ? _(msg`Everybody can reply`)
68 : noOneCanReply
69 ? _(msg`Replies disabled`)
70 : _(msg`Some people can reply`)
71
72 const onPressOpen = () => {
73 if (isNative && Keyboard.isVisible()) {
74 Keyboard.dismiss()
75 }
76 if (isThreadAuthor) {
77 editDialogControl.open()
78 } else {
79 infoDialogControl.open()
80 }
81 }
82
83 return (
84 <>
85 <Button
86 label={
87 isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
88 }
89 onPress={onPressOpen}
90 {...(isThreadAuthor
91 ? Platform.select({
92 web: {
93 onHoverIn: prefetchPostInteractionSettings,
94 },
95 native: {
96 onPressIn: prefetchPostInteractionSettings,
97 },
98 })
99 : {})}
100 hitSlop={HITSLOP_10}>
101 {({hovered}) => (
102 <View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
103 <Icon
104 color={t.palette.contrast_400}
105 width={16}
106 settings={settings}
107 />
108 <Text
109 style={[
110 a.text_sm,
111 a.leading_tight,
112 t.atoms.text_contrast_medium,
113 hovered && a.underline,
114 ]}>
115 {description}
116 </Text>
117
118 {isThreadAuthor && (
119 <PencilLine width={12} fill={t.palette.primary_500} />
120 )}
121 </View>
122 )}
123 </Button>
124
125 {isThreadAuthor ? (
126 <PostInteractionSettingsDialog
127 postUri={post.uri}
128 rootPostUri={rootUri}
129 control={editDialogControl}
130 initialThreadgateView={post.threadgate}
131 />
132 ) : (
133 <WhoCanReplyDialog
134 control={infoDialogControl}
135 post={post}
136 settings={settings}
137 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
138 />
139 )}
140 </>
141 )
142}
143
144function Icon({
145 color,
146 width,
147 settings,
148}: {
149 color: string
150 width?: number
151 settings: ThreadgateAllowUISetting[]
152}) {
153 const isEverybody = settings.length === 0
154 const isNobody = !!settings.find(gate => gate.type === 'nobody')
155 const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group
156 return <IconComponent fill={color} width={width} />
157}
158
159function WhoCanReplyDialog({
160 control,
161 post,
162 settings,
163 embeddingDisabled,
164}: {
165 control: Dialog.DialogControlProps
166 post: AppBskyFeedDefs.PostView
167 settings: ThreadgateAllowUISetting[]
168 embeddingDisabled: boolean
169}) {
170 const {_} = useLingui()
171 return (
172 <Dialog.Outer control={control}>
173 <Dialog.Handle />
174 <Dialog.ScrollableInner
175 label={_(msg`Dialog: adjust who can interact with this post`)}
176 style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
177 <View style={[a.gap_sm]}>
178 <Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
179 <Trans>Who can interact with this post?</Trans>
180 </Text>
181 <Rules
182 post={post}
183 settings={settings}
184 embeddingDisabled={embeddingDisabled}
185 />
186 </View>
187 </Dialog.ScrollableInner>
188 </Dialog.Outer>
189 )
190}
191
192function Rules({
193 post,
194 settings,
195 embeddingDisabled,
196}: {
197 post: AppBskyFeedDefs.PostView
198 settings: ThreadgateAllowUISetting[]
199 embeddingDisabled: boolean
200}) {
201 const t = useTheme()
202
203 return (
204 <>
205 <Text
206 style={[
207 a.text_sm,
208 a.leading_snug,
209 a.flex_wrap,
210 t.atoms.text_contrast_medium,
211 ]}>
212 {settings[0].type === 'everybody' ? (
213 <Trans>Everybody can reply to this post.</Trans>
214 ) : settings[0].type === 'nobody' ? (
215 <Trans>Replies to this post are disabled.</Trans>
216 ) : (
217 <Trans>
218 Only{' '}
219 {settings.map((rule, i) => (
220 <React.Fragment key={`rule-${i}`}>
221 <Rule rule={rule} post={post} lists={post.threadgate!.lists} />
222 <Separator i={i} length={settings.length} />
223 </React.Fragment>
224 ))}{' '}
225 can reply.
226 </Trans>
227 )}{' '}
228 </Text>
229 {embeddingDisabled && (
230 <Text
231 style={[
232 a.text_sm,
233 a.leading_snug,
234 a.flex_wrap,
235 t.atoms.text_contrast_medium,
236 ]}>
237 <Trans>No one but the author can quote this post.</Trans>
238 </Text>
239 )}
240 </>
241 )
242}
243
244function Rule({
245 rule,
246 post,
247 lists,
248}: {
249 rule: ThreadgateAllowUISetting
250 post: AppBskyFeedDefs.PostView
251 lists: AppBskyGraphDefs.ListViewBasic[] | undefined
252}) {
253 if (rule.type === 'mention') {
254 return <Trans>mentioned users</Trans>
255 }
256 if (rule.type === 'following') {
257 return (
258 <Trans>
259 users followed by{' '}
260 <InlineLinkText
261 label={`@${post.author.handle}`}
262 to={makeProfileLink(post.author)}
263 style={[a.text_sm, a.leading_snug]}>
264 @{post.author.handle}
265 </InlineLinkText>
266 </Trans>
267 )
268 }
269 if (rule.type === 'list') {
270 const list = lists?.find(l => l.uri === rule.list)
271 if (list) {
272 const listUrip = new AtUri(list.uri)
273 return (
274 <Trans>
275 <InlineLinkText
276 label={list.name}
277 to={makeListLink(listUrip.hostname, listUrip.rkey)}
278 style={[a.text_sm, a.leading_snug]}>
279 {list.name}
280 </InlineLinkText>{' '}
281 members
282 </Trans>
283 )
284 }
285 }
286}
287
288function Separator({i, length}: {i: number; length: number}) {
289 if (length < 2 || i === length - 1) {
290 return null
291 }
292 if (i === length - 2) {
293 return (
294 <>
295 {length > 2 ? ',' : ''} <Trans>and</Trans>{' '}
296 </>
297 )
298 }
299 return <>, </>
300}