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