diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js index afd9cd8c6..b52862036 100644 --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -1,341 +1,358 @@ // @flow import invariant from 'invariant'; import genesis from '../facts/genesis.js'; import type { ChangeThreadSettingsPayload, LeaveThreadPayload, UpdateThreadRequest, ClientNewThreadRequest, NewThreadResult, ClientThreadJoinRequest, ThreadJoinPayload, ThreadFetchMediaRequest, ThreadFetchMediaResult, RoleModificationRequest, RoleModificationPayload, RoleDeletionRequest, RoleDeletionPayload, } from '../types/thread-types.js'; import { extractKeyserverIDFromID } from '../utils/action-utils.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; import type { CallKeyserverEndpoint } from '../utils/keyserver-call'; import { useKeyserverCall } from '../utils/keyserver-call.js'; import { values } from '../utils/objects.js'; +export type DeleteThreadInput = { + +threadID: string, +}; + const deleteThreadActionTypes = Object.freeze({ started: 'DELETE_THREAD_STARTED', success: 'DELETE_THREAD_SUCCESS', failed: 'DELETE_THREAD_FAILED', }); const deleteThread = ( callServerEndpoint: CallServerEndpoint, - ): (( - threadID: string, - currentAccountPassword: ?string, - ) => Promise) => - async (threadID, currentAccountPassword) => { - const response = await callServerEndpoint('delete_thread', { - threadID, - accountPassword: currentAccountPassword, - }); + ): ((input: DeleteThreadInput) => Promise) => + async input => { + const keyserverID = extractKeyserverIDFromID(input.threadID); + const requests = { [keyserverID]: input }; + + const responses = await callServerEndpoint('delete_thread', requests); + 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 changeThreadSettings = ( - callServerEndpoint: CallServerEndpoint, - ): ((request: UpdateThreadRequest) => Promise) => - async request => { + callKeyserverEndpoint: CallKeyserverEndpoint, + ): ((input: UpdateThreadRequest) => Promise) => + async input => { invariant( - Object.keys(request.changes).length > 0, + Object.keys(input.changes).length > 0, 'No changes provided to changeThreadSettings!', ); - const response = await callServerEndpoint('update_thread', request); + const keyserverID = extractKeyserverIDFromID(input.threadID); + const requests = { [keyserverID]: input }; + + const responses = await callKeyserverEndpoint('update_thread', requests); + const response = responses[keyserverID]; return { - threadID: request.threadID, + threadID: input.threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; }; +function useChangeThreadSettings(): ( + input: UpdateThreadRequest, +) => Promise { + return useKeyserverCall(changeThreadSettings); +} + 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 removeUsersFromThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: RemoveUsersFromThreadInput, ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint('remove_members', requests); 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 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); 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 newThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: ClientNewThreadRequest) => 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 useNewThread(): ( input: ClientNewThreadRequest, ) => Promise { return useKeyserverCall(newThread); } const joinThreadActionTypes = Object.freeze({ started: 'JOIN_THREAD_STARTED', success: 'JOIN_THREAD_SUCCESS', failed: 'JOIN_THREAD_FAILED', }); 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); const response = responses[keyserverID]; const userInfos = values(response.userInfos); return { updatesResult: response.updatesResult, rawMessageInfos: response.rawMessageInfos, truncationStatuses: response.truncationStatuses, userInfos, }; }; 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 leaveThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: LeaveThreadInput) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint('leave_thread', requests); 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, - deleteThread, + useDeleteThread, changeThreadSettingsActionTypes, - changeThreadSettings, + useChangeThreadSettings, removeUsersFromThreadActionTypes, useRemoveUsersFromThread, changeThreadMemberRolesActionTypes, useChangeThreadMemberRoles, newThreadActionTypes, useNewThread, 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 9105128b7..8bbe1abe8 100644 --- a/lib/components/base-edit-thread-avatar-provider.react.js +++ b/lib/components/base-edit-thread-avatar-provider.react.js @@ -1,118 +1,115 @@ // @flow import * as React from 'react'; import { - changeThreadSettings, + useChangeThreadSettings, changeThreadSettingsActionTypes, } from '../actions/thread-actions.js'; import { createLoadingStatusSelector } from '../selectors/loading-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, - useServerCall, -} from '../utils/action-utils.js'; +import { useDispatchActionPromise } from '../utils/action-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 dispatchActionPromise = useDispatchActionPromise(); - const changeThreadSettingsCall = useServerCall(changeThreadSettings); + const changeThreadSettingsCall = useChangeThreadSettings(); 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') { updateThreadAvatarMediaUploadInProgress(false); } const promise = changeThreadSettingsCall(updateThreadRequest); 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/lib/hooks/promote-sidebar.react.js b/lib/hooks/promote-sidebar.react.js index 342212c07..c38316ce1 100644 --- a/lib/hooks/promote-sidebar.react.js +++ b/lib/hooks/promote-sidebar.react.js @@ -1,97 +1,94 @@ // @flow import * as React from 'react'; import { changeThreadSettingsActionTypes, - changeThreadSettings, + useChangeThreadSettings, } from '../actions/thread-actions.js'; import { createLoadingStatusSelector } from '../selectors/loading-selectors.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { threadHasPermission, threadIsSidebar, } from '../shared/thread-utils.js'; import type { LoadingStatus } from '../types/loading-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import { type ThreadInfo } from '../types/thread-types.js'; -import { - useServerCall, - useDispatchActionPromise, -} from '../utils/action-utils.js'; +import { useDispatchActionPromise } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; function canPromoteSidebar( sidebarThreadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ): boolean { if (!threadIsSidebar(sidebarThreadInfo)) { return false; } const canChangeThreadType = threadHasPermission( sidebarThreadInfo, threadPermissions.EDIT_PERMISSIONS, ); const canCreateSubchannelsInParent = threadHasPermission( parentThreadInfo, threadPermissions.CREATE_SUBCHANNELS, ); return canChangeThreadType && canCreateSubchannelsInParent; } type PromoteSidebarType = { +onPromoteSidebar: () => void, +loading: LoadingStatus, +canPromoteSidebar: boolean, }; function usePromoteSidebar( threadInfo: ThreadInfo, onError?: () => mixed, ): PromoteSidebarType { const dispatchActionPromise = useDispatchActionPromise(); - const callChangeThreadSettings = useServerCall(changeThreadSettings); + const callChangeThreadSettings = useChangeThreadSettings(); const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); const loadingStatus = useSelector(loadingStatusSelector); const { parentThreadID } = threadInfo; const parentThreadInfo: ?ThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const canPromote = canPromoteSidebar(threadInfo, parentThreadInfo); const onClick = React.useCallback(() => { try { dispatchActionPromise( changeThreadSettingsActionTypes, (async () => { return await callChangeThreadSettings({ threadID: threadInfo.id, changes: { type: threadTypes.COMMUNITY_OPEN_SUBTHREAD }, }); })(), ); } catch (e) { onError?.(); throw e; } }, [threadInfo.id, callChangeThreadSettings, dispatchActionPromise, onError]); const returnValues = React.useMemo( () => ({ onPromoteSidebar: onClick, loading: loadingStatus, canPromoteSidebar: canPromote, }), [onClick, loadingStatus, canPromote], ); return returnValues; } export { usePromoteSidebar, canPromoteSidebar }; diff --git a/lib/shared/messages/text-message-spec.js b/lib/shared/messages/text-message-spec.js index 1b141bb07..addab039e 100644 --- a/lib/shared/messages/text-message-spec.js +++ b/lib/shared/messages/text-message-spec.js @@ -1,325 +1,322 @@ // @flow import invariant from 'invariant'; import * as SimpleMarkdown from 'simple-markdown'; import { pushTypes, type MessageSpec, type RawMessageInfoFromServerDBRowParams, type NotificationTextsParams, } from './message-spec.js'; import { assertSingleMessageInfo, joinResult } from './utils.js'; import { changeThreadSettingsActionTypes, - changeThreadSettings, + useChangeThreadSettings, } from '../../actions/thread-actions.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type RawTextMessageInfo, type TextMessageData, type TextMessageInfo, rawTextMessageInfoValidator, } from '../../types/messages/text.js'; import type { NotifTexts } from '../../types/notif-types.js'; import { threadTypes } from '../../types/thread-types-enum.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; -import { - useDispatchActionPromise, - useServerCall, -} from '../../utils/action-utils.js'; +import { useDispatchActionPromise } from '../../utils/action-utils.js'; import { ET } from '../../utils/entity-text.js'; import { type ASTNode, type SingleASTNode, stripSpoilersFromNotifications, stripSpoilersFromMarkdownAST, } from '../markdown.js'; import { isUserMentioned, renderChatMentionsWithAltText, } from '../mention-utils.js'; import { notifTextsForSidebarCreation } from '../notif-utils.js'; import { threadIsGroupChat, extractNewMentionedParentMembers, } from '../thread-utils.js'; /** * most of the markdown leaves contain `content` field * (it is an array or a string) apart from lists, * which have `items` field (that holds an array) */ const rawTextFromMarkdownAST = (node: ASTNode): string => { if (Array.isArray(node)) { return node.map(rawTextFromMarkdownAST).join(''); } const { content, items } = node; if (content && typeof content === 'string') { return content; } else if (items) { return rawTextFromMarkdownAST(items); } else if (content) { return rawTextFromMarkdownAST(content); } return ''; }; const getFirstNonQuotedRawLine = ( nodes: $ReadOnlyArray, ): string => { let result = 'message'; for (const node of nodes) { if (node.type === 'blockQuote') { result = 'quoted message'; } else { const rawText = rawTextFromMarkdownAST(node); if (!rawText || !rawText.replace(/\s/g, '')) { // handles the case of an empty(or containing only white spaces) // new line that usually occurs between a quote and the rest // of the message(we don't want it as a title, thus continue) continue; } return rawText; } } return result; }; export const textMessageSpec: MessageSpec< TextMessageData, RawTextMessageInfo, TextMessageInfo, > = Object.freeze({ messageContentForServerDB( data: TextMessageData | RawTextMessageInfo, ): string { return data.text; }, messageContentForClientDB(data: RawTextMessageInfo): string { return this.messageContentForServerDB(data); }, messageTitle({ messageInfo, markdownRules }) { const { text } = messageInfo; const parser = SimpleMarkdown.parserFor(markdownRules); const ast = stripSpoilersFromMarkdownAST( parser(text, { disableAutoBlockNewlines: true }), ); return ET`${getFirstNonQuotedRawLine(ast).trim()}`; }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawTextMessageInfo { let rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), text: row.content, }; if (params.localID) { rawTextMessageInfo = { ...rawTextMessageInfo, localID: params.localID }; } return rawTextMessageInfo; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawTextMessageInfo { let rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, text: clientDBMessageInfo.content ?? '', }; if (clientDBMessageInfo.local_id) { rawTextMessageInfo = { ...rawTextMessageInfo, localID: clientDBMessageInfo.local_id, }; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawTextMessageInfo = { ...rawTextMessageInfo, id: clientDBMessageInfo.id, }; } return rawTextMessageInfo; }, createMessageInfo( rawMessageInfo: RawTextMessageInfo, creator: RelativeUserInfo, ): TextMessageInfo { let messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; }, rawMessageInfoFromMessageData( messageData: TextMessageData, id: ?string, ): RawTextMessageInfo { const { sidebarCreation, ...rest } = messageData; if (id) { return { ...rest, id }; } else { return { ...rest }; } }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): Promise { // We special-case sidebarCreations. Usually we don't send any notifs in // that case to avoid a double-notif, but we need to update the original // notif if somebody was @-tagged in this message if ( messageInfos.length === 3 && messageInfos[2].type === messageTypes.SIDEBAR_SOURCE && messageInfos[1].type === messageTypes.CREATE_SIDEBAR ) { const sidebarSourceMessageInfo = messageInfos[2]; const createSidebarMessageInfo = messageInfos[1]; const sourceMessage = messageInfos[2].sourceMessage; const { username } = params.notifTargetUserInfo; if (!username) { // If we couldn't fetch the username for some reason, we won't be able // to extract @-mentions anyways, so we'll give up on updating the notif return null; } if ( sourceMessage.type === messageTypes.TEXT && isUserMentioned(username, sourceMessage.text) ) { // If the notif target was already mentioned in the source message, // there's no need to update the notif return null; } const messageInfo = messageInfos[0]; invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); if (!isUserMentioned(username, messageInfo.text)) { // We only need to update the notif if the notif target is mentioned return null; } return notifTextsForSidebarCreation({ createSidebarMessageInfo, sidebarSourceMessageInfo, firstSidebarMessageInfo: messageInfo, threadInfo, params, }); } const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); const transformedNotificationText = stripSpoilersFromNotifications( renderChatMentionsWithAltText(messageInfo.text), ); if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { const thread = ET.thread({ display: 'uiName', threadInfo }); return { merged: ET`${thread}: ${transformedNotificationText}`, body: transformedNotificationText, title: threadInfo.uiName, }; } else { const creator = ET.user({ userInfo: messageInfo.creator }); const thread = ET.thread({ display: 'shortName', threadInfo }); return { merged: ET`${creator} to ${thread}: ${transformedNotificationText}`, body: transformedNotificationText, title: threadInfo.uiName, prefix: ET`${creator}:`, }; } }, notificationCollapseKey( rawMessageInfo: RawTextMessageInfo, messageData: TextMessageData, ): ?string { if (!messageData.sidebarCreation) { return null; } return joinResult(messageTypes.CREATE_SIDEBAR, rawMessageInfo.threadID); }, generatesNotifs: async () => pushTypes.NOTIF, includedInRepliesCount: true, useCreationSideEffectsFunc: () => { const dispatchActionPromise = useDispatchActionPromise(); - const callChangeThreadSettings = useServerCall(changeThreadSettings); + const callChangeThreadSettings = useChangeThreadSettings(); return async ( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { if (threadInfo.type !== threadTypes.SIDEBAR) { return; } invariant(parentThreadInfo, 'all sidebars should have a parent thread'); const mentionedNewMembers = extractNewMentionedParentMembers( messageInfo.text, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return; } const newMemberIDs = mentionedNewMembers.map(({ id }) => id); const addMembersPromise = callChangeThreadSettings({ threadID: threadInfo.id, changes: { newMemberIDs }, }); dispatchActionPromise(changeThreadSettingsActionTypes, addMembersPromise); await addMembersPromise; }; }, canBeSidebarSource: true, canBePinned: true, validator: rawTextMessageInfoValidator, }); diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index fdfd9b6d2..f3546d13e 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,471 +1,471 @@ // @flow import t, { type TInterface } from 'tcomb'; import { type AvatarDBContent, type ClientAvatar, clientAvatarValidator, type UpdateUserAvatarRequest, } from './avatar-types.js'; import type { Shape } from './core.js'; import type { CalendarQuery } from './entry-types.js'; import type { Media } from './media-types.js'; import type { MessageTruncationStatuses, RawMessageInfo, } from './message-types.js'; import { type ThreadSubscription, threadSubscriptionValidator, } from './subscription-types.js'; import { type ThreadPermissionsInfo, threadPermissionsInfoValidator, type ThreadRolePermissionsBlob, threadRolePermissionsBlobValidator, type UserSurfacedPermission, } from './thread-permission-types.js'; import { type ThreadType, threadTypeValidator } from './thread-types-enum.js'; import type { ClientUpdateInfo, ServerUpdateInfo } from './update-types.js'; import type { UserInfo, UserInfos } from './user-types.js'; import { type ThreadEntity, threadEntityValidator, } from '../utils/entity-text.js'; import { tID, tShape } from '../utils/validation-utils.js'; export type MemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +isSender: boolean, }; const memberInfoValidator = tShape({ id: t.String, role: t.maybe(tID), permissions: threadPermissionsInfoValidator, isSender: t.Boolean, }); export type RelativeMemberInfo = { ...MemberInfo, +username: ?string, +isViewer: boolean, }; const relativeMemberInfoValidator = tShape({ ...memberInfoValidator.meta.props, username: t.maybe(t.String), isViewer: t.Boolean, }); export type RoleInfo = { +id: string, +name: string, +permissions: ThreadRolePermissionsBlob, +isDefault: boolean, }; const roleInfoValidator = tShape({ id: tID, name: t.String, permissions: threadRolePermissionsBlobValidator, isDefault: t.Boolean, }); export type ThreadCurrentUserInfo = { +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, }; const threadCurrentUserInfoValidator = tShape({ role: t.maybe(tID), permissions: threadPermissionsInfoValidator, subscription: threadSubscriptionValidator, unread: t.maybe(t.Boolean), }); export type RawThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type RawThreadInfos = { +[id: string]: RawThreadInfo, }; export const rawThreadInfoValidator: TInterface = tShape({ id: tID, type: threadTypeValidator, name: t.maybe(t.String), avatar: t.maybe(clientAvatarValidator), description: t.maybe(t.String), color: t.String, creationTime: t.Number, parentThreadID: t.maybe(tID), containingThreadID: t.maybe(tID), community: t.maybe(tID), members: t.list(memberInfoValidator), roles: t.dict(tID, roleInfoValidator), currentUser: threadCurrentUserInfoValidator, sourceMessageID: t.maybe(tID), repliesCount: t.Number, pinnedCount: t.maybe(t.Number), }); export type ThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +uiName: string | ThreadEntity, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export const threadInfoValidator: TInterface = tShape({ id: tID, type: threadTypeValidator, name: t.maybe(t.String), uiName: t.union([t.String, threadEntityValidator]), avatar: t.maybe(clientAvatarValidator), description: t.maybe(t.String), color: t.String, creationTime: t.Number, parentThreadID: t.maybe(tID), containingThreadID: t.maybe(tID), community: t.maybe(tID), members: t.list(relativeMemberInfoValidator), roles: t.dict(tID, roleInfoValidator), currentUser: threadCurrentUserInfoValidator, sourceMessageID: t.maybe(tID), repliesCount: t.Number, pinnedCount: t.maybe(t.Number), }); export type ResolvedThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +uiName: string, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ServerMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, +isSender: boolean, }; export type ServerThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +avatar?: AvatarDBContent, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +depth: number, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +sourceMessageID?: string, +repliesCount: number, +pinnedCount: number, }; export type ThreadStore = { +threadInfos: { +[id: string]: RawThreadInfo }, }; export const threadStoreValidator: TInterface = tShape({ threadInfos: t.dict(tID, rawThreadInfoValidator), }); export type ClientDBThreadInfo = { +id: string, +type: number, +name: ?string, +avatar?: ?string, +description: ?string, +color: string, +creationTime: string, +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: string, +roles: string, +currentUser: string, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ThreadDeletionRequest = { +threadID: string, - +accountPassword?: ?string, + +accountPassword?: empty, }; export type RemoveMembersRequest = { +threadID: string, +memberIDs: $ReadOnlyArray, }; export type RoleChangeRequest = { +threadID: string, +memberIDs: $ReadOnlyArray, +role: string, }; export type ChangeThreadSettingsResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, }; export type ChangeThreadSettingsPayload = { +threadID: string, +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, }; export type LeaveThreadRequest = { +threadID: string, }; export type LeaveThreadResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type LeaveThreadPayload = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type ThreadChanges = Shape<{ +type: ThreadType, +name: string, +description: string, +color: string, +parentThreadID: ?string, +newMemberIDs: $ReadOnlyArray, +avatar: UpdateUserAvatarRequest, }>; export type UpdateThreadRequest = { +threadID: string, +changes: ThreadChanges, +accountPassword?: empty, }; export type BaseNewThreadRequest = { +id?: ?string, +name?: ?string, +description?: ?string, +color?: ?string, +parentThreadID?: ?string, +initialMemberIDs?: ?$ReadOnlyArray, +ghostMemberIDs?: ?$ReadOnlyArray, }; type NewThreadRequest = | { +type: 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12, ...BaseNewThreadRequest, } | { +type: 5, +sourceMessageID: string, ...BaseNewThreadRequest, }; export type ClientNewThreadRequest = { ...NewThreadRequest, +calendarQuery: CalendarQuery, }; export type ServerNewThreadRequest = { ...NewThreadRequest, +calendarQuery?: ?CalendarQuery, }; export type NewThreadResponse = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +userInfos: UserInfos, +newThreadID: string, }; export type NewThreadResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +userInfos: UserInfos, +newThreadID: string, }; export type ServerThreadJoinRequest = { +threadID: string, +calendarQuery?: ?CalendarQuery, +inviteLinkSecret?: string, }; export type ClientThreadJoinRequest = { +threadID: string, +calendarQuery: CalendarQuery, +inviteLinkSecret?: string, }; export type ThreadJoinResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: UserInfos, }; export type ThreadJoinPayload = { +updatesResult: { newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: $ReadOnlyArray, }; export type ThreadFetchMediaResult = { +media: $ReadOnlyArray, }; export type ThreadFetchMediaRequest = { +threadID: string, +limit: number, +offset: number, }; export type SidebarInfo = { +threadInfo: ThreadInfo, +lastUpdatedTime: number, +mostRecentNonLocalMessage: ?string, }; export type ToggleMessagePinRequest = { +messageID: string, +action: 'pin' | 'unpin', }; export type ToggleMessagePinResult = { +newMessageInfos: $ReadOnlyArray, +threadID: string, }; type CreateRoleAction = { +community: string, +name: string, +permissions: $ReadOnlyArray, +action: 'create_role', }; type EditRoleAction = { +community: string, +existingRoleID: string, +name: string, +permissions: $ReadOnlyArray, +action: 'edit_role', }; export type RoleModificationRequest = CreateRoleAction | EditRoleAction; export type RoleModificationResult = { +threadInfo: RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleModificationPayload = { +threadInfo: RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionRequest = { +community: string, +roleID: string, }; export type RoleDeletionResult = { +threadInfo: RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionPayload = { +threadInfo: RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3; // We can show a max of 5 sidebars inline underneath their parent // in the chat tab if every one of the displayed sidebars is unread export const maxUnreadSidebars = 5; export type ThreadStoreThreadInfos = { +[id: string]: RawThreadInfo }; export type ChatMentionCandidates = { +[id: string]: ResolvedThreadInfo }; export type ChatMentionCandidatesObj = { +[id: string]: ChatMentionCandidates, }; export type UserProfileThreadInfo = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, }; diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index eefe7329f..9dfddc4ca 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,299 +1,296 @@ // @flow import * as React from 'react'; import { View, Text, ActivityIndicator } from 'react-native'; import { changeThreadSettingsActionTypes, - changeThreadSettings, + useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadActualMembers } from 'lib/shared/thread-utils.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; -import { - useServerCall, - useDispatchActionPromise, -} from 'lib/utils/action-utils.js'; +import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import Button from '../../components/button.react.js'; import Modal from '../../components/modal.react.js'; import { createTagInput } from '../../components/tag-input.react.js'; import UserList from '../../components/user-list.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 { useStyles } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; export type AddUsersModalParams = { +presentedFrom: string, +threadInfo: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'AddUsersModal'>, +route: NavigationRoute<'AddUsersModal'>, }; function AddUsersModal(props: Props): React.Node { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const tagInputRef = React.useRef(); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setUsernameInputText(''); setUserInfoInputArray([]); tagInputRef.current?.focus(); }, []); const { navigation } = props; const { goBackOnce } = navigation; const close = React.useCallback(() => { goBackOnce(); }, [goBackOnce]); - const callChangeThreadSettings = useServerCall(changeThreadSettings); + const callChangeThreadSettings = useChangeThreadSettings(); const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); const { route } = props; const { threadInfo } = route.params; const threadID = threadInfo.id; const addUsersToThread = React.useCallback(async () => { try { const result = await callChangeThreadSettings({ threadID: threadID, changes: { newMemberIDs: userInfoInputIDs }, }); close(); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ callChangeThreadSettings, threadID, userInfoInputIDs, close, onUnknownErrorAlertAcknowledged, ]); const inputLength = userInfoInputArray.length; const dispatchActionPromise = useDispatchActionPromise(); const userInfoInputArrayEmpty = inputLength === 0; const onPressAdd = React.useCallback(() => { if (userInfoInputArrayEmpty) { return; } dispatchActionPromise(changeThreadSettingsActionTypes, addUsersToThread()); }, [userInfoInputArrayEmpty, dispatchActionPromise, addUsersToThread]); const changeThreadSettingsLoadingStatus = useSelector( createLoadingStatusSelector(changeThreadSettingsActionTypes), ); const isLoading = changeThreadSettingsLoadingStatus === 'loading'; const styles = useStyles(unboundStyles); let addButton = null; if (inputLength > 0) { let activityIndicator = null; if (isLoading) { activityIndicator = ( ); } const addButtonText = `Add (${inputLength})`; addButton = ( ); } let cancelButton; if (!isLoading) { cancelButton = ( ); } else { cancelButton = ; } const threadMemberIDs = React.useMemo( () => threadActualMembers(threadInfo.members), [threadInfo.members], ); const excludeUserIDs = React.useMemo( () => userInfoInputIDs.concat(threadMemberIDs), [userInfoInputIDs, threadMemberIDs], ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const { parentThreadID, community } = props.route.params.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const userSearchResults = React.useMemo( () => getPotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType: threadInfo.type, }), [ usernameInputText, otherUserInfos, userSearchIndex, excludeUserIDs, parentThreadInfo, communityThreadInfo, threadInfo.type, ], ); const onChangeTagInput = React.useCallback( (newUserInfoInputArray: $ReadOnlyArray) => { if (!isLoading) { setUserInfoInputArray(newUserInfoInputArray); } }, [isLoading], ); const onChangeTagInputText = React.useCallback( (text: string) => { if (!isLoading) { setUsernameInputText(text); } }, [isLoading], ); const onUserSelect = React.useCallback( ({ id }: AccountUserInfo) => { if (isLoading) { return; } if (userInfoInputIDs.some(existingUserID => id === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); setUsernameInputText(''); }, [isLoading, userInfoInputIDs, otherUserInfos], ); const inputProps = React.useMemo( () => ({ ...tagInputProps, onSubmitEditing: onPressAdd, }), [onPressAdd], ); const userSearchResultWithENSNames = useENSNames(userSearchResults); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); return ( {cancelButton} {addButton} ); } const unboundStyles = { activityIndicator: { paddingRight: 6, }, addButton: { backgroundColor: 'vibrantGreenButton', borderRadius: 3, flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 4, }, addText: { color: 'white', fontSize: 18, }, buttons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, }, cancelButton: { backgroundColor: 'modalButton', borderRadius: 3, paddingHorizontal: 10, paddingVertical: 4, }, cancelText: { color: 'modalButtonLabel', fontSize: 18, }, }; const MemoizedAddUsersModal: React.ComponentType = React.memo(AddUsersModal); export default MemoizedAddUsersModal; diff --git a/native/chat/settings/color-selector-modal.react.js b/native/chat/settings/color-selector-modal.react.js index 85c138090..fe0b53cba 100644 --- a/native/chat/settings/color-selector-modal.react.js +++ b/native/chat/settings/color-selector-modal.react.js @@ -1,193 +1,190 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import * as React from 'react'; import { TouchableHighlight } from 'react-native'; import { changeThreadSettingsActionTypes, - changeThreadSettings, + useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { type ThreadInfo, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types.js'; import type { DispatchActionPromise } from 'lib/utils/action-utils.js'; -import { - useServerCall, - useDispatchActionPromise, -} from 'lib/utils/action-utils.js'; +import { useDispatchActionPromise } from 'lib/utils/action-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, useStyles, useColors } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; export type ColorSelectorModalParams = { +presentedFrom: string, +color: string, +threadInfo: ThreadInfo, +setColor: (color: string) => void, }; type BaseProps = { +navigation: RootNavigationProp<'ColorSelectorModal'>, +route: NavigationRoute<'ColorSelectorModal'>, }; type Props = { ...BaseProps, // Redux state +colors: Colors, +styles: typeof unboundStyles, +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( 'Unknown error', 'Uhh... try again?', [{ 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; 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 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, }, }; 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 = useServerCall(changeThreadSettings); + const callChangeThreadSettings = useChangeThreadSettings(); return ( ); }); export default ConnectedColorSelectorModal; diff --git a/native/chat/settings/delete-thread.react.js b/native/chat/settings/delete-thread.react.js index 487555582..dcd2b9bfc 100644 --- a/native/chat/settings/delete-thread.react.js +++ b/native/chat/settings/delete-thread.react.js @@ -1,299 +1,299 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput as BaseTextInput, ActivityIndicator, } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { deleteThreadActionTypes, - deleteThread, + useDeleteThread, } from 'lib/actions/thread-actions.js'; +import type { DeleteThreadInput } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector, containedThreadInfos, } from 'lib/selectors/thread-selectors.js'; import { identifyInvalidatedThreads, getThreadsToDeleteText, } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo, ResolvedThreadInfo, LeaveThreadPayload, } from 'lib/types/thread-types.js'; import { - useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import Button from '../../components/button.react.js'; import { clearThreadsActionType } from '../../navigation/action-types.js'; import { NavContext, type NavAction, } from '../../navigation/navigation-context.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 Alert from '../../utils/alert.js'; import type { ChatNavigationProp } from '../chat.react.js'; export type DeleteThreadParams = { +threadInfo: ThreadInfo, }; type BaseProps = { +navigation: ChatNavigationProp<'DeleteThread'>, +route: NavigationRoute<'DeleteThread'>, }; type Props = { ...BaseProps, // Redux state +threadInfo: ResolvedThreadInfo, +shouldUseDeleteConfirmationAlert: boolean, +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs - +deleteThread: (threadID: string) => Promise, + +deleteThread: (input: DeleteThreadInput) => Promise, // withNavContext +navDispatch: (action: NavAction) => void, }; class DeleteThread extends React.PureComponent { mounted = false; passwordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } guardedSetState(change, callback) { if (this.mounted) { this.setState(change, callback); } } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete chat ); const { threadInfo } = this.props; return ( {`The chat "${threadInfo.uiName}" will be permanently deleted. `} There is no way to reverse this. ); } passwordInputRef = ( passwordInput: ?React.ElementRef, ) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; dispatchDeleteThreadAction = () => { this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThread(), ); }; submitDeletion = () => { if (!this.props.shouldUseDeleteConfirmationAlert) { this.dispatchDeleteThreadAction(); return; } Alert.alert( 'Warning', `${getThreadsToDeleteText( this.props.threadInfo, )} will also be permanently deleted.`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Continue', onPress: this.dispatchDeleteThreadAction }, ], { cancelable: false }, ); }; async deleteThread() { const { threadInfo, navDispatch } = this.props; navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadInfo.id] }, }); try { - const result = await this.props.deleteThread(threadInfo.id); + const result = await this.props.deleteThread({ threadID: threadInfo.id }); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Permission not granted', 'You do not have permission to delete this thread', [{ text: 'OK' }], { cancelable: false }, ); } else { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); } throw e; } } } const unboundStyles = { deleteButton: { backgroundColor: 'vibrantRedButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, deleteText: { color: 'white', fontSize: 18, textAlign: 'center', }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginBottom: 24, marginHorizontal: 24, textAlign: 'center', }, }; const loadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); const ConnectedDeleteThread: React.ComponentType = React.memo(function ConnectedDeleteThread(props: BaseProps) { const threadID = props.route.params.threadInfo.id; const reduxThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); const reduxContainedThreadInfos = useSelector( state => containedThreadInfos(state)[threadID], ); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const threadInfo = reduxThreadInfo ?? props.route.params.threadInfo; const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); const loadingStatus = useSelector(loadingStatusSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); - const callDeleteThread = useServerCall(deleteThread); + const callDeleteThread = useDeleteThread(); const navContext = React.useContext(NavContext); invariant(navContext, 'NavContext should be set in DeleteThread'); const navDispatch = navContext.dispatch; const shouldUseDeleteConfirmationAlert = reduxContainedThreadInfos && reduxContainedThreadInfos.length > 0; return ( ); }); export default ConnectedDeleteThread; diff --git a/native/chat/settings/thread-settings-description.react.js b/native/chat/settings/thread-settings-description.react.js index bc912f34e..838d62ccb 100644 --- a/native/chat/settings/thread-settings-description.react.js +++ b/native/chat/settings/thread-settings-description.react.js @@ -1,323 +1,322 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, ActivityIndicator, TextInput as BaseTextInput, View, } from 'react-native'; import { changeThreadSettingsActionTypes, - changeThreadSettings, + useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadInfo, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, - useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import SaveSettingButton from './save-setting-button.react.js'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryFooter, } 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, useStyles, useColors } from '../../themes/colors.js'; import type { LayoutEvent, ContentSizeChangeEvent, } from '../../types/react-native.js'; import Alert from '../../utils/alert.js'; 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: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, }; class ThreadSettingsDescription extends React.PureComponent { textInput: ?React.ElementRef; render() { if ( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined ) { const textInputStyle = {}; 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 canEditThreadDescription = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const { panelIosHighlightUnderlay } = this.props.colors; if (canEditThreadDescription) { return ( ); } return null; } renderButton() { 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; this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editDescriptionPromise, { customKeyName: `${action}:${threadID}:description`, }, ); editDescriptionPromise.then(() => { this.props.setDescriptionEditValue(null); }); }; async editDescription(newDescription: string) { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { description: newDescription }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ 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 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', }, }; 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 = useServerCall(changeThreadSettings); + const callChangeThreadSettings = useChangeThreadSettings(); 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 656768f60..836e377de 100644 --- a/native/chat/settings/thread-settings-name.react.js +++ b/native/chat/settings/thread-settings-name.react.js @@ -1,247 +1,246 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, ActivityIndicator, TextInput as BaseTextInput, View, } from 'react-native'; import { changeThreadSettingsActionTypes, - changeThreadSettings, + 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, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, - useServerCall, useDispatchActionPromise, } from 'lib/utils/action-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 Alert from '../../utils/alert.js'; type BaseProps = { +threadInfo: ResolvedThreadInfo, +nameEditValue: ?string, +setNameEditValue: (value: ?string, callback?: () => void) => void, +canChangeSettings: boolean, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, }; class ThreadSettingsName extends React.PureComponent { textInput: ?React.ElementRef; render() { return ( Name {this.renderContent()} ); } renderButton() { if (this.props.loadingStatus === 'loading') { return ( ); } else if ( this.props.nameEditValue === null || this.props.nameEditValue === undefined ) { return ( ); } return ; } renderContent() { 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() { 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; this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editNamePromise, { customKeyName: `${action}:${threadID}:name`, }, ); editNamePromise.then(() => { this.props.setNameEditValue(null); }); }; async editName(newName: string) { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { name: newName }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ 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 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, }, }; 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 = useServerCall(changeThreadSettings); + const callChangeThreadSettings = useChangeThreadSettings(); return ( ); }); export default ConnectedThreadSettingsName; diff --git a/native/community-creation/community-creation-members.react.js b/native/community-creation/community-creation-members.react.js index c2629d535..6bff818c8 100644 --- a/native/community-creation/community-creation-members.react.js +++ b/native/community-creation/community-creation-members.react.js @@ -1,202 +1,199 @@ // @flow import * as React from 'react'; import { ActivityIndicator } from 'react-native'; import { - changeThreadSettings, + useChangeThreadSettings, changeThreadSettingsActionTypes, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems } from 'lib/shared/search-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { AccountUserInfo } from 'lib/types/user-types.js'; -import { - useDispatchActionPromise, - useServerCall, -} from 'lib/utils/action-utils.js'; +import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import CommunityCreationContentContainer from './community-creation-content-container.react.js'; import CommunityCreationKeyserverLabel from './community-creation-keyserver-label.react.js'; import type { CommunityCreationNavigationProp } from './community-creation-navigator.react.js'; import RegistrationContainer from '../account/registration/registration-container.react.js'; import { useNavigateToThread } from '../chat/message-list-types.js'; import LinkButton from '../components/link-button.react.js'; import { createTagInput } from '../components/tag-input.react.js'; import UserList from '../components/user-list.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; export type CommunityCreationMembersScreenParams = { +announcement: boolean, +threadID: string, }; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; type Props = { +navigation: CommunityCreationNavigationProp<'CommunityCreationMembers'>, +route: NavigationRoute<'CommunityCreationMembers'>, }; const changeThreadSettingsLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); function CommunityCreationMembers(props: Props): React.Node { const { announcement, threadID } = props.route.params; const dispatchActionPromise = useDispatchActionPromise(); - const callChangeThreadSettings = useServerCall(changeThreadSettings); + const callChangeThreadSettings = useChangeThreadSettings(); const changeThreadSettingsLoadingStatus: LoadingStatus = useSelector( changeThreadSettingsLoadingStatusSelector, ); const { navigation } = props; const { setOptions } = navigation; const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const [usernameInputText, setUsernameInputText] = React.useState(''); const [selectedUsers, setSelectedUsers] = React.useState< $ReadOnlyArray, >([]); const selectedUserIDs = React.useMemo( () => selectedUsers.map(userInfo => userInfo.id), [selectedUsers], ); const navigateToThread = useNavigateToThread(); const threadInfos = useSelector(threadInfoSelector); const communityThreadInfo = threadInfos[threadID]; const addSelectedUsersToCommunity = React.useCallback(() => { dispatchActionPromise( changeThreadSettingsActionTypes, (async () => { const result = await callChangeThreadSettings({ threadID, changes: { newMemberIDs: selectedUserIDs }, }); navigateToThread({ threadInfo: communityThreadInfo }); return result; })(), ); }, [ callChangeThreadSettings, communityThreadInfo, dispatchActionPromise, navigateToThread, selectedUserIDs, threadID, ]); const exitCommunityCreationFlow = React.useCallback(() => { navigateToThread({ threadInfo: communityThreadInfo }); }, [communityThreadInfo, navigateToThread]); const activityIndicatorStyle = React.useMemo( () => ({ paddingRight: 20 }), [], ); React.useEffect(() => { setOptions({ // eslint-disable-next-line react/display-name headerRight: () => { if (changeThreadSettingsLoadingStatus === 'loading') { return ( ); } return ( ); }, }); }, [ activityIndicatorStyle, addSelectedUsersToCommunity, changeThreadSettingsLoadingStatus, exitCommunityCreationFlow, selectedUserIDs.length, setOptions, ]); const userSearchResults = React.useMemo( () => getPotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs: selectedUserIDs, threadType: announcement ? threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT : threadTypes.COMMUNITY_ROOT, }), [ announcement, otherUserInfos, selectedUserIDs, userSearchIndex, usernameInputText, ], ); const onSelectUser = React.useCallback( ({ id }: AccountUserInfo) => { if (selectedUserIDs.some(existingUserID => id === existingUserID)) { return; } setSelectedUsers(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); setUsernameInputText(''); }, [otherUserInfos, selectedUserIDs], ); const tagInputRef = React.useRef(); return ( ); } export default CommunityCreationMembers; diff --git a/web/modals/threads/members/add-members-modal.react.js b/web/modals/threads/members/add-members-modal.react.js index 8fe1f0848..2c50df35b 100644 --- a/web/modals/threads/members/add-members-modal.react.js +++ b/web/modals/threads/members/add-members-modal.react.js @@ -1,202 +1,199 @@ // @flow import * as React from 'react'; import { changeThreadSettingsActionTypes, - changeThreadSettings, + useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userSearchIndexForPotentialMembers, userInfoSelectorForPotentialMembers, } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadActualMembers } from 'lib/shared/thread-utils.js'; -import { - useDispatchActionPromise, - useServerCall, -} from 'lib/utils/action-utils.js'; +import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import AddMembersListContent from './add-members-list-content.react.js'; import css from './members-modal.css'; import Button from '../../../components/button.react.js'; import Label from '../../../components/label.react.js'; import { useSelector } from '../../../redux/redux-utils.js'; import SearchModal from '../../search-modal.react.js'; type ContentProps = { +searchText: string, +threadID: string, +onClose: () => void, }; function AddMembersModalContent(props: ContentProps): React.Node { const { searchText, threadID, onClose } = props; const [pendingUsersToAdd, setPendingUsersToAdd] = React.useState< $ReadOnlySet, >(new Set()); const threadInfo = useSelector(state => threadInfoSelector(state)[threadID]); const { parentThreadID, community } = threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const excludeUserIDs = React.useMemo( () => threadActualMembers(threadInfo.members).concat( Array.from(pendingUsersToAdd), ), [pendingUsersToAdd, threadInfo.members], ); const userSearchResults = React.useMemo( () => getPotentialMemberItems({ text: searchText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType: threadInfo.type, }), [ communityThreadInfo, excludeUserIDs, otherUserInfos, parentThreadInfo, searchText, threadInfo.type, userSearchIndex, ], ); const userSearchResultsWithENSNames = useENSNames(userSearchResults); const onSwitchUser = React.useCallback( userID => setPendingUsersToAdd(users => { const newUsers = new Set(users); if (newUsers.has(userID)) { newUsers.delete(userID); } else { newUsers.add(userID); } return newUsers; }), [], ); const dispatchActionPromise = useDispatchActionPromise(); - const callChangeThreadSettings = useServerCall(changeThreadSettings); + const callChangeThreadSettings = useChangeThreadSettings(); const addUsers = React.useCallback(() => { dispatchActionPromise( changeThreadSettingsActionTypes, callChangeThreadSettings({ threadID, changes: { newMemberIDs: Array.from(pendingUsersToAdd) }, }), ); onClose(); }, [ callChangeThreadSettings, dispatchActionPromise, onClose, pendingUsersToAdd, threadID, ]); const pendingUserInfos = React.useMemo( () => Array.from(pendingUsersToAdd) .map(userID => ({ id: userID, username: otherUserInfos[userID].username, })) .sort((a, b) => a.username.localeCompare(b.username)), [otherUserInfos, pendingUsersToAdd], ); const pendingUserInfosWithENSNames = useENSNames(pendingUserInfos); const labelItems = React.useMemo(() => { if (!pendingUserInfosWithENSNames.length) { return null; } return (
{pendingUserInfosWithENSNames.map(userInfo => ( ))}
); }, [onSwitchUser, pendingUserInfosWithENSNames]); return (
{labelItems}
); } type Props = { +threadID: string, +onClose: () => void, }; function AddMembersModal(props: Props): React.Node { const { threadID, onClose } = props; const addMembersModalContent = React.useCallback( (searchText: string) => ( ), [onClose, threadID], ); return ( {addMembersModalContent} ); } export { AddMembersModal, AddMembersModalContent }; diff --git a/web/modals/threads/settings/thread-settings-delete-tab.react.js b/web/modals/threads/settings/thread-settings-delete-tab.react.js index f6fc3e97d..6ab3a1f23 100644 --- a/web/modals/threads/settings/thread-settings-delete-tab.react.js +++ b/web/modals/threads/settings/thread-settings-delete-tab.react.js @@ -1,134 +1,131 @@ // @flow import * as React from 'react'; import { deleteThreadActionTypes, - deleteThread, + useDeleteThread, } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.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/thread-types.js'; -import { - useDispatchActionPromise, - useServerCall, -} from 'lib/utils/action-utils.js'; +import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import SubmitSection from './submit-section.react.js'; import ThreadDeleteConfirmationModal from './thread-settings-delete-confirmation-modal.react.js'; import css from './thread-settings-delete-tab.css'; import { buttonThemes } from '../../../components/button.react.js'; import { useSelector } from '../../../redux/redux-utils.js'; type ThreadSettingsDeleteTabProps = { +threadSettingsOperationInProgress: boolean, +threadInfo: ThreadInfo, +setErrorMessage: SetState, +errorMessage?: ?string, }; function ThreadSettingsDeleteTab( props: ThreadSettingsDeleteTabProps, ): React.Node { const { threadSettingsOperationInProgress, threadInfo, setErrorMessage, errorMessage, } = props; const modalContext = useModalContext(); const dispatchActionPromise = useDispatchActionPromise(); - const callDeleteThread = useServerCall(deleteThread); + 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(threadInfo.id); + 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(() => { dispatchActionPromise(deleteThreadActionTypes, deleteThreadAction()); }, [dispatchActionPromise, deleteThreadAction]); const onDelete = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); if (shouldUseDeleteConfirmationModal) { modalContext.pushModal( , ); } else { dispatchDeleteThreadAction(); } }, [ dispatchDeleteThreadAction, modalContext, shouldUseDeleteConfirmationModal, threadInfo, ], ); return (

