Add flow for adding people to a group clip clop (#10255)
This commit is contained in:
@@ -0,0 +1,470 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {LayoutAnimation, type TextInput, View} from 'react-native'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||||
|
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {type ListMethods} from '#/view/com/util/List'
|
||||||
|
import {android, atoms as a, native, useTheme, web} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {canBeMessaged} from '#/components/dms/util'
|
||||||
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
||||||
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
import {ChatProfileTabs} from './ChatProfileTabs'
|
||||||
|
import {EmptyMemberList} from './components/EmptyMemberList'
|
||||||
|
import {GroupChatProfileCard} from './components/GroupChatProfileCard'
|
||||||
|
import {ProfileCardSkeleton} from './components/ProfileCardSkeleton'
|
||||||
|
import {UserLabel} from './components/UserLabel'
|
||||||
|
import {UserSearchInput} from './components/UserSearchInput'
|
||||||
|
|
||||||
|
type LabelItem = {
|
||||||
|
type: 'label'
|
||||||
|
key: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileItem = {
|
||||||
|
type: 'profile'
|
||||||
|
key: string
|
||||||
|
profile: bsky.profile.AnyProfileView
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmptyItem = {
|
||||||
|
type: 'empty'
|
||||||
|
key: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaceholderItem = {
|
||||||
|
type: 'placeholder'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorItem = {
|
||||||
|
type: 'error'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
|
||||||
|
|
||||||
|
export type State = {
|
||||||
|
groupChatDids: string[]
|
||||||
|
groupChatProfiles: bsky.profile.AnyProfileView[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| {
|
||||||
|
type: 'setDids'
|
||||||
|
groupChatDids: string[]
|
||||||
|
groupChatProfiles: bsky.profile.AnyProfileView[]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'removeDids'
|
||||||
|
groupChatDids: string[]
|
||||||
|
groupChatProfiles: bsky.profile.AnyProfileView[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'setDids': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
groupChatDids: action.groupChatDids,
|
||||||
|
groupChatProfiles: action.groupChatProfiles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'removeDids': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
groupChatDids: action.groupChatDids,
|
||||||
|
groupChatProfiles: action.groupChatProfiles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddMembersFlow({
|
||||||
|
title,
|
||||||
|
onAddMembers,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
onAddMembers: (dids: string[]) => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
const [headerHeight, setHeaderHeight] = useState(0)
|
||||||
|
const [footerHeight, setFooterHeight] = useState(0)
|
||||||
|
const listRef = useRef<ListMethods>(null)
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const inputRef = useRef<TextInput>(null)
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: results,
|
||||||
|
isError,
|
||||||
|
isFetching,
|
||||||
|
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||||
|
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||||
|
|
||||||
|
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
|
||||||
|
groupChatDids: [],
|
||||||
|
groupChatProfiles: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const onRemoveDid = useCallback(
|
||||||
|
(did: string) => {
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||||
|
dispatch({
|
||||||
|
type: 'removeDids',
|
||||||
|
groupChatDids: groupChatDids.filter(d => d !== did),
|
||||||
|
groupChatProfiles: groupChatProfiles.filter(
|
||||||
|
profile => profile.did !== did,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[groupChatDids, groupChatProfiles],
|
||||||
|
)
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
let _items: Item[] = []
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
_items.push({
|
||||||
|
type: 'empty',
|
||||||
|
key: 'empty',
|
||||||
|
message: l`We’re having network issues, try again`,
|
||||||
|
})
|
||||||
|
} else if (searchText.length) {
|
||||||
|
if (results?.length) {
|
||||||
|
for (const profile of results) {
|
||||||
|
if (profile.did === currentAccount?.did) continue
|
||||||
|
_items.push({
|
||||||
|
type: 'profile',
|
||||||
|
key: profile.did,
|
||||||
|
profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_items = _items.sort(item => {
|
||||||
|
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const placeholders: Item[] = Array(10)
|
||||||
|
.fill(0)
|
||||||
|
.map((__, i) => ({
|
||||||
|
type: 'placeholder',
|
||||||
|
key: i + '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (follows) {
|
||||||
|
for (const page of follows.pages) {
|
||||||
|
for (const profile of page.follows) {
|
||||||
|
_items.push({
|
||||||
|
type: 'profile',
|
||||||
|
key: profile.did,
|
||||||
|
profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_items = _items.sort(item => {
|
||||||
|
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_items.push(...placeholders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchText === '') {
|
||||||
|
_items.unshift({
|
||||||
|
type: 'label',
|
||||||
|
key: 'suggested',
|
||||||
|
message: l`Suggested`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return _items
|
||||||
|
}, [isError, searchText, l, results, currentAccount?.did, follows])
|
||||||
|
|
||||||
|
if (searchText && !isFetching && !items.length && !isError) {
|
||||||
|
items.push({type: 'empty', key: 'empty', message: l`No results`})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePressBack = useCallback(() => {
|
||||||
|
control.close()
|
||||||
|
}, [control])
|
||||||
|
|
||||||
|
const handlePressAdd = useCallback(() => {
|
||||||
|
onAddMembers(groupChatDids)
|
||||||
|
}, [groupChatDids, onAddMembers])
|
||||||
|
|
||||||
|
const renderItems = useCallback(
|
||||||
|
({item}: {item: Item}) => {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'label': {
|
||||||
|
return <UserLabel key={item.key} message={item.message} />
|
||||||
|
}
|
||||||
|
case 'profile': {
|
||||||
|
return (
|
||||||
|
<GroupChatProfileCard
|
||||||
|
key={item.key}
|
||||||
|
profile={item.profile}
|
||||||
|
moderationOpts={moderationOpts!}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'placeholder': {
|
||||||
|
return <ProfileCardSkeleton key={item.key} />
|
||||||
|
}
|
||||||
|
case 'empty': {
|
||||||
|
return <EmptyMemberList key={item.key} message={item.message} />
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[moderationOpts],
|
||||||
|
)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (IS_WEB) {
|
||||||
|
setImmediate(() => {
|
||||||
|
inputRef?.current?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
let buttonLabel = l`Continue to group name`
|
||||||
|
let buttonText = l`Next`
|
||||||
|
let showButton = groupChatProfiles.length > 0
|
||||||
|
let isButtonDisabled = !showButton
|
||||||
|
|
||||||
|
const showChatProfileTabs = groupChatProfiles.length > 0
|
||||||
|
|
||||||
|
const listHeader = useMemo(
|
||||||
|
() => (
|
||||||
|
<View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
web(a.pt_lg),
|
||||||
|
native(a.pt_4xl),
|
||||||
|
android({
|
||||||
|
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||||
|
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||||
|
}),
|
||||||
|
a.px_lg,
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_sm,
|
||||||
|
a.relative,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
web(a.pb_lg),
|
||||||
|
]}>
|
||||||
|
{IS_NATIVE ? (
|
||||||
|
<Button
|
||||||
|
label={l`Back`}
|
||||||
|
size="large"
|
||||||
|
shape="round"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
style={[native([a.absolute, a.z_20])]}
|
||||||
|
onPress={handlePressBack}>
|
||||||
|
<ButtonIcon icon={ArrowLeftIcon} size="lg" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_grow,
|
||||||
|
a.z_10,
|
||||||
|
a.text_lg,
|
||||||
|
a.font_bold,
|
||||||
|
a.leading_tight,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
a.text_center,
|
||||||
|
a.px_5xl,
|
||||||
|
]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{IS_WEB ? (
|
||||||
|
<Button
|
||||||
|
label={l`Close`}
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
style={[a.absolute, a.z_20, {right: -4}]}
|
||||||
|
onPress={() => control.close()}>
|
||||||
|
<ButtonIcon icon={XIcon} size="lg" />
|
||||||
|
</Button>
|
||||||
|
) : showButton ? (
|
||||||
|
<Button
|
||||||
|
label={buttonLabel}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
style={[
|
||||||
|
native([
|
||||||
|
a.absolute,
|
||||||
|
a.z_20,
|
||||||
|
{
|
||||||
|
right: 8,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]}
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
onPress={handlePressAdd}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Add</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View style={[web(a.pt_xs), native(a.pt_md)]}>
|
||||||
|
<UserSearchInput
|
||||||
|
inputRef={inputRef}
|
||||||
|
value={searchText}
|
||||||
|
onChangeText={text => {
|
||||||
|
setSearchText(text)
|
||||||
|
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||||
|
}}
|
||||||
|
onEscape={control.close}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{showChatProfileTabs ? (
|
||||||
|
<View style={[a.pb_sm, a.pt_md, t.atoms.bg]}>
|
||||||
|
<ChatProfileTabs
|
||||||
|
testID="newGroupChatMembers"
|
||||||
|
profiles={groupChatProfiles}
|
||||||
|
onRemove={onRemoveDid}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
buttonLabel,
|
||||||
|
control,
|
||||||
|
groupChatProfiles,
|
||||||
|
handlePressAdd,
|
||||||
|
handlePressBack,
|
||||||
|
isButtonDisabled,
|
||||||
|
l,
|
||||||
|
onRemoveDid,
|
||||||
|
searchText,
|
||||||
|
showButton,
|
||||||
|
showChatProfileTabs,
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
title,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setGroupChatMembers = (dids: string[]) => {
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||||
|
|
||||||
|
const added = dids.filter(d => !groupChatDids.includes(d))
|
||||||
|
const removed = groupChatDids.filter(d => !dids.includes(d))
|
||||||
|
const newDids = [
|
||||||
|
...groupChatDids.filter(d => !removed.includes(d)),
|
||||||
|
...added,
|
||||||
|
]
|
||||||
|
|
||||||
|
const kept = groupChatProfiles.filter(p => dids.includes(p.did))
|
||||||
|
const keptDids = new Set(kept.map(p => p.did))
|
||||||
|
const addedProfiles = items
|
||||||
|
.filter(
|
||||||
|
(item): item is ProfileItem =>
|
||||||
|
item.type === 'profile' &&
|
||||||
|
dids.includes(item.profile.did) &&
|
||||||
|
!keptDids.has(item.profile.did),
|
||||||
|
)
|
||||||
|
.map(item => item.profile)
|
||||||
|
.sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did))
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'setDids',
|
||||||
|
groupChatDids: newDids,
|
||||||
|
groupChatProfiles: [...kept, ...addedProfiles],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toggle.Group
|
||||||
|
values={groupChatDids}
|
||||||
|
onChange={setGroupChatMembers}
|
||||||
|
type="checkbox"
|
||||||
|
label={l`Add group chat members`}
|
||||||
|
style={web([a.contents])}>
|
||||||
|
<Dialog.InnerFlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={items}
|
||||||
|
renderItem={renderItems}
|
||||||
|
ListHeaderComponent={listHeader}
|
||||||
|
stickyHeaderIndices={[0]}
|
||||||
|
keyExtractor={(item: Item) => item.key}
|
||||||
|
style={[
|
||||||
|
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||||
|
native({height: '100%'}),
|
||||||
|
]}
|
||||||
|
webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]}
|
||||||
|
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||||
|
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
footer={
|
||||||
|
IS_WEB ? (
|
||||||
|
<Dialog.FlatListFooter
|
||||||
|
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||||
|
<Button
|
||||||
|
label={l`Back`}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
onPress={handlePressBack}>
|
||||||
|
<ButtonIcon icon={ArrowLeftIcon} size="md" />
|
||||||
|
<ButtonText>
|
||||||
|
{' '}
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
label={buttonLabel}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
onPress={handlePressAdd}>
|
||||||
|
<ButtonText>{buttonText} </ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Dialog.FlatListFooter>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Toggle.Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {LayoutAnimation, TextInput, View} from 'react-native'
|
import {LayoutAnimation, type TextInput, View} from 'react-native'
|
||||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
@@ -23,13 +23,11 @@ import * as Dialog from '#/components/Dialog'
|
|||||||
import {canBeMessaged} from '#/components/dms/util'
|
import {canBeMessaged} from '#/components/dms/util'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon,
|
ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon,
|
||||||
ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon,
|
ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon,
|
||||||
} from '#/components/icons/Arrow'
|
} from '#/components/icons/Arrow'
|
||||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
|
||||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
|
|
||||||
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
@@ -37,6 +35,11 @@ import {Text} from '#/components/Typography'
|
|||||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
import {ChatProfileTabs} from './ChatProfileTabs'
|
import {ChatProfileTabs} from './ChatProfileTabs'
|
||||||
|
import {EmptyMemberList} from './components/EmptyMemberList'
|
||||||
|
import {GroupChatProfileCard} from './components/GroupChatProfileCard'
|
||||||
|
import {ProfileCardSkeleton} from './components/ProfileCardSkeleton'
|
||||||
|
import {UserLabel} from './components/UserLabel'
|
||||||
|
import {UserSearchInput} from './components/UserSearchInput'
|
||||||
|
|
||||||
type NewGroupChatItem = {
|
type NewGroupChatItem = {
|
||||||
type: 'newGroupChat'
|
type: 'newGroupChat'
|
||||||
@@ -49,7 +52,7 @@ type LabelItem = {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProfileItem = {
|
type ProfileItem = {
|
||||||
type: 'profile'
|
type: 'profile'
|
||||||
key: string
|
key: string
|
||||||
profile: bsky.profile.AnyProfileView
|
profile: bsky.profile.AnyProfileView
|
||||||
@@ -184,6 +187,7 @@ function reducer(state: State, action: Action): State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InitiateChatFlow({
|
export function InitiateChatFlow({
|
||||||
title,
|
title,
|
||||||
onSelectChat,
|
onSelectChat,
|
||||||
@@ -382,7 +386,7 @@ export function InitiateChatFlow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'label': {
|
case 'label': {
|
||||||
return <Label key={item.key} message={item.message} />
|
return <UserLabel key={item.key} message={item.message} />
|
||||||
}
|
}
|
||||||
case 'profile': {
|
case 'profile': {
|
||||||
switch (chatState) {
|
switch (chatState) {
|
||||||
@@ -417,7 +421,7 @@ export function InitiateChatFlow({
|
|||||||
return <ProfileCardSkeleton key={item.key} />
|
return <ProfileCardSkeleton key={item.key} />
|
||||||
}
|
}
|
||||||
case 'empty': {
|
case 'empty': {
|
||||||
return <Empty key={item.key} message={item.message} />
|
return <EmptyMemberList key={item.key} message={item.message} />
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
@@ -560,7 +564,7 @@ export function InitiateChatFlow({
|
|||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<SearchInput
|
<UserSearchInput
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChangeText={text => {
|
onChangeText={text => {
|
||||||
@@ -813,59 +817,6 @@ function DefaultProfileCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupChatProfileCard({
|
|
||||||
profile,
|
|
||||||
moderationOpts,
|
|
||||||
}: {
|
|
||||||
profile: bsky.profile.AnyProfileView
|
|
||||||
moderationOpts: ModerationOpts
|
|
||||||
}) {
|
|
||||||
const t = useTheme()
|
|
||||||
const enabled = canBeMessaged(profile)
|
|
||||||
const moderation = moderateProfile(profile, moderationOpts)
|
|
||||||
const handle = sanitizeHandle(profile.handle, '@')
|
|
||||||
const displayName = sanitizeDisplayName(
|
|
||||||
profile.displayName || sanitizeHandle(profile.handle),
|
|
||||||
moderation.ui('displayName'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Toggle.Item
|
|
||||||
key={profile.did}
|
|
||||||
disabled={!enabled}
|
|
||||||
name={profile.did}
|
|
||||||
label={displayName}
|
|
||||||
style={[a.flex_1, a.py_sm, a.px_lg]}>
|
|
||||||
<View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}>
|
|
||||||
<ProfileCard.Header>
|
|
||||||
<ProfileCard.Avatar
|
|
||||||
profile={profile}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
size={44}
|
|
||||||
disabledPreview
|
|
||||||
/>
|
|
||||||
<View>
|
|
||||||
<ProfileCard.Name
|
|
||||||
profile={profile}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
|
||||||
{enabled ? (
|
|
||||||
<ProfileCard.Handle profile={profile} />
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
style={[a.leading_snug, t.atoms.text_contrast_high]}
|
|
||||||
numberOfLines={2}>
|
|
||||||
<Trans>{handle} can’t be messaged</Trans>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ProfileCard.Header>
|
|
||||||
</View>
|
|
||||||
{enabled ? <Toggle.Checkbox /> : null}
|
|
||||||
</Toggle.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupChatMemberProfileCard({
|
function GroupChatMemberProfileCard({
|
||||||
profile,
|
profile,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
@@ -902,106 +853,3 @@ function GroupChatMemberProfileCard({
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileCardSkeleton() {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_1,
|
|
||||||
a.py_md,
|
|
||||||
a.px_lg,
|
|
||||||
a.gap_md,
|
|
||||||
a.align_center,
|
|
||||||
a.flex_row,
|
|
||||||
]}>
|
|
||||||
<ProfileCard.AvatarPlaceholder size={42} />
|
|
||||||
<ProfileCard.NameAndHandlePlaceholder />
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Label({message}: {message: string}) {
|
|
||||||
const t = useTheme()
|
|
||||||
return (
|
|
||||||
<View style={[a.px_lg, a.py_sm]}>
|
|
||||||
<Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
|
||||||
{message}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Empty({message}: {message: string}) {
|
|
||||||
const t = useTheme()
|
|
||||||
return (
|
|
||||||
<View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}>
|
|
||||||
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}>
|
|
||||||
{message}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchInput({
|
|
||||||
value,
|
|
||||||
onChangeText,
|
|
||||||
onEscape,
|
|
||||||
inputRef,
|
|
||||||
}: {
|
|
||||||
value: string
|
|
||||||
onChangeText: (text: string) => void
|
|
||||||
onEscape: () => void
|
|
||||||
inputRef: React.RefObject<TextInput | null>
|
|
||||||
}) {
|
|
||||||
const t = useTheme()
|
|
||||||
const {t: l} = useLingui()
|
|
||||||
const {
|
|
||||||
state: hovered,
|
|
||||||
onIn: onMouseEnter,
|
|
||||||
onOut: onMouseLeave,
|
|
||||||
} = useInteractionState()
|
|
||||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
|
||||||
const interacted = hovered || focused
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
{...web({
|
|
||||||
onMouseEnter,
|
|
||||||
onMouseLeave,
|
|
||||||
})}
|
|
||||||
style={[a.flex_row, a.align_center, a.gap_sm]}>
|
|
||||||
<SearchIcon
|
|
||||||
size="md"
|
|
||||||
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
// @ts-ignore bottom sheet input types issue - esb
|
|
||||||
ref={inputRef}
|
|
||||||
placeholder={l`Search for people`}
|
|
||||||
value={value}
|
|
||||||
onChangeText={onChangeText}
|
|
||||||
onFocus={onFocus}
|
|
||||||
onBlur={onBlur}
|
|
||||||
style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
|
|
||||||
placeholderTextColor={t.palette.contrast_500}
|
|
||||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
|
||||||
returnKeyType="search"
|
|
||||||
clearButtonMode="while-editing"
|
|
||||||
maxLength={50}
|
|
||||||
onKeyPress={({nativeEvent}) => {
|
|
||||||
if (nativeEvent.key === 'Escape') {
|
|
||||||
onEscape()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoCorrect={false}
|
|
||||||
autoComplete="off"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoFocus
|
|
||||||
accessibilityLabel={l`Search profiles`}
|
|
||||||
accessibilityHint={l`Searches for profiles`}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function EmptyMemberList({message}: {message: string}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}>
|
||||||
|
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||||
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {canBeMessaged} from '#/components/dms/util'
|
||||||
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
export function GroupChatProfileCard({
|
||||||
|
profile,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
profile: bsky.profile.AnyProfileView
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const enabled = canBeMessaged(profile)
|
||||||
|
const moderation = moderateProfile(profile, moderationOpts)
|
||||||
|
const handle = sanitizeHandle(profile.handle, '@')
|
||||||
|
const displayName = sanitizeDisplayName(
|
||||||
|
profile.displayName || sanitizeHandle(profile.handle),
|
||||||
|
moderation.ui('displayName'),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toggle.Item
|
||||||
|
key={profile.did}
|
||||||
|
disabled={!enabled}
|
||||||
|
name={profile.did}
|
||||||
|
label={displayName}
|
||||||
|
style={[a.flex_1, a.py_sm, a.px_lg]}>
|
||||||
|
<View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}>
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.Avatar
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
size={44}
|
||||||
|
disabledPreview
|
||||||
|
/>
|
||||||
|
<View>
|
||||||
|
<ProfileCard.Name
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
{enabled ? (
|
||||||
|
<ProfileCard.Handle profile={profile} />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[a.leading_snug, t.atoms.text_contrast_high]}
|
||||||
|
numberOfLines={2}>
|
||||||
|
<Trans>{handle} can’t be messaged</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ProfileCard.Header>
|
||||||
|
</View>
|
||||||
|
{enabled ? <Toggle.Checkbox /> : null}
|
||||||
|
</Toggle.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
|
||||||
|
export function ProfileCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.py_md,
|
||||||
|
a.px_lg,
|
||||||
|
a.gap_md,
|
||||||
|
a.align_center,
|
||||||
|
a.flex_row,
|
||||||
|
]}>
|
||||||
|
<ProfileCard.AvatarPlaceholder size={42} />
|
||||||
|
<ProfileCard.NameAndHandlePlaceholder />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function UserLabel({message}: {message: string}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View style={[a.px_lg, a.py_sm]}>
|
||||||
|
<Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {TextInput, View} from 'react-native'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
|
||||||
|
|
||||||
|
export function UserSearchInput({
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
onEscape,
|
||||||
|
inputRef,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChangeText: (text: string) => void
|
||||||
|
onEscape: () => void
|
||||||
|
inputRef: React.RefObject<TextInput | null>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const {
|
||||||
|
state: hovered,
|
||||||
|
onIn: onMouseEnter,
|
||||||
|
onOut: onMouseLeave,
|
||||||
|
} = useInteractionState()
|
||||||
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
|
const interacted = hovered || focused
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...web({
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
})}
|
||||||
|
style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<SearchIcon
|
||||||
|
size="md"
|
||||||
|
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
// @ts-ignore bottom sheet input types issue - esb
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder={l`Search for people`}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
|
||||||
|
placeholderTextColor={t.palette.contrast_500}
|
||||||
|
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||||
|
returnKeyType="search"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
maxLength={50}
|
||||||
|
onKeyPress={({nativeEvent}) => {
|
||||||
|
if (nativeEvent.key === 'Escape') {
|
||||||
|
onEscape()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoFocus
|
||||||
|
accessibilityLabel={l`Search profiles`}
|
||||||
|
accessibilityHint={l`Searches for profiles`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,7 +31,8 @@ import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
|||||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||||
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
|
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
|
||||||
import type * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
|
||||||
import {Error} from '#/components/Error'
|
import {Error} from '#/components/Error'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
@@ -138,11 +139,11 @@ function SettingsInner() {
|
|||||||
},
|
},
|
||||||
...[...data]
|
...[...data]
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aIsAdmin = a.did === primaryMember?.did
|
const aIsOwner = a.did === primaryMember?.did
|
||||||
const bIsAdmin = b.did === primaryMember?.did
|
const bIsOwner = b.did === primaryMember?.did
|
||||||
const aIsSelf = a.did === currentAccount?.did
|
const aIsSelf = a.did === currentAccount?.did
|
||||||
const bIsSelf = b.did === currentAccount?.did
|
const bIsSelf = b.did === currentAccount?.did
|
||||||
if (aIsAdmin !== bIsAdmin) return aIsAdmin ? -1 : 1
|
if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1
|
||||||
if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
|
if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
|
||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
@@ -216,6 +217,14 @@ function MembersAndRequests({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const convoState = useConvo()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
const isOwner =
|
||||||
|
currentAccount?.did == null
|
||||||
|
? false
|
||||||
|
: convoState.getPrimaryMember?.()?.did === currentAccount.did
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}>
|
<View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}>
|
||||||
<View style={[a.flex_row, a.align_center]}>
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
@@ -229,7 +238,7 @@ function MembersAndRequests({
|
|||||||
{color: t.palette.contrast_500},
|
{color: t.palette.contrast_500},
|
||||||
]}>{l`${memberCount}/${MEMBER_LIMIT}`}</Text>
|
]}>{l`${memberCount}/${MEMBER_LIMIT}`}</Text>
|
||||||
</View>
|
</View>
|
||||||
{requestCount > 0 ? (
|
{isOwner && requestCount > 0 ? (
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
label={l`View incoming group chat requests`}
|
label={l`View incoming group chat requests`}
|
||||||
style={[a.text_sm, a.text_right, a.font_semi_bold]}
|
style={[a.text_sm, a.text_right, a.font_semi_bold]}
|
||||||
@@ -246,63 +255,98 @@ function MembersAndRequests({
|
|||||||
|
|
||||||
function AddMembersLink() {
|
function AddMembersLink() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const convoState = useConvo()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
const addMembersControl = Dialog.useDialogControl()
|
||||||
|
|
||||||
|
const isOwner =
|
||||||
|
currentAccount?.did == null
|
||||||
|
? false
|
||||||
|
: convoState.getPrimaryMember?.()?.did === currentAccount.did
|
||||||
|
|
||||||
|
if (!isOwner) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SubtleHoverWrapper>
|
<>
|
||||||
<View
|
<SubtleHoverWrapper>
|
||||||
style={[
|
<View
|
||||||
a.mx_xl,
|
style={[
|
||||||
{
|
a.mx_xl,
|
||||||
marginTop: ROW_SPACING,
|
{
|
||||||
marginBottom: ROW_SPACING,
|
marginTop: ROW_SPACING,
|
||||||
},
|
marginBottom: ROW_SPACING,
|
||||||
]}>
|
},
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={({pressed}) => [
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_between,
|
|
||||||
pressed && web({outline: 'none'}),
|
|
||||||
]}>
|
]}>
|
||||||
{({pressed}) => (
|
<Pressable
|
||||||
<>
|
accessibilityRole="button"
|
||||||
<View>
|
style={({pressed}) => [
|
||||||
<View style={[a.flex_row, a.align_center]}>
|
a.flex_row,
|
||||||
<View
|
a.align_center,
|
||||||
style={[
|
a.justify_between,
|
||||||
a.flex_row,
|
pressed && web({outline: 'none'}),
|
||||||
a.align_center,
|
]}
|
||||||
a.justify_center,
|
onPress={() => addMembersControl.open()}>
|
||||||
a.p_lg,
|
{({pressed}) => (
|
||||||
a.rounded_full,
|
<>
|
||||||
pressed
|
<View>
|
||||||
? t.atoms.bg_contrast_100
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
: t.atoms.bg_contrast_50,
|
<View
|
||||||
{
|
style={[
|
||||||
height: 48,
|
a.flex_row,
|
||||||
width: 48,
|
a.align_center,
|
||||||
},
|
a.justify_center,
|
||||||
]}>
|
a.p_lg,
|
||||||
<PlusIcon style={[t.atoms.text_contrast_high]} size="sm" />
|
a.rounded_full,
|
||||||
|
pressed
|
||||||
|
? t.atoms.bg_contrast_100
|
||||||
|
: t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
height: 48,
|
||||||
|
width: 48,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<PlusIcon
|
||||||
|
style={[t.atoms.text_contrast_high]}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.font_semi_bold,
|
||||||
|
a.pl_sm,
|
||||||
|
t.atoms.text,
|
||||||
|
]}>
|
||||||
|
<Trans>Add members</Trans>
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.text_md,
|
|
||||||
a.font_semi_bold,
|
|
||||||
a.pl_sm,
|
|
||||||
t.atoms.text,
|
|
||||||
]}>
|
|
||||||
<Trans>Add members</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" />
|
||||||
<ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" />
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</Pressable>
|
||||||
</Pressable>
|
</View>
|
||||||
</View>
|
</SubtleHoverWrapper>
|
||||||
</SubtleHoverWrapper>
|
|
||||||
|
<Dialog.Outer
|
||||||
|
control={addMembersControl}
|
||||||
|
testID="addChatMembersDialog"
|
||||||
|
nativeOptions={{fullHeight: true}}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<AddMembersFlow
|
||||||
|
title={l`Add members`}
|
||||||
|
onAddMembers={(_dids: string[]) => {
|
||||||
|
// TODO Add members here
|
||||||
|
addMembersControl.close()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Dialog.Outer>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {useSession} from '#/state/session'
|
|||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
|
||||||
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
||||||
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
@@ -14,8 +16,17 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const addMembersControl = Dialog.useDialogControl()
|
||||||
|
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
const isOwner =
|
||||||
|
currentAccount?.did == null
|
||||||
|
? false
|
||||||
|
: convoState.getPrimaryMember?.()?.did === currentAccount.did
|
||||||
|
// TODO Get this from @api/atproto - dsb
|
||||||
|
const isLinkEnabled = false
|
||||||
|
|
||||||
const groupName = convoState.getGroupInfo?.()?.name
|
const groupName = convoState.getGroupInfo?.()?.name
|
||||||
|
|
||||||
const members = (convoState?.convo?.members ?? []).filter(
|
const members = (convoState?.convo?.members ?? []).filter(
|
||||||
@@ -47,49 +58,78 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showButtons = isOwner || isLinkEnabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.align_center, a.justify_center]}>
|
<>
|
||||||
<AvatarBubbles animate={true} profiles={members} />
|
<View style={[a.align_center, a.justify_center]}>
|
||||||
{groupName ? (
|
<AvatarBubbles animate={true} profiles={members} />
|
||||||
<Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}>
|
{groupName ? (
|
||||||
{groupName}
|
<Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}>
|
||||||
</Text>
|
{groupName}
|
||||||
) : null}
|
</Text>
|
||||||
{names ? (
|
) : null}
|
||||||
<Text style={[a.text_sm, a.mt_xs, t.atoms.text_contrast_high]}>
|
{names ? (
|
||||||
{names}
|
<Text
|
||||||
</Text>
|
style={[
|
||||||
) : null}
|
a.text_sm,
|
||||||
<View
|
a.mt_xs,
|
||||||
style={[
|
t.atoms.text_contrast_high,
|
||||||
a.flex_row,
|
showButtons ? null : a.mb_4xl,
|
||||||
a.align_center,
|
]}>
|
||||||
a.justify_center,
|
{names}
|
||||||
a.gap_sm,
|
</Text>
|
||||||
a.mt_lg,
|
) : null}
|
||||||
a.mb_4xl,
|
{showButtons ? (
|
||||||
]}>
|
<View
|
||||||
<Button
|
style={[
|
||||||
color="secondary"
|
a.flex_row,
|
||||||
size="small"
|
a.align_center,
|
||||||
label={l`Click here to add people to this group chat`}
|
a.justify_center,
|
||||||
onPress={() => {}}>
|
a.gap_sm,
|
||||||
<ButtonIcon icon={PersonPlusIcon} />
|
a.mt_lg,
|
||||||
<ButtonText>
|
a.mb_4xl,
|
||||||
<Trans>Add people</Trans>
|
]}>
|
||||||
</ButtonText>
|
{isOwner ? (
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
color="secondary"
|
||||||
color="secondary"
|
size="small"
|
||||||
size="small"
|
label={l`Click here to add people to this group chat`}
|
||||||
label={l`Click here to view or create an invite link for this group chat`}
|
onPress={() => addMembersControl.open()}>
|
||||||
onPress={() => {}}>
|
<ButtonIcon icon={PersonPlusIcon} />
|
||||||
<ButtonIcon icon={ChainLinkIcon} />
|
<ButtonText>
|
||||||
<ButtonText>
|
<Trans>Add people</Trans>
|
||||||
<Trans>Invite link</Trans>
|
</ButtonText>
|
||||||
</ButtonText>
|
</Button>
|
||||||
</Button>
|
) : null}
|
||||||
|
{isOwner || isLinkEnabled ? (
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
label={l`Click here to view or create an invite link for this group chat`}
|
||||||
|
onPress={() => {}}>
|
||||||
|
<ButtonIcon icon={ChainLinkIcon} />
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Invite link</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<Dialog.Outer
|
||||||
|
control={addMembersControl}
|
||||||
|
testID="addChatMembersDialog"
|
||||||
|
nativeOptions={{fullHeight: true}}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<AddMembersFlow
|
||||||
|
title={l`Add people`}
|
||||||
|
onAddMembers={(_dids: string[]) => {
|
||||||
|
// TODO Add members here
|
||||||
|
addMembersControl.close()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Dialog.Outer>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user