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