diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 660cdfcf6c..656817b5ff 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -38,6 +38,7 @@ import { useProgressGuideControls, } from '#/state/shell/progress-guide' import {atoms as a, useTheme} from '#/alf' +import * as Dialog from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' @@ -47,6 +48,7 @@ import {formatCount} from '../numeric/format' import {Text} from '../text/Text' import * as Toast from '../Toast' import {RepostButton} from './RepostButton' +import {FeedSelectDialog} from './SubmitButton' let PostCtrls = ({ big, @@ -85,6 +87,7 @@ let PostCtrls = ({ const {sendInteraction} = useFeedFeedbackContext() const {captureAction} = useProgressGuideControls() const playHaptic = useHaptics() + const submitControl = Dialog.useDialogControl() const isBlocked = Boolean( post.author.viewer?.blocking || post.author.viewer?.blockedBy || @@ -224,6 +227,10 @@ let PostCtrls = ({ isBlocked, ]) + const onSubmit = () => { + submitControl.open() + } + const onShare = useCallback(() => { const urip = new AtUri(post.uri) const href = makeProfileLink(post.author, 'post', urip.rkey) @@ -293,7 +300,7 @@ let PostCtrls = ({ repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)} onRepost={onRepost} onQuote={onQuote} - onSubmit={onShare /* TODO */} + onSubmit={onSubmit} big={big} embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} /> @@ -400,6 +407,15 @@ let PostCtrls = ({ )} + + + {}} + profiles={[]} + /> + ) } diff --git a/src/view/com/util/post-ctrls/SubmitButton.tsx b/src/view/com/util/post-ctrls/SubmitButton.tsx new file mode 100644 index 0000000000..df641b5373 --- /dev/null +++ b/src/view/com/util/post-ctrls/SubmitButton.tsx @@ -0,0 +1,629 @@ +import React from 'react' +import {TextInput, View} from 'react-native' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +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 {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, native, useTheme, web} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as Toggle from '#/components/forms/Toggle' +import {useInteractionState} from '#/components/hooks/useInteractionState' +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' + +const AVI_SIZE = 30 +const AVI_BORDER = 1 + +type Item = + | { + type: 'profile' + key: string + checked: boolean + profile: AppBskyActorDefs.ProfileView + } + | { + type: 'empty' + key: string + message: string + } + | { + type: 'placeholder' + key: string + } + | { + type: 'error' + 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 ( + <> + + + + + + + + ) +} + +export function FeedSelectDialog({ + control, + profiles, + dids, + onChangeDids, +}: { + control: Dialog.DialogOuterProps['control'] + profiles: AppBskyActorDefs.ProfileView[] + dids: string[] + onChangeDids: (dids: string[]) => void +}) { + const t = useTheme() + const {_} = useLingui() + const moderationOpts = useModerationOpts() + const listRef = React.useRef(null) + const inputRef = React.useRef(null) + + const [searchText, setSearchText] = React.useState('') + + const { + data: results, + isError, + isFetching, + } = useActorAutocompleteQuery(searchText, true, 12) + + const onToggleUser = React.useCallback( + (did: string, checked: boolean) => { + if (checked) { + onChangeDids(Array.from(new Set([did, ...dids]))) + } else { + onChangeDids(dids.filter(d => d !== did)) + } + }, + [dids, onChangeDids], + ) + + const items = React.useMemo(() => { + let _items: Item[] = [] + + if (isError) { + _items.push({ + type: 'empty', + key: 'empty', + message: _(msg`We're having network issues, try again`), + }) + } else if (searchText.length) { + if (results?.length) { + for (const profile of results) { + _items.push({ + type: 'profile', + key: profile.did, + checked: dids.includes(profile.did), + profile, + }) + } + + // _items = _items.sort(item => { + // // @ts-ignore + // return item.enabled ? -1 : 1 + // }) + } + } else { + if (profiles.length) { + for (const profile of profiles) { + _items.push({ + type: 'profile', + key: profile.did, + checked: dids.includes(profile.did), + profile, + }) + } + + // _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 + }, [_, dids, profiles, searchText, results, isError]) + + if (searchText && !isFetching && !items.length && !isError) { + items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) + } + + const renderItems = React.useCallback( + ({item}: {item: Item}) => { + switch (item.type) { + case 'profile': { + return ( + + ) + } + case 'placeholder': { + return + } + case 'empty': { + return + } + default: + return null + } + }, + [moderationOpts, onToggleUser], + ) + + React.useLayoutEffect(() => { + if (isWeb) { + setImmediate(() => { + inputRef?.current?.focus() + }) + } + }, []) + + const listHeader = React.useMemo(() => { + return ( + + + { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onEscape={control.close} + /> + + + {isWeb && ( + + )} + + ) + }, [t.atoms.border_contrast_low, t.atoms.bg, _, searchText, control]) + + return ( + item.key} + style={[ + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), + native({height: '100%'}), + a.p_0, + ]} + webInnerStyle={[a.p_0, {maxWidth: 500, minWidth: 200}]} + keyboardDismissMode="on-drag" + /> + ) +} + +function ProfileCard({ + checked, + profile, + moderationOpts, + onToggle, +}: { + checked: boolean + profile: AppBskyActorDefs.ProfileView + moderationOpts: ModerationOpts + onToggle: (did: string, checked: boolean) => void +}) { + const t = useTheme() + const {_} = useLingui() + const moderation = moderateProfile(profile, moderationOpts) + const handle = sanitizeHandle(profile.handle, '@') + const displayName = sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ) + + const handleOnPress = React.useCallback( + (selected: boolean) => { + onToggle(profile.did, selected) + }, + [onToggle, profile.did], + ) + + return ( + + {({hovered, pressed, focused}) => ( + + + + + {displayName} + + + {handle} + + + + + )} + + ) +} + +function ProfileCardSkeleton() { + const t = useTheme() + + return ( + + + + + + + + + ) +} + +function Empty({message}: {message: string}) { + const t = useTheme() + return ( + + + {message} + + + (╯°□°)╯︵ ┻━┻ + + ) +} + +function SearchInput({ + value, + onChangeText, + onEscape, + inputRef, +}: { + value: string + onChangeText: (text: string) => void + onEscape: () => void + inputRef: React.RefObject +}) { + const t = useTheme() + const {_} = useLingui() + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const interacted = hovered || focused + + return ( + + + + { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + autoFocus + accessibilityLabel={_(msg`Search profiles`)} + accessibilityHint={_(msg`Search profiles`)} + /> + + ) +}