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 {getTerminology} from '#/lib/strings/terminology'
21import {useTerminologyPreference} from '#/state/preferences'
22import {logger} from '#/logger'
23import {isNative} from '#/platform/detection'
24import {
25 type ThreadgateAllowUISetting,
26 threadgateViewToAllowUISetting,
27} from '#/state/queries/threadgate'
28import {atoms as a, native, useTheme, web} from '#/alf'
29import {Button, ButtonText} from '#/components/Button'
30import * as Dialog from '#/components/Dialog'
31import {useDialogControl} from '#/components/Dialog'
32import {
33 PostInteractionSettingsDialog,
34 usePrefetchPostInteractionSettings,
35} from '#/components/dialogs/PostInteractionSettingsDialog'
36import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronDownIcon} from '#/components/icons/Chevron'
37import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSignIcon} from '#/components/icons/CircleBanSign'
38import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe'
39import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group'
40import {InlineLinkText} from '#/components/Link'
41import {Text} from '#/components/Typography'
42import * as bsky from '#/types/bsky'
43
44interface WhoCanReplyProps {
45 post: AppBskyFeedDefs.PostView
46 isThreadAuthor: boolean
47 style?: StyleProp<ViewStyle>
48}
49
50export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
51 const {_} = useLingui()
52 const t = useTheme()
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 (isNative && Keyboard.isVisible()) {
92 Keyboard.dismiss()
93 }
94 if (isThreadAuthor) {
95 logger.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 logger.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 const terminologyPreference = useTerminologyPreference()
218
219 return (
220 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
221 <Dialog.Handle />
222 <Dialog.ScrollableInner
223 label={_(msg`Dialog: adjust who can interact with this post`)}
224 style={web({maxWidth: 400})}>
225 <View style={[a.gap_sm]}>
226 <Text style={[a.font_semi_bold, a.text_xl, a.pb_sm]}>
227 <Trans>{_(getTerminology(terminologyPreference, {
228 skeet: msg`Who can interact with this skeet?`,
229 post: msg`Who can interact with this post?`,
230 spell: msg`Who can interact with this spell?`,
231 }))}</Trans>
232 </Text>
233 <Rules
234 post={post}
235 settings={settings}
236 embeddingDisabled={embeddingDisabled}
237 />
238 </View>
239 {isNative && (
240 <Button
241 label={_(msg`Close`)}
242 onPress={() => control.close()}
243 size="small"
244 variant="solid"
245 color="secondary"
246 style={[a.mt_5xl]}>
247 <ButtonText>
248 <Trans>Close</Trans>
249 </ButtonText>
250 </Button>
251 )}
252 <Dialog.Close />
253 </Dialog.ScrollableInner>
254 </Dialog.Outer>
255 )
256}
257
258function Rules({
259 post,
260 settings,
261 embeddingDisabled,
262}: {
263 post: AppBskyFeedDefs.PostView
264 settings: ThreadgateAllowUISetting[]
265 embeddingDisabled: boolean
266}) {
267 const t = useTheme()
268 const {_} = useLingui()
269 const terminologyPreference = useTerminologyPreference()
270
271 return (
272 <>
273 <Text
274 style={[
275 a.text_sm,
276 a.leading_snug,
277 a.flex_wrap,
278 t.atoms.text_contrast_medium,
279 ]}>
280 {settings.length === 0 ? (
281 <Trans>{_(getTerminology(terminologyPreference, {
282 skeet: msg`This skeet has an unknown type of threadgate on it. Your app may be out of date.`,
283 post: msg`This post has an unknown type of threadgate on it. Your app may be out of date.`,
284 spell: msg`This spell has an unknown type of threadgate on it. Your app may be out of date.`,
285 }))}</Trans>
286 ) : settings[0].type === 'everybody' ? (
287 <Trans>{_(getTerminology(terminologyPreference, {
288 skeet: msg`Everybody can reply to this skeet.`,
289 post: msg`Everybody can reply to this post.`,
290 spell: msg`Everybody can reply to this spell.`,
291 }))}</Trans>
292 ) : settings[0].type === 'nobody' ? (
293 <Trans>{_(getTerminology(terminologyPreference, {
294 skeet: msg`Replies to this skeet are disabled.`,
295 post: msg`Replies to this post are disabled.`,
296 spell: msg`Replies to this spell are disabled.`,
297 }))}</Trans>
298 ) : (
299 <Trans>
300 Only{' '}
301 {settings.map((rule, i) => (
302 <Fragment key={`rule-${i}`}>
303 <Rule rule={rule} post={post} lists={post.threadgate!.lists} />
304 <Separator i={i} length={settings.length} />
305 </Fragment>
306 ))}{' '}
307 can reply.
308 </Trans>
309 )}{' '}
310 </Text>
311 {embeddingDisabled && (
312 <Text
313 style={[
314 a.text_sm,
315 a.leading_snug,
316 a.flex_wrap,
317 t.atoms.text_contrast_medium,
318 ]}>
319 <Trans>{_(getTerminology(terminologyPreference, {
320 skeet: msg`No one but the author can quote this skeet.`,
321 post: msg`No one but the author can quote this post.`,
322 spell: msg`No one but the author can quote this spell.`,
323 }))}</Trans>
324 </Text>
325 )}
326 </>
327 )
328}
329
330function Rule({
331 rule,
332 post,
333 lists,
334}: {
335 rule: ThreadgateAllowUISetting
336 post: AppBskyFeedDefs.PostView
337 lists: AppBskyGraphDefs.ListViewBasic[] | undefined
338}) {
339 if (rule.type === 'mention') {
340 return <Trans>mentioned users</Trans>
341 }
342 if (rule.type === 'followers') {
343 return (
344 <Trans>
345 users following{' '}
346 <InlineLinkText
347 label={`@${post.author.handle}`}
348 to={makeProfileLink(post.author)}
349 style={[a.text_sm, a.leading_snug]}>
350 @{post.author.handle}
351 </InlineLinkText>
352 </Trans>
353 )
354 }
355 if (rule.type === 'following') {
356 return (
357 <Trans>
358 users followed by{' '}
359 <InlineLinkText
360 label={`@${post.author.handle}`}
361 to={makeProfileLink(post.author)}
362 style={[a.text_sm, a.leading_snug]}>
363 @{post.author.handle}
364 </InlineLinkText>
365 </Trans>
366 )
367 }
368 if (rule.type === 'list') {
369 const list = lists?.find(l => l.uri === rule.list)
370 if (list) {
371 const listUrip = new AtUri(list.uri)
372 return (
373 <Trans>
374 <InlineLinkText
375 label={list.name}
376 to={makeListLink(listUrip.hostname, listUrip.rkey)}
377 style={[a.text_sm, a.leading_snug]}>
378 {list.name}
379 </InlineLinkText>{' '}
380 members
381 </Trans>
382 )
383 }
384 }
385}
386
387function Separator({i, length}: {i: number; length: number}) {
388 if (length < 2 || i === length - 1) {
389 return null
390 }
391 if (i === length - 2) {
392 return (
393 <>
394 {length > 2 ? ',' : ''} <Trans>and</Trans>{' '}
395 </>
396 )
397 }
398 return <>, </>
399}