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