this repo has no description
1import {
2 useCallback,
3 useLayoutEffect,
4 useMemo,
5 useReducer,
6 useRef,
7 useState,
8} from 'react'
9import {LayoutAnimation, TextInput, View} from 'react-native'
10import {moderateProfile, type ModerationOpts} from '@atproto/api'
11import {Trans, useLingui} from '@lingui/react/macro'
12
13import {sanitizeDisplayName} from '#/lib/strings/display-names'
14import {sanitizeHandle} from '#/lib/strings/handles'
15import {useModerationOpts} from '#/state/preferences/moderation-opts'
16import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
17import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
18import {useSession} from '#/state/session'
19import {type ListMethods} from '#/view/com/util/List'
20import {android, atoms as a, native, useTheme, web} from '#/alf'
21import {Button, ButtonIcon, ButtonText} from '#/components/Button'
22import * as Dialog from '#/components/Dialog'
23import {canBeMessaged} from '#/components/dms/util'
24import * as TextField from '#/components/forms/TextField'
25import * as Toggle from '#/components/forms/Toggle'
26import {useInteractionState} from '#/components/hooks/useInteractionState'
27import {
28 ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon,
29 ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon,
30} from '#/components/icons/Arrow'
31import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
32import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
33import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
34import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
35import * as ProfileCard from '#/components/ProfileCard'
36import {Text} from '#/components/Typography'
37import {IS_NATIVE, IS_WEB} from '#/env'
38import type * as bsky from '#/types/bsky'
39import {ChatProfileTabs} from './ChatProfileTabs'
40
41type NewGroupChatItem = {
42 type: 'newGroupChat'
43 key: string
44}
45
46type LabelItem = {
47 type: 'label'
48 key: string
49 message: string
50}
51
52export type ProfileItem = {
53 type: 'profile'
54 key: string
55 profile: bsky.profile.AnyProfileView
56}
57
58type EmptyItem = {
59 type: 'empty'
60 key: string
61 message: string
62}
63
64type PlaceholderItem = {
65 type: 'placeholder'
66 key: string
67}
68
69type ErrorItem = {
70 type: 'error'
71 key: string
72}
73
74type Item =
75 | NewGroupChatItem
76 | LabelItem
77 | ProfileItem
78 | EmptyItem
79 | PlaceholderItem
80 | ErrorItem
81
82enum ChatState {
83 NEW_CHAT,
84 NEW_GROUP_CHAT,
85 GROUP_NAME,
86}
87
88export type State = {
89 chatState: ChatState
90 screenTitle: string
91 groupChatDids: string[]
92 groupChatProfiles: bsky.profile.AnyProfileView[]
93 groupName: string
94}
95
96export type Action =
97 | {
98 type: 'startNewGroupChat'
99 screenTitle: string
100 }
101 | {
102 type: 'setDids'
103 groupChatDids: string[]
104 groupChatProfiles: bsky.profile.AnyProfileView[]
105 }
106 | {
107 type: 'removeDids'
108 groupChatDids: string[]
109 groupChatProfiles: bsky.profile.AnyProfileView[]
110 }
111 | {
112 type: 'startNameGroup'
113 screenTitle: string
114 }
115 | {
116 type: 'nameGroup'
117 groupName: string
118 }
119 | {
120 type: 'goBackFromNewGroupChat'
121 screenTitle: string
122 }
123 | {
124 type: 'goBackFromGroupName'
125 screenTitle: string
126 }
127
128function reducer(state: State, action: Action): State {
129 switch (action.type) {
130 case 'startNewGroupChat': {
131 return {
132 ...state,
133 chatState: ChatState.NEW_GROUP_CHAT,
134 screenTitle: action.screenTitle,
135 groupChatDids: [],
136 groupChatProfiles: [],
137 groupName: '',
138 }
139 }
140 case 'setDids': {
141 return {
142 ...state,
143 groupChatDids: action.groupChatDids,
144 groupChatProfiles: action.groupChatProfiles,
145 }
146 }
147 case 'removeDids': {
148 return {
149 ...state,
150 groupChatDids: action.groupChatDids,
151 groupChatProfiles: action.groupChatProfiles,
152 }
153 }
154 case 'startNameGroup': {
155 return {
156 ...state,
157 chatState: ChatState.GROUP_NAME,
158 screenTitle: action.screenTitle,
159 }
160 }
161 case 'nameGroup': {
162 return {
163 ...state,
164 groupName: action.groupName,
165 }
166 }
167 case 'goBackFromNewGroupChat': {
168 return {
169 ...state,
170 chatState: ChatState.NEW_CHAT,
171 screenTitle: action.screenTitle,
172 groupChatDids: [],
173 groupChatProfiles: [],
174 groupName: '',
175 }
176 }
177 case 'goBackFromGroupName': {
178 return {
179 ...state,
180 chatState: ChatState.NEW_GROUP_CHAT,
181 screenTitle: action.screenTitle,
182 groupName: '',
183 }
184 }
185 }
186}
187export function InitiateChatFlow({
188 title,
189 onSelectChat,
190 onSelectGroupChat,
191}: {
192 title: string
193 onSelectChat: (did: string) => void
194 onSelectGroupChat: (dids: string[], groupName: string) => void
195}) {
196 const t = useTheme()
197 const {t: l} = useLingui()
198 const moderationOpts = useModerationOpts()
199 const control = Dialog.useDialogContext()
200 const [headerHeight, setHeaderHeight] = useState(0)
201 const [footerHeight, setFooterHeight] = useState(0)
202 const listRef = useRef<ListMethods>(null)
203 const {currentAccount} = useSession()
204 const inputRef = useRef<TextInput>(null)
205
206 const [searchText, setSearchText] = useState('')
207
208 const {
209 data: results,
210 isError,
211 isFetching,
212 } = useActorAutocompleteQuery(searchText, true, 12)
213 const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
214
215 const [
216 {chatState, screenTitle, groupChatDids, groupChatProfiles, groupName},
217 dispatch,
218 ] = useReducer(reducer, {
219 chatState: ChatState.NEW_CHAT,
220 screenTitle: title,
221 groupChatDids: [],
222 groupChatProfiles: [],
223 groupName: '',
224 })
225
226 const newGroupChatTitle = l`New group chat`
227 const groupNameTitle = l`Group name`
228
229 const onRemoveDid = useCallback(
230 (did: string) => {
231 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
232 dispatch({
233 type: 'removeDids',
234 groupChatDids: groupChatDids.filter(d => d !== did),
235 groupChatProfiles: groupChatProfiles.filter(
236 profile => profile.did !== did,
237 ),
238 })
239 },
240 [groupChatDids, groupChatProfiles],
241 )
242
243 const items = useMemo(() => {
244 let _items: Item[] = []
245
246 if (isError) {
247 _items.push({
248 type: 'empty',
249 key: 'empty',
250 message: l`We’re having network issues, try again`,
251 })
252 } else if (chatState === ChatState.GROUP_NAME) {
253 _items = groupChatProfiles.map(profile => ({
254 type: 'profile',
255 key: profile.did,
256 profile,
257 }))
258 _items.unshift({
259 type: 'label',
260 key: 'members',
261 message: l`New group chat with:`,
262 })
263 } else if (searchText.length) {
264 if (results?.length) {
265 for (const profile of results) {
266 if (profile.did === currentAccount?.did) continue
267 _items.push({
268 type: 'profile',
269 key: profile.did,
270 profile,
271 })
272 }
273
274 _items = _items.sort(item => {
275 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
276 })
277 }
278 } else {
279 const placeholders: Item[] = Array(10)
280 .fill(0)
281 .map((__, i) => ({
282 type: 'placeholder',
283 key: i + '',
284 }))
285
286 if (follows) {
287 for (const page of follows.pages) {
288 for (const profile of page.follows) {
289 _items.push({
290 type: 'profile',
291 key: profile.did,
292 profile,
293 })
294 }
295 }
296
297 _items = _items.sort(item => {
298 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
299 })
300 } else {
301 _items.push(...placeholders)
302 }
303 }
304
305 if (
306 searchText === '' &&
307 (chatState === ChatState.NEW_CHAT ||
308 chatState === ChatState.NEW_GROUP_CHAT)
309 ) {
310 _items.unshift({
311 type: 'label',
312 key: 'suggested',
313 message: l`Suggested`,
314 })
315 }
316
317 if (chatState === ChatState.NEW_CHAT && searchText === '') {
318 _items.unshift({type: 'newGroupChat', key: 'newGroupChat'})
319 }
320
321 return _items
322 }, [
323 isError,
324 chatState,
325 searchText,
326 l,
327 groupChatProfiles,
328 results,
329 currentAccount?.did,
330 follows,
331 ])
332
333 if (searchText && !isFetching && !items.length && !isError) {
334 items.push({type: 'empty', key: 'empty', message: l`No results`})
335 }
336
337 const handlePressBack = useCallback(() => {
338 switch (chatState) {
339 case ChatState.NEW_CHAT:
340 control.close()
341 break
342 case ChatState.NEW_GROUP_CHAT:
343 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
344 dispatch({type: 'goBackFromNewGroupChat', screenTitle: title})
345 setSearchText('')
346 break
347 case ChatState.GROUP_NAME:
348 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
349 dispatch({type: 'goBackFromGroupName', screenTitle: newGroupChatTitle})
350 break
351 }
352 }, [chatState, control, newGroupChatTitle, title])
353
354 const handlePressNewGroupChat = useCallback(() => {
355 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
356 dispatch({type: 'startNewGroupChat', screenTitle: newGroupChatTitle})
357 }, [newGroupChatTitle])
358
359 const handlePressNext = useCallback(() => {
360 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
361 dispatch({type: 'startNameGroup', screenTitle: groupNameTitle})
362 setSearchText('')
363 }, [groupNameTitle])
364
365 const handlePressConfirm = useCallback(() => {
366 onSelectGroupChat(groupChatDids, groupName)
367 }, [groupChatDids, groupName, onSelectGroupChat])
368
369 const setGroupName = (newGroupName: string) => {
370 dispatch({type: 'nameGroup', groupName: newGroupName})
371 }
372
373 const renderItems = useCallback(
374 ({item}: {item: Item}) => {
375 switch (item.type) {
376 case 'newGroupChat': {
377 return (
378 <NewGroupChatButton
379 key={item.key}
380 onPress={handlePressNewGroupChat}
381 />
382 )
383 }
384 case 'label': {
385 return <Label key={item.key} message={item.message} />
386 }
387 case 'profile': {
388 switch (chatState) {
389 case ChatState.NEW_CHAT:
390 return (
391 <DefaultProfileCard
392 key={item.key}
393 profile={item.profile}
394 moderationOpts={moderationOpts!}
395 onPress={onSelectChat}
396 />
397 )
398 case ChatState.NEW_GROUP_CHAT:
399 return (
400 <GroupChatProfileCard
401 key={item.key}
402 profile={item.profile}
403 moderationOpts={moderationOpts!}
404 />
405 )
406 case ChatState.GROUP_NAME:
407 return (
408 <GroupChatMemberProfileCard
409 key={item.key}
410 profile={item.profile}
411 moderationOpts={moderationOpts!}
412 />
413 )
414 }
415 }
416 case 'placeholder': {
417 return <ProfileCardSkeleton key={item.key} />
418 }
419 case 'empty': {
420 return <Empty key={item.key} message={item.message} />
421 }
422 default:
423 return null
424 }
425 },
426 [chatState, handlePressNewGroupChat, moderationOpts, onSelectChat],
427 )
428
429 useLayoutEffect(() => {
430 if (IS_WEB) {
431 setImmediate(() => {
432 inputRef?.current?.focus()
433 })
434 }
435 }, [])
436
437 let buttonLabel = l`Continue to group name`
438 let buttonText = l`Next`
439 let handleButtonPress = handlePressNext
440 let showButton =
441 chatState === ChatState.NEW_GROUP_CHAT && groupChatProfiles.length > 0
442 let isButtonDisabled = !showButton
443 switch (chatState) {
444 case ChatState.GROUP_NAME:
445 buttonLabel = l`Create group chat`
446 buttonText = l`Create`
447 handleButtonPress = handlePressConfirm
448 showButton = true
449 isButtonDisabled = groupName === ''
450 break
451 }
452
453 const showChatProfileTabs =
454 chatState === ChatState.NEW_GROUP_CHAT && groupChatProfiles.length > 0
455
456 const listHeader = useMemo(
457 () => (
458 <View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
459 <View
460 style={[
461 a.relative,
462 web(a.pt_lg),
463 native(a.pt_4xl),
464 android({
465 borderTopLeftRadius: a.rounded_md.borderRadius,
466 borderTopRightRadius: a.rounded_md.borderRadius,
467 }),
468 a.px_lg,
469 chatState !== ChatState.GROUP_NAME ? a.pb_xs : a.pb_lg,
470 chatState !== ChatState.GROUP_NAME && a.border_b,
471 t.atoms.border_contrast_low,
472 t.atoms.bg,
473 ]}>
474 <View
475 style={[
476 a.flex_row,
477 a.gap_sm,
478 a.relative,
479 a.align_center,
480 a.justify_between,
481 web(a.pb_lg),
482 ]}>
483 {IS_NATIVE ? (
484 <Button
485 label={l`Back`}
486 size="large"
487 shape="round"
488 variant="ghost"
489 color="secondary"
490 style={[native([a.absolute, a.z_20])]}
491 onPress={handlePressBack}>
492 <ButtonIcon icon={ArrowLeftIcon} size="lg" />
493 </Button>
494 ) : null}
495 <Text
496 style={[
497 a.flex_grow,
498 a.z_10,
499 a.text_lg,
500 a.font_bold,
501 a.leading_tight,
502 t.atoms.text_contrast_high,
503 a.text_center,
504 a.px_5xl,
505 ]}>
506 {screenTitle}
507 </Text>
508 {IS_WEB ? (
509 <Button
510 label={l`Close`}
511 size="small"
512 shape="round"
513 variant="ghost"
514 color="secondary"
515 style={[a.absolute, a.z_20, {right: -4}]}
516 onPress={() => control.close()}>
517 <ButtonIcon icon={XIcon} size="lg" />
518 </Button>
519 ) : showButton ? (
520 <Button
521 label={buttonLabel}
522 size="small"
523 color="primary"
524 style={[
525 native([
526 a.absolute,
527 a.z_20,
528 {
529 right: 8,
530 },
531 ]),
532 ]}
533 disabled={isButtonDisabled}
534 onPress={handleButtonPress}>
535 <ButtonText>{buttonText}</ButtonText>
536 </Button>
537 ) : null}
538 </View>
539 <View style={[web(a.pt_xs), native(a.pt_md)]}>
540 {chatState === ChatState.GROUP_NAME ? (
541 <View
542 style={[a.w_full, a.relative, web(a.pt_md), native(a.pt_xl)]}>
543 <TextField.Root>
544 <TextField.Input
545 label={l`Group name`}
546 value={groupName}
547 returnKeyType="next"
548 keyboardAppearance={t.scheme}
549 selectTextOnFocus={IS_NATIVE}
550 autoFocus={false}
551 accessibilityRole="text"
552 autoCorrect={false}
553 autoComplete="off"
554 autoCapitalize="none"
555 onChangeText={setGroupName}
556 onSubmitEditing={
557 isButtonDisabled ? undefined : handleButtonPress
558 }
559 />
560 </TextField.Root>
561 </View>
562 ) : (
563 <SearchInput
564 inputRef={inputRef}
565 value={searchText}
566 onChangeText={text => {
567 setSearchText(text)
568 listRef.current?.scrollToOffset({offset: 0, animated: false})
569 }}
570 onEscape={control.close}
571 />
572 )}
573 </View>
574 </View>
575 {showChatProfileTabs ? (
576 <View style={[a.pb_sm, a.pt_md, t.atoms.bg]}>
577 <ChatProfileTabs
578 testID="newGroupChatMembers"
579 profiles={groupChatProfiles}
580 onRemove={onRemoveDid}
581 />
582 </View>
583 ) : null}
584 </View>
585 ),
586 [
587 chatState,
588 t.atoms.border_contrast_low,
589 t.atoms.bg,
590 t.atoms.text_contrast_high,
591 t.scheme,
592 l,
593 handlePressBack,
594 screenTitle,
595 showButton,
596 buttonLabel,
597 isButtonDisabled,
598 handleButtonPress,
599 buttonText,
600 groupName,
601 searchText,
602 control,
603 showChatProfileTabs,
604 groupChatProfiles,
605 onRemoveDid,
606 ],
607 )
608
609 const setGroupChatMembers = (dids: string[]) => {
610 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
611
612 const added = dids.filter(d => !groupChatDids.includes(d))
613 const removed = groupChatDids.filter(d => !dids.includes(d))
614 const newDids = [
615 ...groupChatDids.filter(d => !removed.includes(d)),
616 ...added,
617 ]
618
619 const kept = groupChatProfiles.filter(p => dids.includes(p.did))
620 const keptDids = new Set(kept.map(p => p.did))
621 const addedProfiles = items
622 .filter(
623 (item): item is ProfileItem =>
624 item.type === 'profile' &&
625 dids.includes(item.profile.did) &&
626 !keptDids.has(item.profile.did),
627 )
628 .map(item => item.profile)
629 .sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did))
630
631 dispatch({
632 type: 'setDids',
633 groupChatDids: newDids,
634 groupChatProfiles: [...kept, ...addedProfiles],
635 })
636 }
637
638 return (
639 <Toggle.Group
640 values={groupChatDids}
641 onChange={setGroupChatMembers}
642 type="checkbox"
643 label={
644 chatState === ChatState.NEW_GROUP_CHAT
645 ? l`Select group chat members`
646 : l`Start chat`
647 }
648 style={web([a.contents])}>
649 <Dialog.InnerFlatList
650 ref={listRef}
651 data={items}
652 renderItem={renderItems}
653 ListHeaderComponent={listHeader}
654 stickyHeaderIndices={[0]}
655 keyExtractor={(item: Item) => item.key}
656 style={[
657 web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
658 native({height: '100%'}),
659 ]}
660 webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]}
661 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
662 scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
663 keyboardDismissMode="on-drag"
664 footer={
665 IS_WEB && chatState !== ChatState.NEW_CHAT ? (
666 <Dialog.FlatListFooter
667 onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}>
668 <View style={[a.flex_row, a.align_center, a.justify_between]}>
669 <Button
670 label={l`Back`}
671 size="small"
672 color="secondary"
673 onPress={handlePressBack}>
674 <ButtonIcon icon={ArrowLeftIcon} size="md" />
675 <ButtonText>
676 {' '}
677 <Trans>Back</Trans>
678 </ButtonText>
679 </Button>
680 <Button
681 label={buttonLabel}
682 size="small"
683 color="primary"
684 disabled={isButtonDisabled}
685 onPress={handleButtonPress}>
686 <ButtonText>{buttonText} </ButtonText>
687 {chatState !== ChatState.GROUP_NAME ? (
688 <ButtonIcon icon={ArrowRightIcon} size="md" />
689 ) : null}
690 </Button>
691 </View>
692 </Dialog.FlatListFooter>
693 ) : null
694 }
695 />
696 </Toggle.Group>
697 )
698}
699
700function NewGroupChatButton({onPress}: {onPress: () => void}) {
701 const t = useTheme()
702 const {t: l} = useLingui()
703
704 const handleOnPress = () => {
705 onPress()
706 }
707
708 return (
709 <Button label={l`New group chat`} onPress={handleOnPress}>
710 {({hovered, pressed, focused}) => (
711 <View
712 style={[
713 a.px_lg,
714 a.py_md,
715 a.flex_row,
716 a.flex_1,
717 a.justify_between,
718 a.align_center,
719 a.gap_sm,
720 pressed || focused || hovered ? t.atoms.bg_contrast_25 : t.atoms.bg,
721 ]}>
722 <View
723 style={[
724 a.rounded_full,
725 a.justify_center,
726 a.align_center,
727 {
728 backgroundColor: t.palette.contrast_50,
729 padding: 12,
730 },
731 ]}>
732 <PersonGroupIcon size="md" fill={t.palette.contrast_1000} />
733 </View>
734 <View style={[a.flex_grow]}>
735 <Text
736 style={[a.text_md, a.font_medium, a.leading_snug, t.atoms.text]}>
737 <Trans>New group chat</Trans>
738 </Text>
739 </View>
740 <ChevronRightIcon size="md" fill={t.palette.contrast_1000} />
741 </View>
742 )}
743 </Button>
744 )
745}
746
747function DefaultProfileCard({
748 profile,
749 moderationOpts,
750 onPress,
751}: {
752 profile: bsky.profile.AnyProfileView
753 moderationOpts: ModerationOpts
754 onPress: (did: string) => void
755}) {
756 const t = useTheme()
757 const {t: l} = useLingui()
758 const enabled = canBeMessaged(profile)
759 const moderation = moderateProfile(profile, moderationOpts)
760 const handle = sanitizeHandle(profile.handle, '@')
761 const displayName = sanitizeDisplayName(
762 profile.displayName || sanitizeHandle(profile.handle),
763 moderation.ui('displayName'),
764 )
765
766 const handleOnPress = useCallback(() => {
767 onPress(profile.did)
768 }, [onPress, profile.did])
769
770 return (
771 <Button
772 disabled={!enabled}
773 label={l`Start chat with ${displayName}`}
774 onPress={handleOnPress}>
775 {({hovered, pressed, focused}) => (
776 <View
777 style={[
778 a.flex_1,
779 a.py_sm,
780 a.px_lg,
781 !enabled
782 ? {opacity: 0.5}
783 : pressed || focused || hovered
784 ? t.atoms.bg_contrast_25
785 : t.atoms.bg,
786 ]}>
787 <ProfileCard.Header>
788 <ProfileCard.Avatar
789 profile={profile}
790 moderationOpts={moderationOpts}
791 size={44}
792 disabledPreview
793 />
794 <View style={[a.flex_1]}>
795 <ProfileCard.Name
796 profile={profile}
797 moderationOpts={moderationOpts}
798 />
799 {enabled ? (
800 <ProfileCard.Handle profile={profile} />
801 ) : (
802 <Text
803 style={[a.leading_snug, t.atoms.text_contrast_high]}
804 numberOfLines={2}>
805 <Trans>{handle} can’t be messaged</Trans>
806 </Text>
807 )}
808 </View>
809 </ProfileCard.Header>
810 </View>
811 )}
812 </Button>
813 )
814}
815
816function GroupChatProfileCard({
817 profile,
818 moderationOpts,
819}: {
820 profile: bsky.profile.AnyProfileView
821 moderationOpts: ModerationOpts
822}) {
823 const t = useTheme()
824 const enabled = canBeMessaged(profile)
825 const moderation = moderateProfile(profile, moderationOpts)
826 const handle = sanitizeHandle(profile.handle, '@')
827 const displayName = sanitizeDisplayName(
828 profile.displayName || sanitizeHandle(profile.handle),
829 moderation.ui('displayName'),
830 )
831
832 return (
833 <Toggle.Item
834 key={profile.did}
835 disabled={!enabled}
836 name={profile.did}
837 label={displayName}
838 style={[a.flex_1, a.py_sm, a.px_lg]}>
839 <View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}>
840 <ProfileCard.Header>
841 <ProfileCard.Avatar
842 profile={profile}
843 moderationOpts={moderationOpts}
844 size={44}
845 disabledPreview
846 />
847 <View>
848 <ProfileCard.Name
849 profile={profile}
850 moderationOpts={moderationOpts}
851 />
852 {enabled ? (
853 <ProfileCard.Handle profile={profile} />
854 ) : (
855 <Text
856 style={[a.leading_snug, t.atoms.text_contrast_high]}
857 numberOfLines={2}>
858 <Trans>{handle} can’t be messaged</Trans>
859 </Text>
860 )}
861 </View>
862 </ProfileCard.Header>
863 </View>
864 {enabled ? <Toggle.Checkbox /> : null}
865 </Toggle.Item>
866 )
867}
868
869function GroupChatMemberProfileCard({
870 profile,
871 moderationOpts,
872}: {
873 profile: bsky.profile.AnyProfileView
874 moderationOpts: ModerationOpts
875}) {
876 const t = useTheme()
877 const enabled = canBeMessaged(profile)
878 const handle = sanitizeHandle(profile.handle, '@')
879
880 return (
881 <View style={[a.flex_1, a.py_sm, a.px_lg, t.atoms.bg]}>
882 <ProfileCard.Header>
883 <ProfileCard.Avatar
884 profile={profile}
885 moderationOpts={moderationOpts}
886 size={44}
887 disabledPreview
888 />
889 <View style={[a.flex_1]}>
890 <ProfileCard.Name profile={profile} moderationOpts={moderationOpts} />
891 {enabled ? (
892 <ProfileCard.Handle profile={profile} />
893 ) : (
894 <Text
895 style={[a.leading_snug, t.atoms.text_contrast_high]}
896 numberOfLines={2}>
897 <Trans>{handle} can’t be messaged</Trans>
898 </Text>
899 )}
900 </View>
901 </ProfileCard.Header>
902 </View>
903 )
904}
905
906function ProfileCardSkeleton() {
907 return (
908 <View
909 style={[
910 a.flex_1,
911 a.py_md,
912 a.px_lg,
913 a.gap_md,
914 a.align_center,
915 a.flex_row,
916 ]}>
917 <ProfileCard.AvatarPlaceholder size={42} />
918 <ProfileCard.NameAndHandlePlaceholder />
919 </View>
920 )
921}
922
923function Label({message}: {message: string}) {
924 const t = useTheme()
925 return (
926 <View style={[a.px_lg, a.py_sm]}>
927 <Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
928 {message}
929 </Text>
930 </View>
931 )
932}
933
934function Empty({message}: {message: string}) {
935 const t = useTheme()
936 return (
937 <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}>
938 <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}>
939 {message}
940 </Text>
941
942 <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text>
943 </View>
944 )
945}
946
947function SearchInput({
948 value,
949 onChangeText,
950 onEscape,
951 inputRef,
952}: {
953 value: string
954 onChangeText: (text: string) => void
955 onEscape: () => void
956 inputRef: React.RefObject<TextInput | null>
957}) {
958 const t = useTheme()
959 const {t: l} = useLingui()
960 const {
961 state: hovered,
962 onIn: onMouseEnter,
963 onOut: onMouseLeave,
964 } = useInteractionState()
965 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
966 const interacted = hovered || focused
967
968 return (
969 <View
970 {...web({
971 onMouseEnter,
972 onMouseLeave,
973 })}
974 style={[a.flex_row, a.align_center, a.gap_sm]}>
975 <SearchIcon
976 size="md"
977 fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
978 />
979 <TextInput
980 // @ts-ignore bottom sheet input types issue - esb
981 ref={inputRef}
982 placeholder={l`Search for people`}
983 value={value}
984 onChangeText={onChangeText}
985 onFocus={onFocus}
986 onBlur={onBlur}
987 style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
988 placeholderTextColor={t.palette.contrast_500}
989 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
990 returnKeyType="search"
991 clearButtonMode="while-editing"
992 maxLength={50}
993 onKeyPress={({nativeEvent}) => {
994 if (nativeEvent.key === 'Escape') {
995 onEscape()
996 }
997 }}
998 autoCorrect={false}
999 autoComplete="off"
1000 autoCapitalize="none"
1001 autoFocus
1002 accessibilityLabel={l`Search profiles`}
1003 accessibilityHint={l`Searches for profiles`}
1004 />
1005 </View>
1006 )
1007}