WIP submit dialog
This commit is contained in:
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user