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