diff --git a/native/components/community-list-item.react.js b/native/components/community-list-item.react.js new file mode 100644 --- /dev/null +++ b/native/components/community-list-item.react.js @@ -0,0 +1,238 @@ +// @flow + +import * as React from 'react'; +import { useState } from 'react'; +import { View } from 'react-native'; + +import { + leaveThreadActionTypes, + useLeaveThread, +} from 'lib/actions/thread-actions.js'; +import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; +import { useJoinCommunity } from 'lib/shared/community-utils.js'; +import { + getCommunity, + useThreadHasPermission, + viewerIsMember, +} from 'lib/shared/thread-utils.js'; +import type { + JoinCommunityStep, + OngoingJoinCommunityData, +} from 'lib/types/community-types'; +import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; +import { threadPermissions } from 'lib/types/thread-permission-types.js'; +import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; +import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; + +import PrimaryButton from './primary-button.react.js'; +import SingleLine from './single-line.react.js'; +import ThreadAvatar from '../avatars/thread-avatar.react.js'; +import { nonThreadCalendarQuery } from '../navigation/nav-selectors.js'; +import { NavContext } from '../navigation/navigation-context.js'; +import { useSelector } from '../redux/redux-utils.js'; +import { useStyles } from '../themes/colors.js'; +import type { TextStyle, ViewStyle } from '../types/styles.js'; + +type Props = { + +threadInfo: ThreadInfo, + +style?: ViewStyle, + +textStyle?: TextStyle, +}; + +function CommunityListItem(props: Props): React.Node { + const { threadInfo: initialThreadInfo, style, textStyle } = props; + + // `initialThreadInfo` will not update if the user leaves or joins the thread, + // so we also need `reduxThreadInfo` to track thread membership and + // permissions + const reduxThreadInfo: ?ThreadInfo = useSelector( + state => threadInfoSelector(state)[initialThreadInfo.id], + ); + + const threadInfo = reduxThreadInfo ?? initialThreadInfo; + + const navContext = React.useContext(NavContext); + const calendarQuery = useSelector(state => + nonThreadCalendarQuery({ + redux: state, + navContext, + }), + ); + const communityID = getCommunity(threadInfo); + const [ongoingJoinData, setOngoingJoinData] = + React.useState(null); + const [step, setStep] = React.useState('inactive'); + + const joinCommunity = useJoinCommunity({ + communityID, + keyserverOverride: null, + calendarQuery, + ongoingJoinData, + setOngoingJoinData, + step, + setStep, + }); + + // We use `reduxThreadInfo` here because `initialThreadInfo` will not have + // the correct thread permissions. If the thread is not in redux, the value + // of `canLeaveThread` is false + const canLeaveThread = useThreadHasPermission( + reduxThreadInfo, + threadPermissions.LEAVE_THREAD, + ); + + const styles = useStyles(unboundStyles); + + // We use `reduxThreadInfo` here because `initialThreadInfo` will not have + // the correct membership status. If the thread is not in redux, the value + // of `isMember` is false + const isMember = viewerIsMember(reduxThreadInfo); + const [joinStatus, setJoinStatus] = useState< + 'joined' | 'notJoined' | 'joining' | 'leaving', + >(`notJoined`); + React.useEffect(() => { + setJoinStatus(isMember ? 'joined' : 'notJoined'); + }, [isMember]); + + const handleJoin = React.useCallback(async () => { + setJoinStatus('joining'); + try { + await joinCommunity(); + setJoinStatus('joined'); + } catch (error) { + console.error('Failed to join community:', error); + setJoinStatus('notJoined'); + } + }, [joinCommunity]); + + const dispatchActionPromise = useDispatchActionPromise(); + const callLeaveThread = useLeaveThread(); + + const handleLeave = React.useCallback(async () => { + setJoinStatus('leaving'); + try { + const leavePromise = callLeaveThread({ threadID: threadInfo.id }); + void dispatchActionPromise(leaveThreadActionTypes, leavePromise); + await leavePromise; + setJoinStatus('notJoined'); + } catch (error) { + console.error('Failed to leave community:', error); + setJoinStatus('joined'); + } + }, [callLeaveThread, dispatchActionPromise, threadInfo.id]); + + const leaveButtonStyle = React.useMemo( + () => [styles.primaryButton, styles.primaryButtonToggled], + [styles.primaryButton, styles.primaryButtonToggled], + ); + + const leaveButtonTextStyle = React.useMemo( + () => [styles.buttonText, styles.buttonTextToggled], + [styles.buttonText, styles.buttonTextToggled], + ); + + let buttonContent; + if (joinStatus === 'joined' && !canLeaveThread) { + buttonContent = ( + {}} + /> + ); + } else if (joinStatus === 'joining' || joinStatus === 'leaving') { + buttonContent = ( + {}} + /> + ); + } else if (joinStatus === 'joined') { + buttonContent = ( + + ); + } else { + buttonContent = ( + + ); + } + + const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); + + const containerStyle = React.useMemo( + () => [styles.container, style], + [style, styles.container], + ); + + const singleLineTextStyle = React.useMemo( + () => [styles.text, textStyle], + [styles.text, textStyle], + ); + + return ( + + + + {resolvedThreadInfo.uiName} + + {buttonContent} + + ); +} + +const MemoizedCommunityListItem: React.ComponentType = + React.memo(CommunityListItem); + +const unboundStyles = { + activityIndicatorContainer: { + marginLeft: 10, + }, + container: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 24, + paddingVertical: 10, + }, + text: { + color: 'modalForegroundLabel', + fontSize: 16, + paddingLeft: 9, + paddingRight: 12, + marginLeft: 8, + }, + primaryButton: { + marginLeft: 'auto', + height: 24, + minWidth: 70, + }, + primaryButtonToggled: { + backgroundColor: 'primaryButtonToggled', + borderColor: 'primaryButtonToggled', + }, + buttonText: { + fontSize: 14, + padding: 4, + }, + buttonTextToggled: { + color: 'primaryButtonText', + }, +}; + +export default MemoizedCommunityListItem; diff --git a/native/components/primary-button.react.js b/native/components/primary-button.react.js --- a/native/components/primary-button.react.js +++ b/native/components/primary-button.react.js @@ -5,7 +5,7 @@ import Button from './button.react.js'; import { useColors, useStyles } from '../themes/colors.js'; -import type { ViewStyle } from '../types/styles'; +import type { ViewStyle, TextStyle } from '../types/styles'; type Props = { +onPress: () => mixed, @@ -13,9 +13,10 @@ +variant?: 'enabled' | 'disabled' | 'loading' | 'outline' | 'danger', +children?: React.Node, +style?: ViewStyle, + +textStyle?: TextStyle, }; function PrimaryButton(props: Props): React.Node { - const { onPress, label, variant } = props; + const { onPress, label, variant, textStyle } = props; const styles = useStyles(unboundStyles); const buttonStyle = React.useMemo(() => { @@ -39,18 +40,22 @@ variant, ]); const buttonTextStyle = React.useMemo(() => { + let baseStyle; if (variant === 'disabled') { - return [styles.buttonText, styles.disabledButtonText]; + baseStyle = [styles.buttonText, styles.disabledButtonText]; } else if (variant === 'loading') { - return [styles.buttonText, styles.invisibleLoadingText]; + baseStyle = [styles.buttonText, styles.invisibleLoadingText]; } else if (variant === 'danger') { - return [styles.buttonText, styles.dangerButtonText]; + baseStyle = [styles.buttonText, styles.dangerButtonText]; } else if (variant === 'outline') { - return [styles.buttonText, styles.outlineButtonText]; + baseStyle = [styles.buttonText, styles.outlineButtonText]; + } else { + baseStyle = [styles.buttonText]; } - return styles.buttonText; + return textStyle ? [...baseStyle, textStyle] : baseStyle; }, [ variant, + textStyle, styles.buttonText, styles.disabledButtonText, styles.invisibleLoadingText, diff --git a/native/themes/colors.js b/native/themes/colors.js --- a/native/themes/colors.js +++ b/native/themes/colors.js @@ -120,6 +120,8 @@ panelSeparator: designSystemColors.shadesWhite60, purpleLink: designSystemColors.violetDark100, purpleButton: designSystemColors.violetDark100, + primaryButtonToggled: designSystemColors.shadesBlack95, + primaryButtonText: designSystemColors.shadesWhite100, reactionSelectionPopoverItemBackground: designSystemColors.shadesBlack75, redText: designSystemColors.errorPrimary, spoiler: designSystemColors.spoilerColor, @@ -223,6 +225,8 @@ panelSeparator: designSystemColors.shadesBlack75, purpleLink: designSystemColors.violetLight100, purpleButton: designSystemColors.violetDark100, + primaryButtonToggled: designSystemColors.shadesWhite100, + primaryButtonText: designSystemColors.shadesBlack95, reactionSelectionPopoverItemBackground: designSystemColors.shadesBlack75, redText: designSystemColors.errorPrimary, spoiler: designSystemColors.spoilerColor,