WIP submit dialog

This commit is contained in:
Dan Abramov
2024-10-23 18:50:36 +01:00
parent f4caa30399
commit 5936ba5148
+68 -307
View File
@@ -1,35 +1,27 @@
import React from 'react' import React from 'react'
import {TextInput, View} from 'react-native' import {TextInput, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useProfilesQuery} from '#/state/queries/profile'
import {ListMethods} from '#/view/com/util/List' import {ListMethods} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf' import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {useFeedsAutocomplete} from '#/components/Composer/FeedsSelector/useFeedsAutocomplete'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
const AVI_SIZE = 30
const AVI_BORDER = 1
type Item = type Item =
| { | {
type: 'profile' type: 'feed'
key: string key: string
checked: boolean checked: boolean
profile: AppBskyActorDefs.ProfileView feed: AppBskyFeedDefs.GeneratorView
} }
| { | {
type: 'empty' type: 'empty'
@@ -45,306 +37,79 @@ type Item =
key: string key: string
} }
export function UserSelectButton({
dids,
onChangeDids,
}: {
dids: string[]
onChangeDids: (dids: string[]) => void
}) {
const t = useTheme()
const {_} = useLingui()
const control = Dialog.useDialogControl()
const {data: profiles} = useProfilesQuery({
handles: dids,
})
const moderationOpts = useModerationOpts()
const slice =
profiles?.profiles?.slice(0, 3).map(f => {
if (!moderationOpts) {
return {
profile: {
...f,
displayName: f.displayName || f.handle,
},
moderation: null,
}
}
const moderation = moderateProfile(f, moderationOpts)
return {
profile: {
...f,
displayName: sanitizeDisplayName(
f.displayName || f.handle,
moderation.ui('displayName'),
),
},
moderation,
}
}) || []
const serverCount = dids.length
const textStyle = [a.text_sm]
return (
<>
<Button label={_(msg`Select users`)} onPress={control.open}>
{ctx => (
<View
style={[
a.w_full,
a.flex_row,
a.align_start,
a.p_lg,
a.border,
a.rounded_sm,
t.atoms.border_contrast_low,
ctx.hovered || ctx.pressed
? [t.atoms.border_contrast_medium]
: [],
]}>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.text_md, a.font_bold, a.leading_tight]}>
{dids.length ? (
<Trans>
{dids.length}{' '}
<Plural value={dids.length} one="user" other="users" />{' '}
selected
</Trans>
) : (
<Trans>No users selected</Trans>
)}
</Text>
{slice.length ? (
<Text style={[textStyle]} numberOfLines={2}>
{slice.length >= 2 ? (
// 2-n followers, including blocks
serverCount > 2 ? (
<Trans>
Including{' '}
<Text
emoji
key={slice[0].profile.did}
style={textStyle}>
{slice[0].profile.displayName}
</Text>
,{' '}
<Text
emoji
key={slice[1].profile.did}
style={textStyle}>
{slice[1].profile.displayName}
</Text>
, and{' '}
<Plural
value={serverCount - 2}
one="# other"
other="# others"
/>
</Trans>
) : (
// only 2
<Trans>
Including{' '}
<Text
emoji
key={slice[0].profile.did}
style={textStyle}>
{slice[0].profile.displayName}
</Text>{' '}
and{' '}
<Text
emoji
key={slice[1].profile.did}
style={textStyle}>
{slice[1].profile.displayName}
</Text>
</Trans>
)
) : serverCount > 1 ? (
// 1-n followers, including blocks
<Trans>
Including{' '}
<Text emoji key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>{' '}
and{' '}
<Plural
value={serverCount - 1}
one="# other"
other="# others"
/>
</Trans>
) : (
// only 1
<Trans>
Including{' '}
<Text emoji key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>
</Trans>
)}
</Text>
) : (
<Text style={[a.text_sm, a.leading_snug]}>
Your feed will not automatically pull posts from any users.
</Text>
)}
</View>
<View style={[a.flex_row, a.justify_end]}>
<View
style={[
{
height: AVI_SIZE,
width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap,
},
]}>
{slice.map(({profile: prof, moderation}, i) => (
<View
key={prof.did}
style={[
a.absolute,
a.rounded_full,
{
borderWidth: AVI_BORDER,
borderColor: t.atoms.bg.backgroundColor,
width: AVI_SIZE + AVI_BORDER * 2,
height: AVI_SIZE + AVI_BORDER * 2,
left: i * a.gap_md.gap,
zIndex: AVI_BORDER - i,
},
]}>
<UserAvatar
size={AVI_SIZE}
avatar={prof.avatar}
moderation={moderation?.ui('avatar')}
/>
</View>
))}
</View>
</View>
</View>
)}
</Button>
<Dialog.Outer control={control}>
<Dialog.Handle />
<FeedSelectDialog
control={control}
dids={dids}
onChangeDids={onChangeDids}
profiles={profiles?.profiles || []}
/>
</Dialog.Outer>
</>
)
}
export function FeedSelectDialog({ export function FeedSelectDialog({
control, control,
profiles,
dids,
onChangeDids,
}: { }: {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
profiles: AppBskyActorDefs.ProfileView[]
dids: string[]
onChangeDids: (dids: string[]) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const moderationOpts = useModerationOpts()
const listRef = React.useRef<ListMethods>(null) const listRef = React.useRef<ListMethods>(null)
const inputRef = React.useRef<TextInput>(null) const inputRef = React.useRef<TextInput>(null)
const [searchText, setSearchText] = React.useState('') const [uris, setUris] = React.useState<string[]>([])
const { const {
data: results, query: searchText,
isError, suggestions: results,
isFetching, feeds,
} = useActorAutocompleteQuery(searchText, true, 12) setQuery: setSearchText,
} = useFeedsAutocomplete()
const onToggleUser = React.useCallback( const onToggleFeed = React.useCallback(
(did: string, checked: boolean) => { (uri: string, checked: boolean) => {
if (checked) { if (checked) {
onChangeDids(Array.from(new Set([did, ...dids]))) setUris(Array.from(new Set([uri, ...uris])))
} else { } else {
onChangeDids(dids.filter(d => d !== did)) setUris(uris.filter(u => u !== uri))
} }
}, },
[dids, onChangeDids], [uris],
) )
const items = React.useMemo(() => { const items = React.useMemo(() => {
let _items: Item[] = [] let _items: Item[] = []
if (isError) { if (searchText.length) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else if (searchText.length) {
if (results?.length) { if (results?.length) {
for (const profile of results) { for (const feed of results) {
_items.push({ _items.push({
type: 'profile', type: 'feed',
key: profile.did, key: feed.uri,
checked: dids.includes(profile.did), checked: uris.includes(feed.uri),
profile, feed,
}) })
} }
// _items = _items.sort(item => {
// // @ts-ignore
// return item.enabled ? -1 : 1
// })
} }
} else { } else {
if (profiles.length) { if (feeds?.length) {
for (const profile of profiles) { for (const feed of feeds) {
_items.push({ _items.push({
type: 'profile', type: 'feed',
key: profile.did, key: feed.uri,
checked: dids.includes(profile.did), checked: uris.includes(feed.uri),
profile, feed,
}) })
} }
// _items = _items.sort(item => {
// // @ts-ignore
// return item.checked ? -1 : 1
// })
} else {
const placeholders: Item[] = Array(10)
.fill(0)
.map((__, i) => ({
type: 'placeholder',
key: i + '',
}))
_items.push(...placeholders)
} }
} }
return _items return _items
}, [_, dids, profiles, searchText, results, isError]) }, [results, uris, feeds, searchText])
if (searchText && !isFetching && !items.length && !isError) { if (searchText && !items.length) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
} }
const renderItems = React.useCallback( const renderItems = React.useCallback(
({item}: {item: Item}) => { ({item}: {item: Item}) => {
switch (item.type) { switch (item.type) {
case 'profile': { case 'feed': {
return ( return (
<ProfileCard <FeedCard
key={item.key} key={item.key}
checked={item.checked} checked={item.checked}
profile={item.profile} feed={item.feed}
moderationOpts={moderationOpts!} onToggle={onToggleFeed}
onToggle={onToggleUser}
/> />
) )
} }
@@ -358,7 +123,7 @@ export function FeedSelectDialog({
return null return null
} }
}, },
[moderationOpts, onToggleUser], [onToggleFeed],
) )
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
@@ -396,21 +161,29 @@ export function FeedSelectDialog({
/> />
</View> </View>
{isWeb && ( <Button
<Button label={_(msg`Submit`)}
label={_(msg`Close`)} size="large"
size="small" shape="default"
shape="round" variant={isWeb ? 'ghost' : 'solid'}
variant={isWeb ? 'ghost' : 'solid'} color="primary"
color="secondary" style={[a.z_20]}
style={[a.z_20]} onPress={() => {
onPress={() => control.close()}> console.log(uris)
<ButtonIcon icon={X} size="md" /> }}>
</Button> <ButtonText>Submit</ButtonText>
)} </Button>
</View> </View>
) )
}, [t.atoms.border_contrast_low, t.atoms.bg, _, searchText, control]) }, [
t.atoms.border_contrast_low,
t.atoms.bg,
uris,
_,
searchText,
control,
setSearchText,
])
return ( return (
<Dialog.InnerFlatList <Dialog.InnerFlatList
@@ -431,37 +204,30 @@ export function FeedSelectDialog({
) )
} }
function ProfileCard({ function FeedCard({
checked, checked,
profile, feed,
moderationOpts,
onToggle, onToggle,
}: { }: {
checked: boolean checked: boolean
profile: AppBskyActorDefs.ProfileView feed: AppBskyFeedDefs.GeneratorView
moderationOpts: ModerationOpts onToggle: (uri: string, checked: boolean) => void
onToggle: (did: string, checked: boolean) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const moderation = moderateProfile(profile, moderationOpts) const displayName = feed.displayName
const handle = sanitizeHandle(profile.handle, '@')
const displayName = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const handleOnPress = React.useCallback( const handleOnPress = React.useCallback(
(selected: boolean) => { (selected: boolean) => {
onToggle(profile.did, selected) onToggle(feed.uri, selected)
}, },
[onToggle, profile.did], [onToggle, feed.uri],
) )
return ( return (
<Toggle.Item <Toggle.Item
label={_(msg`Add ${displayName} to feed`)} label={_(msg`Add post to ${displayName}`)}
name={_(msg`Add ${displayName} to feed`)} name={_(msg`Add post to ${displayName}`)}
value={checked} value={checked}
onChange={handleOnPress}> onChange={handleOnPress}>
{({hovered, pressed, focused}) => ( {({hovered, pressed, focused}) => (
@@ -479,12 +245,7 @@ function ProfileCard({
? t.atoms.bg_contrast_50 ? t.atoms.bg_contrast_50
: t.atoms.bg, : t.atoms.bg,
]}> ]}>
<UserAvatar <UserAvatar size={42} avatar={feed.avatar} type="algo" />
size={42}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={[a.flex_1, a.gap_2xs]}> <View style={[a.flex_1, a.gap_2xs]}>
<Text <Text
style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]} style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
@@ -501,7 +262,7 @@ function ProfileCard({
]} ]}
numberOfLines={1} numberOfLines={1}
emoji> emoji>
{handle} by @{feed.creator.handle}
</Text> </Text>
</View> </View>
<Toggle.Checkbox /> <Toggle.Checkbox />