diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js index 6bb6f3c82..4815d60cd 100644 --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -1,409 +1,493 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; +import uuid from 'uuid'; import genesis from '../facts/genesis.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { useKeyserverCall } from '../keyserver-conn/keyserver-call.js'; import type { CallKeyserverEndpoint } from '../keyserver-conn/keyserver-conn-types.js'; +import { + type OutboundDMOperationSpecification, + dmOperationSpecificationTypes, +} from '../shared/dm-ops/dm-op-utils.js'; +import { useProcessAndSendDMOperation } from '../shared/dm-ops/process-dm-ops.js'; import { permissionsAndAuthRelatedRequestTimeout } from '../shared/timeouts.js'; +import type { + DMChangeThreadSettingsOperation, + DMThreadSettingsChangesBase, +} from '../types/dm-ops.js'; +import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; +import { + thickThreadTypes, + threadTypeIsThick, +} from '../types/thread-types-enum.js'; import type { ChangeThreadSettingsPayload, LeaveThreadPayload, UpdateThreadRequest, ClientNewThinThreadRequest, NewThreadResult, ClientThreadJoinRequest, ThreadJoinPayload, ThreadFetchMediaRequest, ThreadFetchMediaResult, RoleModificationRequest, RoleModificationPayload, RoleDeletionRequest, RoleDeletionPayload, } from '../types/thread-types.js'; import { values } from '../utils/objects.js'; +import { useSelector } from '../utils/redux-utils.js'; export type DeleteThreadInput = { +threadID: string, }; const deleteThreadActionTypes = Object.freeze({ started: 'DELETE_THREAD_STARTED', success: 'DELETE_THREAD_SUCCESS', failed: 'DELETE_THREAD_FAILED', }); const deleteThreadEndpointOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const deleteThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: DeleteThreadInput) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'delete_thread', requests, deleteThreadEndpointOptions, ); const response = responses[keyserverID]; return { updatesResult: response.updatesResult, }; }; function useDeleteThread(): ( input: DeleteThreadInput, ) => Promise { return useKeyserverCall(deleteThread); } const changeThreadSettingsActionTypes = Object.freeze({ started: 'CHANGE_THREAD_SETTINGS_STARTED', success: 'CHANGE_THREAD_SETTINGS_SUCCESS', failed: 'CHANGE_THREAD_SETTINGS_FAILED', }); const changeThreadSettingsEndpointOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const changeThreadSettings = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: UpdateThreadRequest) => Promise) => async input => { invariant( Object.keys(input.changes).length > 0, 'No changes provided to changeThreadSettings!', ); const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'update_thread', requests, changeThreadSettingsEndpointOptions, ); const response = responses[keyserverID]; return { threadID: input.threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; }; -function useChangeThreadSettings(): ( - input: UpdateThreadRequest, -) => Promise { - return useKeyserverCall(changeThreadSettings); +function useChangeThreadSettings( + threadInfo: ?ThreadInfo, +): (input: UpdateThreadRequest) => Promise { + const processAndSendDMOperation = useProcessAndSendDMOperation(); + const viewerID = useSelector( + state => state.currentUserInfo && state.currentUserInfo.id, + ); + + const keyserverCall = useKeyserverCall(changeThreadSettings); + + return React.useCallback( + async (input: UpdateThreadRequest) => { + if (!threadInfo || !threadTypeIsThick(threadInfo.type)) { + return await keyserverCall(input); + } + + invariant(viewerID, 'viewerID should be set'); + + const changes: { ...DMThreadSettingsChangesBase } = {}; + if (input.changes.name) { + changes.name = input.changes.name; + } + if (input.changes.description) { + changes.description = input.changes.description; + } + if (input.changes.color) { + changes.color = input.changes.color; + } + if (input.changes.avatar && input.changes.avatar.type === 'emoji') { + changes.avatar = { + type: 'emoji', + emoji: input.changes.avatar.emoji, + color: input.changes.avatar.color, + }; + } else if (input.changes.avatar && input.changes.avatar.type === 'ens') { + changes.avatar = { type: 'ens' }; + } + // To support `image` and `encrypted_image` avatars we first, need stop + // sending multimedia metadata to keyserver. + // ENG-8708 + + const op: DMChangeThreadSettingsOperation = { + type: 'change_thread_settings', + threadID: threadInfo.id, + editorID: viewerID, + time: Date.now(), + changes, + messageIDsPrefix: uuid.v4(), + }; + const opSpecification: OutboundDMOperationSpecification = { + type: dmOperationSpecificationTypes.OUTBOUND, + op, + recipients: { + type: 'all_thread_members', + threadID: + threadInfo.type === thickThreadTypes.THICK_SIDEBAR && + threadInfo.parentThreadID + ? threadInfo.parentThreadID + : threadInfo.id, + }, + }; + + await processAndSendDMOperation(opSpecification); + return ({ + threadID: threadInfo.id, + updatesResult: { newUpdates: [] }, + newMessageInfos: [], + }: ChangeThreadSettingsPayload); + }, + [keyserverCall, processAndSendDMOperation, threadInfo, viewerID], + ); } export type RemoveUsersFromThreadInput = { +threadID: string, +memberIDs: $ReadOnlyArray, }; const removeUsersFromThreadActionTypes = Object.freeze({ started: 'REMOVE_USERS_FROM_THREAD_STARTED', success: 'REMOVE_USERS_FROM_THREAD_SUCCESS', failed: 'REMOVE_USERS_FROM_THREAD_FAILED', }); const removeMembersFromThreadEndpointOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const removeUsersFromThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: RemoveUsersFromThreadInput, ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'remove_members', requests, removeMembersFromThreadEndpointOptions, ); const response = responses[keyserverID]; return { threadID: input.threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; }; function useRemoveUsersFromThread(): ( input: RemoveUsersFromThreadInput, ) => Promise { return useKeyserverCall(removeUsersFromThread); } export type ChangeThreadMemberRolesInput = { +threadID: string, +memberIDs: $ReadOnlyArray, +newRole: string, }; const changeThreadMemberRolesActionTypes = Object.freeze({ started: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', success: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', failed: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', }); const changeThreadMemberRoleEndpointOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const changeThreadMemberRoles = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: ChangeThreadMemberRolesInput, ) => Promise) => async input => { const { threadID, memberIDs, newRole } = input; const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: { threadID, memberIDs, role: newRole, }, }; const responses = await callKeyserverEndpoint( 'update_role', requests, changeThreadMemberRoleEndpointOptions, ); const response = responses[keyserverID]; return { threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; }; function useChangeThreadMemberRoles(): ( input: ChangeThreadMemberRolesInput, ) => Promise { return useKeyserverCall(changeThreadMemberRoles); } const newThreadActionTypes = Object.freeze({ started: 'NEW_THREAD_STARTED', success: 'NEW_THREAD_SUCCESS', failed: 'NEW_THREAD_FAILED', }); const newThinThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: ClientNewThinThreadRequest) => Promise) => async input => { const parentThreadID = input.parentThreadID ?? genesis().id; const keyserverID = extractKeyserverIDFromID(parentThreadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint('create_thread', requests); const response = responses[keyserverID]; return { newThreadID: response.newThreadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, userInfos: response.userInfos, }; }; function useNewThinThread(): ( input: ClientNewThinThreadRequest, ) => Promise { return useKeyserverCall(newThinThread); } const joinThreadActionTypes = Object.freeze({ started: 'JOIN_THREAD_STARTED', success: 'JOIN_THREAD_SUCCESS', failed: 'JOIN_THREAD_FAILED', }); const joinThreadOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const joinThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: ClientThreadJoinRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'join_thread', requests, joinThreadOptions, ); const response = responses[keyserverID]; const userInfos = values(response.userInfos); return { updatesResult: response.updatesResult, rawMessageInfos: response.rawMessageInfos, truncationStatuses: response.truncationStatuses, userInfos, keyserverID, }; }; function useJoinThread(): ( input: ClientThreadJoinRequest, ) => Promise { return useKeyserverCall(joinThread); } export type LeaveThreadInput = { +threadID: string, }; const leaveThreadActionTypes = Object.freeze({ started: 'LEAVE_THREAD_STARTED', success: 'LEAVE_THREAD_SUCCESS', failed: 'LEAVE_THREAD_FAILED', }); const leaveThreadEndpointOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const leaveThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: LeaveThreadInput) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'leave_thread', requests, leaveThreadEndpointOptions, ); const response = responses[keyserverID]; return { updatesResult: response.updatesResult, }; }; function useLeaveThread(): ( input: LeaveThreadInput, ) => Promise { return useKeyserverCall(leaveThread); } const fetchThreadMedia = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: ThreadFetchMediaRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'fetch_thread_media', requests, ); const response = responses[keyserverID]; return { media: response.media }; }; function useFetchThreadMedia(): ( input: ThreadFetchMediaRequest, ) => Promise { return useKeyserverCall(fetchThreadMedia); } const modifyCommunityRoleActionTypes = Object.freeze({ started: 'MODIFY_COMMUNITY_ROLE_STARTED', success: 'MODIFY_COMMUNITY_ROLE_SUCCESS', failed: 'MODIFY_COMMUNITY_ROLE_FAILED', }); const modifyCommunityRole = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: RoleModificationRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.community); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'modify_community_role', requests, ); const response = responses[keyserverID]; return { threadInfo: response.threadInfo, updatesResult: response.updatesResult, }; }; function useModifyCommunityRole(): ( input: RoleModificationRequest, ) => Promise { return useKeyserverCall(modifyCommunityRole); } const deleteCommunityRoleActionTypes = Object.freeze({ started: 'DELETE_COMMUNITY_ROLE_STARTED', success: 'DELETE_COMMUNITY_ROLE_SUCCESS', failed: 'DELETE_COMMUNITY_ROLE_FAILED', }); const deleteCommunityRole = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: RoleDeletionRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.community); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'delete_community_role', requests, ); const response = responses[keyserverID]; return { threadInfo: response.threadInfo, updatesResult: response.updatesResult, }; }; function useDeleteCommunityRole(): ( input: RoleDeletionRequest, ) => Promise { return useKeyserverCall(deleteCommunityRole); } export { deleteThreadActionTypes, useDeleteThread, changeThreadSettingsActionTypes, useChangeThreadSettings, removeUsersFromThreadActionTypes, useRemoveUsersFromThread, changeThreadMemberRolesActionTypes, useChangeThreadMemberRoles, newThreadActionTypes, useNewThinThread, joinThreadActionTypes, useJoinThread, leaveThreadActionTypes, useLeaveThread, useFetchThreadMedia, modifyCommunityRoleActionTypes, useModifyCommunityRole, deleteCommunityRoleActionTypes, useDeleteCommunityRole, }; diff --git a/lib/components/base-edit-thread-avatar-provider.react.js b/lib/components/base-edit-thread-avatar-provider.react.js index 8e65f6c51..93483944f 100644 --- a/lib/components/base-edit-thread-avatar-provider.react.js +++ b/lib/components/base-edit-thread-avatar-provider.react.js @@ -1,118 +1,122 @@ // @flow import * as React from 'react'; import { useChangeThreadSettings, changeThreadSettingsActionTypes, } from '../actions/thread-actions.js'; import { createLoadingStatusSelector } from '../selectors/loading-selectors.js'; +import { threadInfoSelector } from '../selectors/thread-selectors.js'; import type { UpdateUserAvatarRequest } from '../types/avatar-types.js'; import type { LoadingStatus } from '../types/loading-types.js'; import type { UpdateThreadRequest } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; export type EditThreadAvatarContextType = { +updateThreadAvatarMediaUploadInProgress: (inProgress: boolean) => void, +threadAvatarSaveInProgress: boolean, +baseSetThreadAvatar: ( threadID: string, avatarRequest: UpdateUserAvatarRequest, ) => Promise, }; const EditThreadAvatarContext: React.Context = React.createContext(); type Props = { +activeThreadID: string, +children: React.Node, }; function BaseEditThreadAvatarProvider(props: Props): React.Node { const { activeThreadID, children } = props; const updateThreadAvatarLoadingStatus: LoadingStatus = useSelector( createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${activeThreadID}:avatar`, ), ); + const threadInfo = useSelector( + state => threadInfoSelector(state)[activeThreadID], + ); const dispatchActionPromise = useDispatchActionPromise(); - const changeThreadSettingsCall = useChangeThreadSettings(); + const changeThreadSettingsCall = useChangeThreadSettings(threadInfo); const [ threadAvatarMediaUploadInProgress, setThreadAvatarMediaUploadInProgress, ] = React.useState<$ReadOnlySet>(new Set()); const updateThreadAvatarMediaUploadInProgress = React.useCallback( (inProgress: boolean) => setThreadAvatarMediaUploadInProgress(prevState => { const updatedSet = new Set(prevState); if (inProgress) { updatedSet.add(activeThreadID); } else { updatedSet.delete(activeThreadID); } return updatedSet; }), [activeThreadID], ); const threadAvatarSaveInProgress = threadAvatarMediaUploadInProgress.has(activeThreadID) || updateThreadAvatarLoadingStatus === 'loading'; // NOTE: Do NOT consume `baseSetThreadAvatar` directly. // Use platform-specific `[web/native]SetThreadAvatar` instead. const baseSetThreadAvatar = React.useCallback( async (threadID: string, avatarRequest: UpdateUserAvatarRequest) => { const updateThreadRequest: UpdateThreadRequest = { threadID, changes: { avatar: avatarRequest, }, }; const action = changeThreadSettingsActionTypes.started; if ( avatarRequest.type === 'image' || avatarRequest.type === 'encrypted_image' ) { updateThreadAvatarMediaUploadInProgress(false); } const promise = changeThreadSettingsCall(updateThreadRequest); void dispatchActionPromise(changeThreadSettingsActionTypes, promise, { customKeyName: `${action}:${threadID}:avatar`, }); await promise; }, [ changeThreadSettingsCall, dispatchActionPromise, updateThreadAvatarMediaUploadInProgress, ], ); const context = React.useMemo( () => ({ updateThreadAvatarMediaUploadInProgress, threadAvatarSaveInProgress, baseSetThreadAvatar, }), [ updateThreadAvatarMediaUploadInProgress, threadAvatarSaveInProgress, baseSetThreadAvatar, ], ); return ( {children} ); } export { EditThreadAvatarContext, BaseEditThreadAvatarProvider }; diff --git a/native/chat/settings/color-selector-modal.react.js b/native/chat/settings/color-selector-modal.react.js index 6c5f9fb45..e392c83e5 100644 --- a/native/chat/settings/color-selector-modal.react.js +++ b/native/chat/settings/color-selector-modal.react.js @@ -1,193 +1,195 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import * as React from 'react'; import { TouchableHighlight } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import ColorSelector from '../../components/color-selector.react.js'; import Modal from '../../components/modal.react.js'; import type { RootNavigationProp } from '../../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import { unknownErrorAlertDetails } from '../../utils/alert-messages.js'; import Alert from '../../utils/alert.js'; export type ColorSelectorModalParams = { +presentedFrom: string, +color: string, +threadInfo: ThreadInfo, +setColor: (color: string) => void, }; const unboundStyles = { closeButton: { borderRadius: 3, height: 18, position: 'absolute', right: 5, top: 5, width: 18, }, closeButtonIcon: { color: 'modalBackgroundSecondaryLabel', left: 3, position: 'absolute', }, colorSelector: { bottom: 10, left: 10, position: 'absolute', right: 10, top: 10, }, colorSelectorContainer: { backgroundColor: 'modalBackground', borderRadius: 5, flex: 0, marginHorizontal: 15, marginVertical: 20, }, }; type BaseProps = { +navigation: RootNavigationProp<'ColorSelectorModal'>, +route: NavigationRoute<'ColorSelectorModal'>, }; type Props = { ...BaseProps, // Redux state +colors: Colors, +styles: $ReadOnly, +windowWidth: number, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, }; function ColorSelectorModal(props: Props): React.Node { const { changeThreadSettings: updateThreadSettings, dispatchActionPromise, windowWidth, } = props; const { threadInfo, setColor } = props.route.params; const close = props.navigation.goBackOnce; const onErrorAcknowledged = React.useCallback(() => { setColor(threadInfo.color); }, [setColor, threadInfo.color]); const editColor = React.useCallback( async (newColor: string) => { const threadID = threadInfo.id; try { return await updateThreadSettings({ threadID, changes: { color: newColor }, }); } catch (e) { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: onErrorAcknowledged }], { cancelable: false }, ); throw e; } }, [onErrorAcknowledged, threadInfo.id, updateThreadSettings], ); const onColorSelected = React.useCallback( (color: string) => { const colorEditValue = color.substr(1); setColor(colorEditValue); close(); const action = changeThreadSettingsActionTypes.started; const threadID = props.route.params.threadInfo.id; void dispatchActionPromise( changeThreadSettingsActionTypes, editColor(colorEditValue), { customKeyName: `${action}:${threadID}:color`, }, ); }, [ setColor, close, dispatchActionPromise, editColor, props.route.params.threadInfo.id, ], ); const { colorSelectorContainer, closeButton, closeButtonIcon } = props.styles; // Based on the assumption we are always in portrait, // and consequently width is the lowest dimensions const modalStyle = React.useMemo( () => [colorSelectorContainer, { height: 0.75 * windowWidth }], [colorSelectorContainer, windowWidth], ); const { modalIosHighlightUnderlay } = props.colors; const { color } = props.route.params; return ( ); } const ConnectedColorSelectorModal: React.ComponentType = React.memo(function ConnectedColorSelectorModal(props: BaseProps) { const styles = useStyles(unboundStyles); const colors = useColors(); const windowWidth = useSelector(state => state.dimensions.width); const dispatchActionPromise = useDispatchActionPromise(); - const callChangeThreadSettings = useChangeThreadSettings(); + const callChangeThreadSettings = useChangeThreadSettings( + props.route.params.threadInfo, + ); return ( ); }); export default ConnectedColorSelectorModal; diff --git a/native/chat/settings/thread-settings-description.react.js b/native/chat/settings/thread-settings-description.react.js index ac732bde9..1f9d4434a 100644 --- a/native/chat/settings/thread-settings-description.react.js +++ b/native/chat/settings/thread-settings-description.react.js @@ -1,329 +1,329 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, Text, TextInput as BaseTextInput, View, } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { useThreadHasPermission } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import SaveSettingButton from './save-setting-button.react.js'; import { ThreadSettingsCategoryFooter, ThreadSettingsCategoryHeader, } from './thread-settings-category.react.js'; import Button from '../../components/button.react.js'; import EditSettingButton from '../../components/edit-setting-button.react.js'; import SWMansionIcon from '../../components/swmansion-icon.react.js'; import TextInput from '../../components/text-input.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { ContentSizeChangeEvent, LayoutEvent, } from '../../types/react-native.js'; import { unknownErrorAlertDetails } from '../../utils/alert-messages.js'; import Alert from '../../utils/alert.js'; const unboundStyles = { addDescriptionButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, }, addDescriptionText: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 16, }, editIcon: { color: 'panelForegroundTertiaryLabel', paddingLeft: 10, textAlign: 'right', }, outlineCategory: { backgroundColor: 'panelForeground', borderColor: 'panelForegroundBorder', borderRadius: 1, borderStyle: 'dashed', borderWidth: 1, marginLeft: -1, marginRight: -1, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 4, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, padding: 0, borderBottomColor: 'transparent', }, }; type BaseProps = { +threadInfo: ThreadInfo, +descriptionEditValue: ?string, +setDescriptionEditValue: (value: ?string, callback?: () => void) => void, +descriptionTextHeight: ?number, +setDescriptionTextHeight: (number: number) => void, +canChangeSettings: boolean, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, +canEditThreadDescription: boolean, }; class ThreadSettingsDescription extends React.PureComponent { textInput: ?React.ElementRef; render(): React.Node { if ( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined ) { const textInputStyle: { height?: number } = {}; if ( this.props.descriptionTextHeight !== undefined && this.props.descriptionTextHeight !== null ) { textInputStyle.height = this.props.descriptionTextHeight; } return ( {this.renderButton()} ); } if (this.props.threadInfo.description) { return ( {this.props.threadInfo.description} {this.renderButton()} ); } const { panelIosHighlightUnderlay } = this.props.colors; if (this.props.canEditThreadDescription) { return ( ); } return null; } renderButton(): React.Node { if (this.props.loadingStatus === 'loading') { return ( ); } else if ( this.props.descriptionEditValue === null || this.props.descriptionEditValue === undefined ) { return ( ); } return ; } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; onLayoutText = (event: LayoutEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.layout.height); }; onTextInputContentSizeChange = (event: ContentSizeChangeEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.contentSize.height); }; onPressEdit = () => { this.props.setDescriptionEditValue(this.props.threadInfo.description); }; onSubmit = () => { invariant( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined, 'should be set', ); const description = this.props.descriptionEditValue.trim(); if (description === this.props.threadInfo.description) { this.props.setDescriptionEditValue(null); return; } const editDescriptionPromise = this.editDescription(description); const action = changeThreadSettingsActionTypes.started; const threadID = this.props.threadInfo.id; void this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editDescriptionPromise, { customKeyName: `${action}:${threadID}:description`, }, ); void editDescriptionPromise.then(() => { this.props.setDescriptionEditValue(null); }); }; async editDescription( newDescription: string, ): Promise { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { description: newDescription }, }); } catch (e) { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setDescriptionEditValue( this.props.threadInfo.description, () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }, ); }; } const ConnectedThreadSettingsDescription: React.ComponentType = React.memo(function ConnectedThreadSettingsDescription( props: BaseProps, ) { const threadID = props.threadInfo.id; const loadingStatus = useSelector( createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:description`, ), ); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); - const callChangeThreadSettings = useChangeThreadSettings(); + const callChangeThreadSettings = useChangeThreadSettings(props.threadInfo); const canEditThreadDescription = useThreadHasPermission( props.threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); return ( ); }); export default ConnectedThreadSettingsDescription; diff --git a/native/chat/settings/thread-settings-name.react.js b/native/chat/settings/thread-settings-name.react.js index fb54150a1..1bdfe921d 100644 --- a/native/chat/settings/thread-settings-name.react.js +++ b/native/chat/settings/thread-settings-name.react.js @@ -1,247 +1,247 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, ActivityIndicator, TextInput as BaseTextInput, View, } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ChangeThreadSettingsPayload, UpdateThreadRequest, } from 'lib/types/thread-types.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { firstLine } from 'lib/utils/string-utils.js'; import { chatNameMaxLength } from 'lib/utils/validation-utils.js'; import SaveSettingButton from './save-setting-button.react.js'; import EditSettingButton from '../../components/edit-setting-button.react.js'; import SingleLine from '../../components/single-line.react.js'; import TextInput from '../../components/text-input.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../../themes/colors.js'; import { unknownErrorAlertDetails } from '../../utils/alert-messages.js'; import Alert from '../../utils/alert.js'; const unboundStyles = { currentValue: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, borderBottomColor: 'transparent', }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 8, }, }; type BaseProps = { +threadInfo: ResolvedThreadInfo, +nameEditValue: ?string, +setNameEditValue: (value: ?string, callback?: () => void) => void, +canChangeSettings: boolean, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, }; class ThreadSettingsName extends React.PureComponent { textInput: ?React.ElementRef; render(): React.Node { return ( Name {this.renderContent()} ); } renderButton(): React.Node { if (this.props.loadingStatus === 'loading') { return ( ); } else if ( this.props.nameEditValue === null || this.props.nameEditValue === undefined ) { return ( ); } return ; } renderContent(): React.Node { if ( this.props.nameEditValue === null || this.props.nameEditValue === undefined ) { return ( {this.props.threadInfo.uiName} {this.renderButton()} ); } return ( {this.renderButton()} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; threadEditName(): string { return firstLine( this.props.threadInfo.name ? this.props.threadInfo.name : '', ); } onPressEdit = () => { this.props.setNameEditValue(this.threadEditName()); }; onSubmit = () => { invariant( this.props.nameEditValue !== null && this.props.nameEditValue !== undefined, 'should be set', ); const name = firstLine(this.props.nameEditValue); if (name === this.threadEditName()) { this.props.setNameEditValue(null); return; } const editNamePromise = this.editName(name); const action = changeThreadSettingsActionTypes.started; const threadID = this.props.threadInfo.id; void this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editNamePromise, { customKeyName: `${action}:${threadID}:name`, }, ); void editNamePromise.then(() => { this.props.setNameEditValue(null); }); }; async editName(newName: string): Promise { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { name: newName }, }); } catch (e) { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setNameEditValue(this.threadEditName(), () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }); }; } const ConnectedThreadSettingsName: React.ComponentType = React.memo(function ConnectedThreadSettingsName(props: BaseProps) { const styles = useStyles(unboundStyles); const colors = useColors(); const threadID = props.threadInfo.id; const loadingStatus = useSelector( createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:name`, ), ); const dispatchActionPromise = useDispatchActionPromise(); - const callChangeThreadSettings = useChangeThreadSettings(); + const callChangeThreadSettings = useChangeThreadSettings(props.threadInfo); return ( ); }); export default ConnectedThreadSettingsName; diff --git a/web/modals/threads/settings/thread-settings-utils.js b/web/modals/threads/settings/thread-settings-utils.js index df98fa66e..3b3c582e3 100644 --- a/web/modals/threads/settings/thread-settings-utils.js +++ b/web/modals/threads/settings/thread-settings-utils.js @@ -1,211 +1,211 @@ // @flow import * as React from 'react'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, deleteThreadActionTypes, useDeleteThread, } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { containedThreadInfos } from 'lib/selectors/thread-selectors.js'; import { type SetState } from 'lib/types/hook-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadChanges } from 'lib/types/thread-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import ThreadDeleteConfirmationModal from './thread-settings-delete-confirmation-modal.react.js'; import { useSelector } from '../../../redux/redux-utils.js'; type UseOnSaveGeneralThreadSettingsParams = { +threadInfo: ThreadInfo, +queuedChanges: ThreadChanges, +setQueuedChanges: SetState, +setErrorMessage: SetState, }; function useOnSaveGeneralThreadSettings( params: UseOnSaveGeneralThreadSettingsParams, ): (event: SyntheticEvent) => mixed { const { threadInfo, queuedChanges, setQueuedChanges, setErrorMessage } = params; const dispatchActionPromise = useDispatchActionPromise(); - const callChangeThreadSettings = useChangeThreadSettings(); + const callChangeThreadSettings = useChangeThreadSettings(threadInfo); const changeThreadSettingsAction = React.useCallback(async () => { try { setErrorMessage(''); return await callChangeThreadSettings({ threadID: threadInfo.id, changes: queuedChanges, }); } catch (e) { setErrorMessage('unknown_error'); throw e; } finally { setQueuedChanges(Object.freeze({})); } }, [ callChangeThreadSettings, queuedChanges, setErrorMessage, setQueuedChanges, threadInfo.id, ]); const onSubmit = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); void dispatchActionPromise( changeThreadSettingsActionTypes, changeThreadSettingsAction(), ); }, [changeThreadSettingsAction, dispatchActionPromise], ); return onSubmit; } type UseOnSavePrivacySettingsParams = { +threadInfo: ThreadInfo, +queuedChanges: ThreadChanges, +setQueuedChanges: SetState, +setErrorMessage: SetState, }; function useOnSavePrivacyThreadSettings( params: UseOnSavePrivacySettingsParams, ): (event: SyntheticEvent) => mixed { const { threadInfo, queuedChanges, setQueuedChanges, setErrorMessage } = params; const modalContext = useModalContext(); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); const changeThreadSettingsAction = React.useCallback(async () => { try { setErrorMessage(''); const response = await callChangeThreadSettings({ threadID: threadInfo.id, changes: queuedChanges, }); modalContext.popModal(); return response; } catch (e) { setErrorMessage('unknown_error'); setQueuedChanges(Object.freeze({})); throw e; } }, [ callChangeThreadSettings, modalContext, queuedChanges, setErrorMessage, setQueuedChanges, threadInfo.id, ]); const onSubmit = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); void dispatchActionPromise( changeThreadSettingsActionTypes, changeThreadSettingsAction(), ); }, [changeThreadSettingsAction, dispatchActionPromise], ); return onSubmit; } type UseOnDeleteParams = { +threadInfo: ThreadInfo, +setErrorMessage: SetState, }; function useOnDeleteThread( params: UseOnDeleteParams, ): (event: SyntheticEvent) => mixed { const { threadInfo, setErrorMessage } = params; const modalContext = useModalContext(); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteThread = useDeleteThread(); const containedThreads = useSelector( state => containedThreadInfos(state)[threadInfo.id], ); const shouldUseDeleteConfirmationModal = React.useMemo( () => containedThreads?.length > 0, [containedThreads?.length], ); const popThreadDeleteConfirmationModal = React.useCallback(() => { if (shouldUseDeleteConfirmationModal) { modalContext.popModal(); } }, [modalContext, shouldUseDeleteConfirmationModal]); const deleteThreadAction = React.useCallback(async () => { try { setErrorMessage(''); const response = await callDeleteThread({ threadID: threadInfo.id }); popThreadDeleteConfirmationModal(); modalContext.popModal(); return response; } catch (e) { popThreadDeleteConfirmationModal(); setErrorMessage( e.message === 'invalid_credentials' ? 'permission not granted' : 'unknown error', ); throw e; } }, [ callDeleteThread, modalContext, popThreadDeleteConfirmationModal, setErrorMessage, threadInfo.id, ]); const dispatchDeleteThreadAction = React.useCallback(() => { void dispatchActionPromise(deleteThreadActionTypes, deleteThreadAction()); }, [dispatchActionPromise, deleteThreadAction]); const onDelete = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); if (shouldUseDeleteConfirmationModal) { modalContext.pushModal( , ); } else { dispatchDeleteThreadAction(); } }, [ dispatchDeleteThreadAction, modalContext, shouldUseDeleteConfirmationModal, threadInfo, ], ); return onDelete; } export { useOnSaveGeneralThreadSettings, useOnSavePrivacyThreadSettings, useOnDeleteThread, };