From ae026bb6992e5c1b17303d9b8af5d6239c98aec0 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 22 Oct 2024 21:06:07 -0500 Subject: [PATCH] Tags and users --- src/components/Dialog/index.web.tsx | 7 +- src/components/Layout.tsx | 8 +- .../Feeds/Creator/UserSelectButton.tsx | 629 ++++++++++++++++++ src/screens/Feeds/Creator/index.tsx | 77 ++- 4 files changed, 712 insertions(+), 9 deletions(-) create mode 100644 src/screens/Feeds/Creator/UserSelectButton.tsx diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 43cb95b032..2fbf270fb1 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -156,6 +156,7 @@ export function Inner({ accessibilityLabelledBy, accessibilityDescribedBy, header, + // TODO no need for this I think contentContainerStyle, }: DialogInnerProps) { const t = useTheme() @@ -181,6 +182,7 @@ export function Inner({ a.rounded_md, a.w_full, a.border, + gtMobile ? a.p_2xl : a.p_xl, t.atoms.bg, { maxWidth: 600, @@ -190,6 +192,7 @@ export function Inner({ shadowRadius: 30, }, flatten(style), + contentContainerStyle, ])}> {header} - - {children} - + {children} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 0bc4cef48e..873f475a90 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {View, ViewProps,ViewStyle} from 'react-native' +import {View, ViewProps, ViewStyle} from 'react-native' import {StyleProp} from 'react-native' import { KeyboardAwareScrollView, @@ -11,7 +11,7 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from '#/lib/routes/types' -import {atoms as a, useBreakpoints,useTheme} from '#/alf' +import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {Divider} from '#/components/Divider' import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' @@ -122,7 +122,8 @@ export function Gutter({ children, top, bottom, -}: { + style, +}: ViewStyleProp & { children: React.ReactNode top?: boolean bottom?: boolean @@ -135,6 +136,7 @@ export function Gutter({ top && a.pt_lg, bottom && a.pb_lg, gtMobile && [a.px_xl, top && a.pt_xl, bottom && a.pb_xl], + style, ]}> {children} diff --git a/src/screens/Feeds/Creator/UserSelectButton.tsx b/src/screens/Feeds/Creator/UserSelectButton.tsx new file mode 100644 index 0000000000..c5e94ff4c5 --- /dev/null +++ b/src/screens/Feeds/Creator/UserSelectButton.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 UserSelectDialog({ + 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`)} + /> + + ) +} diff --git a/src/screens/Feeds/Creator/index.tsx b/src/screens/Feeds/Creator/index.tsx index c8ab3018ce..5f8b33cc91 100644 --- a/src/screens/Feeds/Creator/index.tsx +++ b/src/screens/Feeds/Creator/index.tsx @@ -5,9 +5,13 @@ import {useLingui} from '@lingui/react' import {useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' +import {UserSelectButton} from '#/screens/Feeds/Creator/UserSelectButton' import {atoms as a, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon,ButtonText} from '#/components/Button' +import {OutlineTags} from '#/components/Composer/OutlineTags' import * as TextField from '#/components/forms/TextField' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import * as Layout from '#/components/Layout' import {Text} from '#/components/Typography' @@ -22,6 +26,8 @@ export function Creator() { const [name, setName] = React.useState('') const [description, setDescription] = React.useState('') + const [_tags, setTags] = React.useState([]) + const [dids, setDids] = React.useState([]) return ( @@ -31,13 +37,12 @@ export function Creator() { Create a feed - + @@ -83,7 +88,7 @@ export function Creator() { value={description} onChangeText={setDescription} multiline - style={{minHeight: 150}} + style={{minHeight: 100}} /> @@ -93,6 +98,72 @@ export function Creator() { find your feed! + + + + Users + + + + + Only posts by users you add here will appear in your feed. + + + + + + + + + Tags + + + + + Posts tagged with the following tags will automatically appear + in your feed, provided they are from the users you've selected + above. + + + + + + + + + Tags added here also help users find your feed! + + + + + +