forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 useCallback,
3 useLayoutEffect,
4 useMemo,
5 useReducer,
6 useRef,
7 useState,
8} from 'react'
9import {LayoutAnimation, type TextInput, View} from 'react-native'
10import {Trans, useLingui} from '@lingui/react/macro'
11
12import {useModerationOpts} from '#/state/preferences/moderation-opts'
13import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
14import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
15import {useSession} from '#/state/session'
16import {type ListMethods} from '#/view/com/util/List'
17import {android, atoms as a, native, useTheme, web} from '#/alf'
18import {Button, ButtonIcon, ButtonText} from '#/components/Button'
19import * as Dialog from '#/components/Dialog'
20import {canBeMessaged} from '#/components/dms/util'
21import * as Toggle from '#/components/forms/Toggle'
22import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
23import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
24import {Text} from '#/components/Typography'
25import {IS_NATIVE, IS_WEB} from '#/env'
26import type * as bsky from '#/types/bsky'
27import {ChatProfileTabs} from './ChatProfileTabs'
28import {EmptyMemberList} from './components/EmptyMemberList'
29import {GroupChatProfileCard} from './components/GroupChatProfileCard'
30import {ProfileCardSkeleton} from './components/ProfileCardSkeleton'
31import {UserLabel} from './components/UserLabel'
32import {UserSearchInput} from './components/UserSearchInput'
33
34type LabelItem = {
35 type: 'label'
36 key: string
37 message: string
38}
39
40type ProfileItem = {
41 type: 'profile'
42 key: string
43 profile: bsky.profile.AnyProfileView
44}
45
46type EmptyItem = {
47 type: 'empty'
48 key: string
49 message: string
50}
51
52type PlaceholderItem = {
53 type: 'placeholder'
54 key: string
55}
56
57type ErrorItem = {
58 type: 'error'
59 key: string
60}
61
62type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
63
64export type State = {
65 groupChatDids: string[]
66 groupChatProfiles: bsky.profile.AnyProfileView[]
67}
68
69export type Action =
70 | {
71 type: 'setDids'
72 groupChatDids: string[]
73 groupChatProfiles: bsky.profile.AnyProfileView[]
74 }
75 | {
76 type: 'removeDids'
77 groupChatDids: string[]
78 groupChatProfiles: bsky.profile.AnyProfileView[]
79 }
80
81function reducer(state: State, action: Action): State {
82 switch (action.type) {
83 case 'setDids': {
84 return {
85 ...state,
86 groupChatDids: action.groupChatDids,
87 groupChatProfiles: action.groupChatProfiles,
88 }
89 }
90 case 'removeDids': {
91 return {
92 ...state,
93 groupChatDids: action.groupChatDids,
94 groupChatProfiles: action.groupChatProfiles,
95 }
96 }
97 }
98}
99
100export function AddMembersFlow({
101 title,
102 onAddMembers,
103}: {
104 title: string
105 onAddMembers: (dids: string[]) => void
106}) {
107 const t = useTheme()
108 const {t: l} = useLingui()
109 const moderationOpts = useModerationOpts()
110 const control = Dialog.useDialogContext()
111 const [headerHeight, setHeaderHeight] = useState(0)
112 const [footerHeight, setFooterHeight] = useState(0)
113 const listRef = useRef<ListMethods>(null)
114 const {currentAccount} = useSession()
115 const inputRef = useRef<TextInput>(null)
116
117 const [searchText, setSearchText] = useState('')
118
119 const {
120 data: results,
121 isError,
122 isFetching,
123 } = useActorAutocompleteQuery(searchText, true, 12)
124 const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
125
126 const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
127 groupChatDids: [],
128 groupChatProfiles: [],
129 })
130
131 const onRemoveDid = useCallback(
132 (did: string) => {
133 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
134 dispatch({
135 type: 'removeDids',
136 groupChatDids: groupChatDids.filter(d => d !== did),
137 groupChatProfiles: groupChatProfiles.filter(
138 profile => profile.did !== did,
139 ),
140 })
141 },
142 [groupChatDids, groupChatProfiles],
143 )
144
145 const items = useMemo(() => {
146 let _items: Item[] = []
147
148 if (isError) {
149 _items.push({
150 type: 'empty',
151 key: 'empty',
152 message: l`We鈥檙e having network issues, try again`,
153 })
154 } else if (searchText.length) {
155 if (results?.length) {
156 for (const profile of results) {
157 if (profile.did === currentAccount?.did) continue
158 _items.push({
159 type: 'profile',
160 key: profile.did,
161 profile,
162 })
163 }
164
165 _items = _items.sort(item => {
166 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
167 })
168 }
169 } else {
170 const placeholders: Item[] = Array(10)
171 .fill(0)
172 .map((__, i) => ({
173 type: 'placeholder',
174 key: i + '',
175 }))
176
177 if (follows) {
178 for (const page of follows.pages) {
179 for (const profile of page.follows) {
180 _items.push({
181 type: 'profile',
182 key: profile.did,
183 profile,
184 })
185 }
186 }
187
188 _items = _items.sort(item => {
189 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
190 })
191 } else {
192 _items.push(...placeholders)
193 }
194 }
195
196 if (searchText === '') {
197 _items.unshift({
198 type: 'label',
199 key: 'suggested',
200 message: l`Suggested`,
201 })
202 }
203
204 return _items
205 }, [isError, searchText, l, results, currentAccount?.did, follows])
206
207 if (searchText && !isFetching && !items.length && !isError) {
208 items.push({type: 'empty', key: 'empty', message: l`No results`})
209 }
210
211 const handlePressBack = useCallback(() => {
212 control.close()
213 }, [control])
214
215 const handlePressAdd = useCallback(() => {
216 onAddMembers(groupChatDids)
217 }, [groupChatDids, onAddMembers])
218
219 const renderItems = useCallback(
220 ({item}: {item: Item}) => {
221 switch (item.type) {
222 case 'label': {
223 return <UserLabel key={item.key} message={item.message} />
224 }
225 case 'profile': {
226 return (
227 <GroupChatProfileCard
228 key={item.key}
229 profile={item.profile}
230 moderationOpts={moderationOpts!}
231 />
232 )
233 }
234 case 'placeholder': {
235 return <ProfileCardSkeleton key={item.key} />
236 }
237 case 'empty': {
238 return <EmptyMemberList key={item.key} message={item.message} />
239 }
240 default:
241 return null
242 }
243 },
244 [moderationOpts],
245 )
246
247 useLayoutEffect(() => {
248 if (IS_WEB) {
249 setImmediate(() => {
250 inputRef?.current?.focus()
251 })
252 }
253 }, [])
254
255 let buttonLabel = l`Continue to group name`
256 let buttonText = l`Next`
257 let showButton = groupChatProfiles.length > 0
258 let isButtonDisabled = !showButton
259
260 const showChatProfileTabs = groupChatProfiles.length > 0
261
262 const listHeader = useMemo(
263 () => (
264 <View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
265 <View
266 style={[
267 a.relative,
268 web(a.pt_lg),
269 native(a.pt_4xl),
270 android({
271 borderTopLeftRadius: a.rounded_md.borderRadius,
272 borderTopRightRadius: a.rounded_md.borderRadius,
273 }),
274 a.px_lg,
275 a.border_b,
276 t.atoms.border_contrast_low,
277 t.atoms.bg,
278 ]}>
279 <View
280 style={[
281 a.flex_row,
282 a.gap_sm,
283 a.relative,
284 a.align_center,
285 a.justify_between,
286 web(a.pb_lg),
287 ]}>
288 {IS_NATIVE ? (
289 <Button
290 label={l`Back`}
291 size="large"
292 shape="round"
293 variant="ghost"
294 color="secondary"
295 style={[native([a.absolute, a.z_20])]}
296 onPress={handlePressBack}>
297 <ButtonIcon icon={ArrowLeftIcon} size="lg" />
298 </Button>
299 ) : null}
300 <Text
301 style={[
302 a.flex_grow,
303 a.z_10,
304 a.text_lg,
305 a.font_bold,
306 a.leading_tight,
307 t.atoms.text_contrast_high,
308 a.text_center,
309 a.px_5xl,
310 ]}>
311 {title}
312 </Text>
313 {IS_WEB ? (
314 <Button
315 label={l`Close`}
316 size="small"
317 shape="round"
318 variant="ghost"
319 color="secondary"
320 style={[a.absolute, a.z_20, {right: -4}]}
321 onPress={() => control.close()}>
322 <ButtonIcon icon={XIcon} size="lg" />
323 </Button>
324 ) : showButton ? (
325 <Button
326 label={buttonLabel}
327 size="small"
328 color="primary"
329 style={[
330 native([
331 a.absolute,
332 a.z_20,
333 {
334 right: 8,
335 },
336 ]),
337 ]}
338 disabled={isButtonDisabled}
339 onPress={handlePressAdd}>
340 <ButtonText>
341 <Trans>Add</Trans>
342 </ButtonText>
343 </Button>
344 ) : null}
345 </View>
346 <View style={[web(a.pt_xs), native(a.pt_md)]}>
347 <UserSearchInput
348 inputRef={inputRef}
349 value={searchText}
350 onChangeText={text => {
351 setSearchText(text)
352 listRef.current?.scrollToOffset({offset: 0, animated: false})
353 }}
354 onEscape={control.close}
355 />
356 </View>
357 </View>
358 {showChatProfileTabs ? (
359 <View style={[a.pb_sm, a.pt_md, t.atoms.bg]}>
360 <ChatProfileTabs
361 testID="newGroupChatMembers"
362 profiles={groupChatProfiles}
363 onRemove={onRemoveDid}
364 />
365 </View>
366 ) : null}
367 </View>
368 ),
369 [
370 buttonLabel,
371 control,
372 groupChatProfiles,
373 handlePressAdd,
374 handlePressBack,
375 isButtonDisabled,
376 l,
377 onRemoveDid,
378 searchText,
379 showButton,
380 showChatProfileTabs,
381 t.atoms.bg,
382 t.atoms.border_contrast_low,
383 t.atoms.text_contrast_high,
384 title,
385 ],
386 )
387
388 const setGroupChatMembers = (dids: string[]) => {
389 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
390
391 const added = dids.filter(d => !groupChatDids.includes(d))
392 const removed = groupChatDids.filter(d => !dids.includes(d))
393 const newDids = [
394 ...groupChatDids.filter(d => !removed.includes(d)),
395 ...added,
396 ]
397
398 const kept = groupChatProfiles.filter(p => dids.includes(p.did))
399 const keptDids = new Set(kept.map(p => p.did))
400 const addedProfiles = items
401 .filter(
402 (item): item is ProfileItem =>
403 item.type === 'profile' &&
404 dids.includes(item.profile.did) &&
405 !keptDids.has(item.profile.did),
406 )
407 .map(item => item.profile)
408 .sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did))
409
410 dispatch({
411 type: 'setDids',
412 groupChatDids: newDids,
413 groupChatProfiles: [...kept, ...addedProfiles],
414 })
415 }
416
417 return (
418 <Toggle.Group
419 values={groupChatDids}
420 onChange={setGroupChatMembers}
421 type="checkbox"
422 label={l`Add group chat members`}
423 style={web([a.contents])}>
424 <Dialog.InnerFlatList
425 ref={listRef}
426 data={items}
427 renderItem={renderItems}
428 ListHeaderComponent={listHeader}
429 stickyHeaderIndices={[0]}
430 keyExtractor={(item: Item) => item.key}
431 style={[
432 web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
433 native({height: '100%'}),
434 ]}
435 webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]}
436 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
437 scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
438 keyboardDismissMode="on-drag"
439 footer={
440 IS_WEB ? (
441 <Dialog.FlatListFooter
442 onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}>
443 <View style={[a.flex_row, a.align_center, a.justify_between]}>
444 <Button
445 label={l`Back`}
446 size="small"
447 color="secondary"
448 onPress={handlePressBack}>
449 <ButtonIcon icon={ArrowLeftIcon} size="md" />
450 <ButtonText>
451 {' '}
452 <Trans>Back</Trans>
453 </ButtonText>
454 </Button>
455 <Button
456 label={buttonLabel}
457 size="small"
458 color="primary"
459 disabled={isButtonDisabled}
460 onPress={handlePressAdd}>
461 <ButtonText>{buttonText} </ButtonText>
462 </Button>
463 </View>
464 </Dialog.FlatListFooter>
465 ) : null
466 }
467 />
468 </Toggle.Group>
469 )
470}