diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -244,6 +244,18 @@ ); } +function useViewerIsMember( + threadInfo: ?(ThreadInfo | LegacyRawThreadInfo | RawThreadInfo), +): boolean { + return React.useMemo(() => { + return !!( + threadInfo && + threadInfo.currentUser.role !== null && + threadInfo.currentUser.role !== undefined + ); + }, [threadInfo]); +} + function isMemberActive( memberInfo: MemberInfoWithPermissions | MinimallyEncodedThickMemberInfo, ): boolean { @@ -1905,6 +1917,7 @@ useCommunityRootMembersToRole, useThreadHasPermission, viewerIsMember, + useViewerIsMember, threadInChatList, useIsThreadInChatList, useThreadsInChatList, 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,218 @@ +// @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, + useViewerIsMember, +} 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, setThreadInfo] = React.useState(initialThreadInfo); + + React.useEffect(() => { + if (reduxThreadInfo) { + setThreadInfo(reduxThreadInfo); + } else { + setThreadInfo(initialThreadInfo); + } + }, [initialThreadInfo, reduxThreadInfo]); + + 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 isMember = useViewerIsMember(threadInfo); + const [joinStatus, setJoinStatus] = useState< + 'joined' | 'notJoined' | 'joining' | 'leaving', + >(`notJoined`); + React.useEffect(() => { + setJoinStatus(isMember ? 'joined' : 'notJoined'); + }, [isMember]); + + const joinCommunity = useJoinCommunity({ + communityID, + keyserverOverride: null, + calendarQuery, + ongoingJoinData, + setOngoingJoinData, + step, + setStep, + }); + + const canLeaveThread = useThreadHasPermission( + threadInfo, + threadPermissions.LEAVE_THREAD, + ); + + const styles = useStyles(unboundStyles); + const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); + + 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]); + + let buttonContent; + if (joinStatus === 'joined' && !canLeaveThread) { + buttonContent = ( + {}} + /> + ); + } else if (joinStatus === 'joining' || joinStatus === 'leaving') { + buttonContent = ( + {}} + /> + ); + } else if (joinStatus === 'joined') { + buttonContent = ( + + ); + } else { + buttonContent = ( + + ); + } + + 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: 'primaryButton', + borderColor: 'primaryButton', + }, + buttonText: { + fontSize: 14, + padding: 2, + }, + 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', +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(() => { @@ -36,17 +37,21 @@ 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 { + baseStyle = [styles.buttonText]; } - return styles.buttonText; + return textStyle ? [...baseStyle, textStyle] : baseStyle; }, [ variant, styles.buttonText, styles.disabledButtonText, styles.invisibleLoadingText, + textStyle, ]); const colors = useColors(); 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, + primaryButton: 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, + primaryButton: designSystemColors.shadesWhite100, + primaryButtonText: designSystemColors.shadesBlack95, reactionSelectionPopoverItemBackground: designSystemColors.shadesBlack75, redText: designSystemColors.errorPrimary, spoiler: designSystemColors.spoilerColor,