Your chat will be permanently deleted. There is no way to reverse this.

Delete
); } export default ThreadSettingsDeleteTab; diff --git a/web/modals/threads/settings/thread-settings-general-tab.react.js b/web/modals/threads/settings/thread-settings-general-tab.react.js index 58a571789..bce0a2c44 100644 --- a/web/modals/threads/settings/thread-settings-general-tab.react.js +++ b/web/modals/threads/settings/thread-settings-general-tab.react.js @@ -1,202 +1,199 @@ // @flow import * as React from 'react'; import tinycolor from 'tinycolor2'; import { changeThreadSettingsActionTypes, - changeThreadSettings, + useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import { type SetState } from 'lib/types/hook-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadInfo, type ThreadChanges } from 'lib/types/thread-types.js'; -import { - useDispatchActionPromise, - useServerCall, -} from 'lib/utils/action-utils.js'; +import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import { firstLine } from 'lib/utils/string-utils.js'; import { chatNameMaxLength } from 'lib/utils/validation-utils.js'; import SubmitSection from './submit-section.react.js'; import css from './thread-settings-general-tab.css'; import EditThreadAvatar from '../../../avatars/edit-thread-avatar.react.js'; import LoadingIndicator from '../../../loading-indicator.react.js'; import Input from '../../input.react.js'; import ColorSelector from '../color-selector.react.js'; type ThreadSettingsGeneralTabProps = { +threadSettingsOperationInProgress: boolean, +threadInfo: ThreadInfo, +threadNamePlaceholder: string, +queuedChanges: ThreadChanges, +setQueuedChanges: SetState, +setErrorMessage: SetState, +errorMessage?: ?string, }; function ThreadSettingsGeneralTab( props: ThreadSettingsGeneralTabProps, ): React.Node { const { threadSettingsOperationInProgress, threadInfo, threadNamePlaceholder, queuedChanges, setQueuedChanges, setErrorMessage, errorMessage, } = props; const dispatchActionPromise = useDispatchActionPromise(); - const callChangeThreadSettings = useServerCall(changeThreadSettings); + const callChangeThreadSettings = useChangeThreadSettings(); const nameInputRef = React.useRef(); React.useEffect(() => { nameInputRef.current?.focus(); }, [threadSettingsOperationInProgress]); const changeQueued: boolean = React.useMemo( () => Object.values(queuedChanges).some(v => v !== null && v !== undefined), [queuedChanges], ); const onChangeName = React.useCallback( (event: SyntheticEvent) => { const target = event.currentTarget; const newName = firstLine(target.value); setQueuedChanges(prevQueuedChanges => Object.freeze({ ...prevQueuedChanges, name: newName !== threadInfo.name ? newName : undefined, }), ); }, [setQueuedChanges, threadInfo.name], ); const onChangeDescription = React.useCallback( (event: SyntheticEvent) => { const target = event.currentTarget; setQueuedChanges(prevQueuedChanges => Object.freeze({ ...prevQueuedChanges, description: target.value !== threadInfo.description ? target.value : undefined, }), ); }, [setQueuedChanges, threadInfo.description], ); const onChangeColor = React.useCallback( (color: string) => { setQueuedChanges(prevQueuedChanges => Object.freeze({ ...prevQueuedChanges, color: !tinycolor.equals(color, threadInfo.color) ? color : undefined, }), ); }, [setQueuedChanges, threadInfo.color], ); 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(); dispatchActionPromise( changeThreadSettingsActionTypes, changeThreadSettingsAction(), ); }, [changeThreadSettingsAction, dispatchActionPromise], ); const threadNameInputDisabled = !threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const saveButtonContent = React.useMemo(() => { if (threadSettingsOperationInProgress) { return ; } return 'Save'; }, [threadSettingsOperationInProgress]); return (
Chat name
Description