diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js index 61f34fbcb..03662c0c7 100644 --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -1,493 +1,559 @@ // @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, + getCreateThickRawThreadInfoInputFromThreadInfo, } 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, DMThreadSettingsChanges, + DMJoinThreadOperation, } from '../types/dm-ops.js'; -import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; +import type { + ThreadInfo, + RawThreadInfo, +} 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( 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: { ...DMThreadSettingsChanges } = {}; 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, }; }; +export type UseJoinThreadInput = $ReadOnly< + | { + +thick: false, + ...ClientThreadJoinRequest, + } + | { + +thick: true, + +rawThreadInfo: RawThreadInfo, + }, +>; + function useJoinThread(): ( - input: ClientThreadJoinRequest, + input: UseJoinThreadInput, ) => Promise { - return useKeyserverCall(joinThread); + const processAndSendDMOperation = useProcessAndSendDMOperation(); + const viewerID = useSelector( + state => state.currentUserInfo && state.currentUserInfo.id, + ); + const keyserverCall = useKeyserverCall(joinThread); + return React.useCallback( + async (input: UseJoinThreadInput) => { + if (!input.thick) { + const { thick, ...rest } = input; + return await keyserverCall({ ...rest }); + } + + const { rawThreadInfo } = input; + + invariant(viewerID, 'viewerID should be set'); + invariant(rawThreadInfo.thick, 'thread must be thick'); + + const existingThreadDetails = + getCreateThickRawThreadInfoInputFromThreadInfo(rawThreadInfo); + + const op: DMJoinThreadOperation = { + type: 'join_thread', + joinerID: viewerID, + time: Date.now(), + messageID: uuid.v4(), + existingThreadDetails, + }; + + const opSpecification: OutboundDMOperationSpecification = { + type: dmOperationSpecificationTypes.OUTBOUND, + op, + recipients: { + type: 'all_thread_members', + threadID: + rawThreadInfo.type === thickThreadTypes.THICK_SIDEBAR && + rawThreadInfo.parentThreadID + ? rawThreadInfo.parentThreadID + : rawThreadInfo.id, + }, + }; + + await processAndSendDMOperation(opSpecification); + return ({ + updatesResult: { newUpdates: [] }, + rawMessageInfos: [], + truncationStatuses: {}, + userInfos: [], + }: ThreadJoinPayload); + }, + [keyserverCall, processAndSendDMOperation, viewerID], + ); } 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/shared/community-utils.js b/lib/shared/community-utils.js index d5fa35037..2d1520fab 100644 --- a/lib/shared/community-utils.js +++ b/lib/shared/community-utils.js @@ -1,455 +1,473 @@ // @flow import * as React from 'react'; import { useThreadHasPermission } from './thread-utils.js'; import { createOrUpdateFarcasterChannelTagActionTypes, useCreateOrUpdateFarcasterChannelTag, deleteFarcasterChannelTagActionTypes, useDeleteFarcasterChannelTag, } from '../actions/community-actions.js'; import { addKeyserverActionType } from '../actions/keyserver-actions.js'; import { joinThreadActionTypes, useJoinThread, } from '../actions/thread-actions.js'; import type { LinkStatus } from '../hooks/invite-links.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { createLoadingStatusSelector } from '../selectors/loading-selectors.js'; import { isLoggedInToKeyserver } from '../selectors/user-selectors.js'; import type { KeyserverOverride } from '../shared/invite-links.js'; import { useIsKeyserverURLValid } from '../shared/keyserver-utils.js'; import { permissionsAndAuthRelatedRequestTimeout } from '../shared/timeouts.js'; import type { JoinCommunityStep, OngoingJoinCommunityData, } from '../types/community-types.js'; import type { CalendarQuery } from '../types/entry-types.js'; import type { SetState } from '../types/hook-types.js'; import { defaultKeyserverInfo } from '../types/keyserver-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { ThreadSubscription } from '../types/subscription-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { ThreadJoinPayload } from '../types/thread-types.js'; import { FetchTimeout } from '../utils/errors.js'; import { useCurrentUserFID } from '../utils/farcaster-utils.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useDispatch, useSelector } from '../utils/redux-utils.js'; const tagFarcasterChannelCopy = { DESCRIPTION: 'Tag a Farcaster channel so followers can find your Comm community!', CHANNEL_NAME_HEADER: 'Selected channel:', NO_CHANNEL_TAGGED: 'No Farcaster channel tagged', REMOVE_TAG_BUTTON: 'Remove tag', }; const tagFarcasterChannelErrorMessages: { +[string]: string } = { already_in_use: 'This Farcaster channel is already tagged to a community.', channel_not_found: 'Could not find a channel with the provided name.', }; function farcasterChannelTagBlobHash(farcasterChannelID: string): string { return `farcaster_channel_tag_${farcasterChannelID}`; } const createOrUpdateFarcasterChannelTagStatusSelector = createLoadingStatusSelector(createOrUpdateFarcasterChannelTagActionTypes); function useCreateFarcasterChannelTag( commCommunityID: string, setError: SetState, onSuccessCallback?: () => mixed, ): { +createTag: (farcasterChannelID: string) => mixed, +isLoading: boolean, } { const dispatchActionPromise = useDispatchActionPromise(); const createOrUpdateFarcasterChannelTag = useCreateOrUpdateFarcasterChannelTag(); const createCreateOrUpdateActionPromise = React.useCallback( async (farcasterChannelID: string) => { try { const res = await createOrUpdateFarcasterChannelTag({ commCommunityID, farcasterChannelID, }); onSuccessCallback?.(); return res; } catch (e) { setError(e.message); throw e; } }, [ commCommunityID, createOrUpdateFarcasterChannelTag, onSuccessCallback, setError, ], ); const createTag = React.useCallback( (farcasterChannelID: string) => { void dispatchActionPromise( createOrUpdateFarcasterChannelTagActionTypes, createCreateOrUpdateActionPromise(farcasterChannelID), ); }, [createCreateOrUpdateActionPromise, dispatchActionPromise], ); const createOrUpdateFarcasterChannelTagStatus = useSelector( createOrUpdateFarcasterChannelTagStatusSelector, ); const isLoading = createOrUpdateFarcasterChannelTagStatus === 'loading'; return { createTag, isLoading, }; } const deleteFarcasterChannelTagStatusSelector = createLoadingStatusSelector( deleteFarcasterChannelTagActionTypes, ); function useRemoveFarcasterChannelTag( commCommunityID: string, farcasterChannelID: string, setError: SetState, ): { +removeTag: () => mixed, +isLoading: boolean, } { const dispatchActionPromise = useDispatchActionPromise(); const deleteFarcasterChannelTag = useDeleteFarcasterChannelTag(); const createDeleteActionPromise = React.useCallback(async () => { try { return await deleteFarcasterChannelTag({ commCommunityID, farcasterChannelID, }); } catch (e) { setError(e.message); throw e; } }, [ commCommunityID, deleteFarcasterChannelTag, farcasterChannelID, setError, ]); const removeTag = React.useCallback(() => { void dispatchActionPromise( deleteFarcasterChannelTagActionTypes, createDeleteActionPromise(), ); }, [createDeleteActionPromise, dispatchActionPromise]); const deleteFarcasterChannelTagStatus = useSelector( deleteFarcasterChannelTagStatusSelector, ); const isLoading = deleteFarcasterChannelTagStatus === 'loading'; return { removeTag, isLoading, }; } function useCanManageFarcasterChannelTag(community: ThreadInfo): boolean { const fid = useCurrentUserFID(); const canManageFarcasterChannelTag = useThreadHasPermission( community, threadPermissions.MANAGE_FARCASTER_CHANNEL_TAGS, ); return ( canManageFarcasterChannelTag && !!fid && community.type !== threadTypes.GENESIS ); } type UseJoinCommunityParams = { +communityID: ?string, +keyserverOverride: ?KeyserverOverride, +calendarQuery: () => CalendarQuery, +ongoingJoinData: ?OngoingJoinCommunityData, +setOngoingJoinData: SetState, +step: JoinCommunityStep, +setStep: SetState, +inviteSecret?: string, +setLinkStatus?: SetState, +threadID?: string, +defaultSubscription?: ThreadSubscription, }; function useJoinCommunity(params: UseJoinCommunityParams): () => Promise { const { communityID, keyserverOverride, calendarQuery, ongoingJoinData, setOngoingJoinData, step, setStep, inviteSecret, setLinkStatus, threadID, defaultSubscription, } = params; const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); + const rawThreadInfo = useSelector(state => + threadID ? state.threadStore.threadInfos[threadID] : null, + ); + const callJoinThread = useJoinThread(); let keyserverID = keyserverOverride?.keyserverID; if (!keyserverID && communityID) { keyserverID = extractKeyserverIDFromID(communityID); } const isKeyserverKnown = useSelector(state => keyserverID ? !!state.keyserverStore.keyserverInfos[keyserverID] : false, ); const isAuthenticated = useSelector(isLoggedInToKeyserver(keyserverID)); const keyserverURL = keyserverOverride?.keyserverURL; const isKeyserverURLValid = useIsKeyserverURLValid(keyserverURL); const timeoutRef = React.useRef(); React.useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); const createJoinPromise = React.useCallback(() => { return new Promise((resolve, reject) => { if ( !keyserverID || !communityID || keyserverID !== extractKeyserverIDFromID(communityID) ) { reject(); setLinkStatus?.('invalid'); setOngoingJoinData(null); return; } const timeoutID = setTimeout(() => { reject(); setOngoingJoinData(oldData => { if (oldData) { setLinkStatus?.('timed_out'); } return null; }); }, permissionsAndAuthRelatedRequestTimeout); timeoutRef.current = timeoutID; const resolveAndClearTimeout = () => { clearTimeout(timeoutID); resolve(); }; const rejectAndClearTimeout = () => { clearTimeout(timeoutID); reject(); }; setOngoingJoinData({ resolve: resolveAndClearTimeout, reject: rejectAndClearTimeout, communityID, threadID, }); setStep('add_keyserver'); }); }, [ communityID, keyserverID, setLinkStatus, setOngoingJoinData, setStep, threadID, ]); React.useEffect(() => { void (async () => { if (!ongoingJoinData || step !== 'add_keyserver') { return; } if (isKeyserverKnown) { setStep('auth_to_keyserver'); return; } const isValid = await isKeyserverURLValid(); if (!isValid || !keyserverURL) { setLinkStatus?.('invalid'); ongoingJoinData.reject(); setOngoingJoinData(null); return; } dispatch({ type: addKeyserverActionType, payload: { keyserverAdminUserID: keyserverID, newKeyserverInfo: defaultKeyserverInfo(keyserverURL), }, }); })(); }, [ dispatch, isKeyserverKnown, isKeyserverURLValid, keyserverID, keyserverURL, ongoingJoinData, setLinkStatus, setOngoingJoinData, setStep, step, ]); React.useEffect(() => { if (step === 'auth_to_keyserver' && ongoingJoinData && isAuthenticated) { setStep('join_community'); } }, [isAuthenticated, ongoingJoinData, setStep, step]); const threadJoinPromiseRef = React.useRef>(null); React.useEffect(() => { void (async () => { if ( !ongoingJoinData || step !== 'join_community' || threadJoinPromiseRef.current ) { return; } const communityThreadID = ongoingJoinData.communityID; const query = calendarQuery(); const joinThreadPromise = callJoinThread({ + thick: false, + threadID: communityThreadID, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [communityThreadID] }, ], }, inviteLinkSecret: inviteSecret, defaultSubscription, }); threadJoinPromiseRef.current = joinThreadPromise; void dispatchActionPromise(joinThreadActionTypes, joinThreadPromise); try { await joinThreadPromise; setStep('join_thread'); } catch (e) { if (e instanceof FetchTimeout) { setLinkStatus?.('timed_out'); } else { setLinkStatus?.((status: LinkStatus) => status === 'valid' ? 'invalid' : status, ); } ongoingJoinData.reject(); setOngoingJoinData(null); } finally { threadJoinPromiseRef.current = null; } })(); }, [ calendarQuery, callJoinThread, defaultSubscription, dispatchActionPromise, inviteSecret, ongoingJoinData, setLinkStatus, setOngoingJoinData, setStep, step, ]); React.useEffect(() => { void (async () => { if (!ongoingJoinData || step !== 'join_thread') { return; } if (!threadID) { setStep('finished'); ongoingJoinData.resolve(); setOngoingJoinData(null); return; } const query = calendarQuery(); - const joinThreadPromise = callJoinThread({ - threadID, - calendarQuery: { - startDate: query.startDate, - endDate: query.endDate, - filters: [ - ...query.filters, - { type: 'threads', threadIDs: [threadID] }, - ], - }, - inviteLinkSecret: inviteSecret, - }); + let joinThreadInput; + if (rawThreadInfo && rawThreadInfo.thick) { + joinThreadInput = { + thick: true, + rawThreadInfo, + }; + } else { + joinThreadInput = { + thick: false, + + threadID, + calendarQuery: { + startDate: query.startDate, + endDate: query.endDate, + filters: [ + ...query.filters, + { type: 'threads', threadIDs: [threadID] }, + ], + }, + }; + } + + const joinThreadPromise = callJoinThread(joinThreadInput); void dispatchActionPromise(joinThreadActionTypes, joinThreadPromise); try { await joinThreadPromise; setStep('finished'); ongoingJoinData.resolve(); } catch (e) { if (e instanceof FetchTimeout) { setLinkStatus?.('timed_out'); } else { setLinkStatus?.((status: LinkStatus) => status === 'valid' ? 'invalid' : status, ); } ongoingJoinData.reject(); } finally { setOngoingJoinData(null); } })(); }, [ + rawThreadInfo, calendarQuery, callJoinThread, dispatchActionPromise, inviteSecret, ongoingJoinData, setLinkStatus, setOngoingJoinData, setStep, step, threadID, ]); return createJoinPromise; } export { tagFarcasterChannelCopy, tagFarcasterChannelErrorMessages, farcasterChannelTagBlobHash, useCreateFarcasterChannelTag, useRemoveFarcasterChannelTag, useCanManageFarcasterChannelTag, useJoinCommunity, }; diff --git a/lib/shared/dm-ops/dm-op-utils.js b/lib/shared/dm-ops/dm-op-utils.js index 3191f961f..08d90e082 100644 --- a/lib/shared/dm-ops/dm-op-utils.js +++ b/lib/shared/dm-ops/dm-op-utils.js @@ -1,242 +1,246 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; import { dmOpSpecs } from './dm-op-specs.js'; import { useProcessAndSendDMOperation } from './process-dm-ops.js'; import type { CreateThickRawThreadInfoInput, DMAddMembersOperation, DMAddViewerToThreadMembersOperation, DMOperation, } from '../../types/dm-ops.js'; import type { ThickRawThreadInfo, ThreadInfo, } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { InboundActionMetadata } from '../../types/redux-types.js'; import { outboundP2PMessageStatuses, type OutboundP2PMessage, } from '../../types/sqlite-types.js'; import { assertThickThreadType, thickThreadTypes, } from '../../types/thread-types-enum.js'; import type { RawThreadInfos } from '../../types/thread-types.js'; import { type DMOperationP2PMessage, userActionsP2PMessageTypes, } from '../../types/tunnelbroker/user-actions-peer-to-peer-message-types.js'; import type { CurrentUserInfo } from '../../types/user-types.js'; import { getContentSigningKey } from '../../utils/crypto-utils.js'; import { useSelector } from '../../utils/redux-utils.js'; function generateMessagesToPeers( message: DMOperation, peers: $ReadOnlyArray<{ +userID: string, +deviceID: string, }>, ): $ReadOnlyArray { const opMessage: DMOperationP2PMessage = { type: userActionsP2PMessageTypes.DM_OPERATION, op: message, }; const plaintext = JSON.stringify(opMessage); const outboundP2PMessages = []; for (const peer of peers) { const messageToPeer: OutboundP2PMessage = { messageID: uuid.v4(), deviceID: peer.deviceID, userID: peer.userID, timestamp: new Date().getTime().toString(), plaintext, ciphertext: '', status: outboundP2PMessageStatuses.persisted, supportsAutoRetry: dmOpSpecs[message.type].supportsAutoRetry, }; outboundP2PMessages.push(messageToPeer); } return outboundP2PMessages; } export const dmOperationSpecificationTypes = Object.freeze({ OUTBOUND: 'OutboundDMOperationSpecification', INBOUND: 'InboundDMOperationSpecification', }); // The operation generated on the sending client, causes changes to // the state and broadcasting information to peers. export type OutboundDMOperationSpecification = { +type: 'OutboundDMOperationSpecification', +op: DMOperation, +recipients: | { +type: 'all_peer_devices' | 'self_devices' } | { +type: 'some_users', +userIDs: $ReadOnlyArray } | { +type: 'all_thread_members', +threadID: string }, +sendOnly?: boolean, }; // The operation received from other peers, causes changes to // the state and after processing, sends confirmation to the sender. export type InboundDMOperationSpecification = { +type: 'InboundDMOperationSpecification', +op: DMOperation, +metadata: ?InboundActionMetadata, }; export type DMOperationSpecification = | OutboundDMOperationSpecification | InboundDMOperationSpecification; async function createMessagesToPeersFromDMOp( operation: OutboundDMOperationSpecification, allPeerUserIDAndDeviceIDs: $ReadOnlyArray<{ +userID: string, +deviceID: string, }>, currentUserInfo: ?CurrentUserInfo, threadInfos: RawThreadInfos, ): Promise<$ReadOnlyArray> { if (!currentUserInfo?.id) { return []; } let peerUserIDAndDeviceIDs = allPeerUserIDAndDeviceIDs; if (operation.recipients.type === 'self_devices') { peerUserIDAndDeviceIDs = allPeerUserIDAndDeviceIDs.filter( peer => peer.userID === currentUserInfo.id, ); } else if (operation.recipients.type === 'some_users') { const userIDs = new Set(operation.recipients.userIDs); peerUserIDAndDeviceIDs = allPeerUserIDAndDeviceIDs.filter(peer => userIDs.has(peer.userID), ); } else if (operation.recipients.type === 'all_thread_members') { const members = threadInfos[operation.recipients.threadID]?.members ?? []; const memberIDs = members.map(member => member.id); const userIDs = new Set(memberIDs); peerUserIDAndDeviceIDs = allPeerUserIDAndDeviceIDs.filter(peer => userIDs.has(peer.userID), ); } const thisDeviceID = await getContentSigningKey(); const targetPeers = peerUserIDAndDeviceIDs.filter( peer => peer.deviceID !== thisDeviceID, ); return generateMessagesToPeers(operation.op, targetPeers); } function getCreateThickRawThreadInfoInputFromThreadInfo( threadInfo: ThickRawThreadInfo, ): CreateThickRawThreadInfoInput { const roleID = Object.keys(threadInfo.roles).pop(); const thickThreadType = assertThickThreadType(threadInfo.type); return { threadID: threadInfo.id, threadType: thickThreadType, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, allMemberIDsWithSubscriptions: threadInfo.members.map( ({ id, subscription }) => ({ id, subscription, }), ), roleID, unread: !!threadInfo.currentUser.unread, name: threadInfo.name, avatar: threadInfo.avatar, description: threadInfo.description, color: threadInfo.color, containingThreadID: threadInfo.containingThreadID, sourceMessageID: threadInfo.sourceMessageID, repliesCount: threadInfo.repliesCount, pinnedCount: threadInfo.pinnedCount, timestamps: threadInfo.timestamps, }; } function useAddDMThreadMembers(): ( newMemberIDs: $ReadOnlyArray, threadInfo: ThreadInfo, ) => Promise { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const processAndSendDMOperation = useProcessAndSendDMOperation(); const threadInfos = useSelector(state => state.threadStore.threadInfos); return React.useCallback( async (newMemberIDs: $ReadOnlyArray, threadInfo: ThreadInfo) => { const rawThreadInfo = threadInfos[threadInfo.id]; invariant(rawThreadInfo.thick, 'thread should be thick'); const existingThreadDetails = getCreateThickRawThreadInfoInputFromThreadInfo(rawThreadInfo); invariant(viewerID, 'viewerID should be set'); const addViewerToThreadMembersOperation: DMAddViewerToThreadMembersOperation = { type: 'add_viewer_to_thread_members', existingThreadDetails, editorID: viewerID, time: Date.now(), messageID: uuid.v4(), addedUserIDs: newMemberIDs, }; const viewerOperationSpecification: OutboundDMOperationSpecification = { type: dmOperationSpecificationTypes.OUTBOUND, op: addViewerToThreadMembersOperation, recipients: { type: 'some_users', userIDs: newMemberIDs, }, sendOnly: true, }; invariant(viewerID, 'viewerID should be set'); const addMembersOperation: DMAddMembersOperation = { type: 'add_members', threadID: threadInfo.id, editorID: viewerID, time: Date.now(), messageID: uuid.v4(), addedUserIDs: newMemberIDs, }; const newMemberIDsSet = new Set(newMemberIDs); const recipientsThreadID = threadInfo.type === thickThreadTypes.THICK_SIDEBAR && threadInfo.parentThreadID ? threadInfo.parentThreadID : threadInfo.id; const existingMembers = threadInfos[recipientsThreadID]?.members ?.map(member => member.id) ?.filter(memberID => !newMemberIDsSet.has(memberID)) ?? []; const addMembersOperationSpecification: OutboundDMOperationSpecification = { type: dmOperationSpecificationTypes.OUTBOUND, op: addMembersOperation, recipients: { type: 'some_users', userIDs: existingMembers, }, }; await Promise.all([ processAndSendDMOperation(viewerOperationSpecification), processAndSendDMOperation(addMembersOperationSpecification), ]); }, [processAndSendDMOperation, threadInfos, viewerID], ); } -export { createMessagesToPeersFromDMOp, useAddDMThreadMembers }; +export { + createMessagesToPeersFromDMOp, + useAddDMThreadMembers, + getCreateThickRawThreadInfoInputFromThreadInfo, +}; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index 90be77d23..32913d522 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,519 +1,519 @@ // @flow import t, { type TInterface } from 'tcomb'; import { type AvatarDBContent, type ClientAvatar, clientAvatarValidator, type UpdateUserAvatarRequest, } from './avatar-types.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 { RawThreadInfo, ResolvedThreadInfo, ThreadInfo, ThickRawThreadInfo, } from './minimally-encoded-thread-permissions-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 ThinThreadType, type ThickThreadType, threadTypeValidator, } from './thread-types-enum.js'; import type { ClientUpdateInfo, ServerUpdateInfo } from './update-types.js'; import type { UserInfo, UserInfos } from './user-types.js'; import type { SpecialRole } from '../permissions/special-roles.js'; import { type ThreadEntity } from '../utils/entity-text.js'; import { tID, tShape, tUserID } from '../utils/validation-utils.js'; export type LegacyMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +isSender: boolean, }; export const legacyMemberInfoValidator: TInterface = tShape({ id: tUserID, role: t.maybe(tID), permissions: threadPermissionsInfoValidator, isSender: t.Boolean, }); export type ClientLegacyRoleInfo = { +id: string, +name: string, +permissions: ThreadRolePermissionsBlob, +isDefault: boolean, }; export const clientLegacyRoleInfoValidator: TInterface = tShape({ id: tID, name: t.String, permissions: threadRolePermissionsBlobValidator, isDefault: t.Boolean, }); export type ServerLegacyRoleInfo = { +id: string, +name: string, +permissions: ThreadRolePermissionsBlob, +isDefault: boolean, +specialRole: ?SpecialRole, }; export type LegacyThreadCurrentUserInfo = { +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, }; export const legacyThreadCurrentUserInfoValidator: TInterface = tShape({ role: t.maybe(tID), permissions: threadPermissionsInfoValidator, subscription: threadSubscriptionValidator, unread: t.maybe(t.Boolean), }); export type LegacyThinRawThreadInfo = { +id: string, +type: ThinThreadType, +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]: ClientLegacyRoleInfo }, +currentUser: LegacyThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ThickMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +isSender: boolean, }; export type ThreadTimestamps = { +name: number, +avatar: number, +description: number, +color: number, +members: { +[id: string]: { +isMember: number, +subscription: number, }, }, +currentUser: { +unread: number, }, }; export const threadTimestampsValidator: TInterface = tShape({ name: t.Number, avatar: t.Number, description: t.Number, color: t.Number, members: t.dict( tUserID, tShape({ isMember: t.Number, subscription: t.Number, }), ), currentUser: tShape({ unread: t.Number, }), }); export type LegacyThickRawThreadInfo = { +thick: true, +id: string, +type: ThickThreadType, +name?: ?string, +avatar?: ?ClientAvatar, +description?: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID?: ?string, +containingThreadID?: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: ClientLegacyRoleInfo }, +currentUser: LegacyThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, +timestamps: ThreadTimestamps, }; export type LegacyRawThreadInfo = | LegacyThinRawThreadInfo | LegacyThickRawThreadInfo; export type LegacyRawThreadInfos = { +[id: string]: LegacyRawThreadInfo, }; export const legacyRawThreadInfoValidator: 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(legacyMemberInfoValidator), roles: t.dict(tID, clientLegacyRoleInfoValidator), currentUser: legacyThreadCurrentUserInfoValidator, sourceMessageID: t.maybe(tID), repliesCount: t.Number, pinnedCount: t.maybe(t.Number), }); export type MixedRawThreadInfos = { +[id: string]: LegacyRawThreadInfo | RawThreadInfo, }; export type ThickRawThreadInfos = { +[id: string]: ThickRawThreadInfo, }; export type RawThreadInfos = { +[id: string]: RawThreadInfo, }; export type ServerMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, +isSender: boolean, }; export type ServerThreadInfo = { +id: string, +type: ThinThreadType, +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]: ServerLegacyRoleInfo }, +sourceMessageID?: string, +repliesCount: number, +pinnedCount: number, }; export type LegacyThreadStore = { +threadInfos: MixedRawThreadInfos, }; export type ThreadStore = { +threadInfos: RawThreadInfos, }; 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, +timestamps?: ?string, }; export type ThreadDeletionRequest = { +threadID: 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 = Partial<{ +type: ThinThreadType, +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 NewThinThreadRequest = | $ReadOnly<{ +type: 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12, ...BaseNewThreadRequest, }> | $ReadOnly<{ +type: 5, +sourceMessageID: string, ...BaseNewThreadRequest, +parentThreadID: string, }>; export type ClientNewThinThreadRequest = $ReadOnly<{ ...NewThinThreadRequest, +calendarQuery: CalendarQuery, }>; export type ServerNewThinThreadRequest = $ReadOnly<{ ...NewThinThreadRequest, +calendarQuery?: ?CalendarQuery, }>; export type NewThickThreadRequest = | $ReadOnly<{ +type: 13 | 14 | 15, ...BaseNewThreadRequest, }> | $ReadOnly<{ +type: 16, +sourceMessageID: string, ...BaseNewThreadRequest, +parentThreadID: string, }>; 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, +defaultSubscription?: ThreadSubscription, }; export type ClientThreadJoinRequest = { +threadID: string, +calendarQuery: CalendarQuery, +inviteLinkSecret?: string, +defaultSubscription?: ThreadSubscription, }; export type ThreadJoinResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: UserInfos, }; export type ThreadJoinPayload = { +updatesResult: { newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: $ReadOnlyArray, - +keyserverID: string, + +keyserverID?: string, }; 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: LegacyRawThreadInfo | RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleModificationPayload = { +threadInfo: LegacyRawThreadInfo | RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionRequest = { +community: string, +roleID: string, }; export type RoleDeletionResult = { +threadInfo: LegacyRawThreadInfo | RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionPayload = { +threadInfo: LegacyRawThreadInfo | 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 = LegacyRawThreadInfos; export type ChatMentionCandidate = { +threadInfo: ResolvedThreadInfo, +rawChatName: string | ThreadEntity, }; export type ChatMentionCandidates = { +[id: string]: ChatMentionCandidate, }; export type ChatMentionCandidatesObj = { +[id: string]: ChatMentionCandidates, }; export type UserProfileThreadInfo = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, }; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index b3f72145f..e98fcaf1d 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,1488 +1,1505 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import type { GenericNavigationAction } from '@react-navigation/core'; import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { ActivityIndicator, NativeAppEventEmitter, Platform, Text, TextInput, TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { EasingNode, FadeInDown, FadeOutDown, } from 'react-native-reanimated'; import { moveDraftActionType, updateDraftActionType, } from 'lib/actions/draft-actions.js'; import { joinThreadActionTypes, newThreadActionTypes, useJoinThread, } from 'lib/actions/thread-actions.js'; +import type { UseJoinThreadInput } from 'lib/actions/thread-actions.js'; import { useChatMentionContext, useThreadChatMentionCandidates, } from 'lib/hooks/chat-mention-hooks.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { useEditMessage } from 'lib/shared/edit-messages-utils.js'; import { getTypeaheadRegexMatches, type MentionTypeaheadSuggestionItem, type Selection, type TypeaheadMatchedStrings, useMentionTypeaheadChatSuggestions, useMentionTypeaheadUserSuggestions, useUserMentionsCandidates, } from 'lib/shared/mention-utils.js'; import { messageKey, type MessagePreviewResult, trimMessage, useMessagePreview, getNextLocalID, } from 'lib/shared/message-utils.js'; import SentencePrefixSearchIndex from 'lib/shared/sentence-prefix-search-index.js'; import { checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, threadActualMembers, useThreadFrozenDueToViewerBlock, useThreadHasPermission, viewerIsMember, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { SetState } from 'lib/types/hook-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { PhotoPaste } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { MessageInfo } from 'lib/types/message-types.js'; import type { RelativeMemberInfo, ThreadInfo, + RawThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ChatMentionCandidates, - ClientThreadJoinRequest, ThreadJoinPayload, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { ChatContext } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react.js'; import { MessageEditingContext, type MessageEditingContextType, } from './message-editing-context.react.js'; import type { RemoveEditMode } from './message-list-types.js'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import MentionTypeaheadTooltipButton from '../chat/mention-typeahead-tooltip-button.react.js'; import Button from '../components/button.react.js'; // eslint-disable-next-line import/extensions import ClearableTextInput from '../components/clearable-text-input.react'; import type { SyncedSelectionData } from '../components/selectable-text-input.js'; // eslint-disable-next-line import/extensions import SelectableTextInput from '../components/selectable-text-input.react'; import SingleLine from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { type EditInputBarMessageParameters, type InputState, InputStateContext, } from '../input/input-state.js'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react.js'; import { KeyboardContext, type KeyboardState, } from '../keyboard/keyboard-state.js'; import { getKeyboardHeight } from '../keyboard/keyboard.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { activeThreadSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import type { OverlayContextType } from '../navigation/overlay-context.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import { ChatCameraModalRouteName, ImagePasteModalRouteName, type NavigationRoute, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { ImagePasteEvent, LayoutEvent } from '../types/react-native.js'; import { AnimatedView, type AnimatedViewStyle, type ViewStyle, } from '../types/styles.js'; import Alert from '../utils/alert.js'; import { runTiming } from '../utils/animation-utils.js'; import { exitEditAlert } from '../utils/edit-messages-utils.js'; import { mentionTypeaheadTooltipActions, nativeMentionTypeaheadRegex, } from '../utils/typeahead-utils.js'; const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock } = Animated; const expandoButtonsAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, container: { backgroundColor: 'listBackground', paddingLeft: Platform.OS === 'android' ? 10 : 5, }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 13 : 11, paddingRight: 2, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { borderRadius: 8, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, }, joinButtonContainer: { flexDirection: 'row', height: 48, marginBottom: 8, }, editView: { marginLeft: 20, marginRight: 20, padding: 10, flexDirection: 'row', justifyContent: 'space-between', }, editViewContent: { flex: 1, paddingRight: 6, }, exitEditButton: { marginTop: 6, }, editingLabel: { paddingBottom: 4, }, editingMessagePreview: { color: 'listForegroundLabel', }, joinButtonContent: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', }, joinButtonTextLight: { color: 'white', fontSize: 20, marginHorizontal: 4, }, joinButtonTextDark: { color: 'black', fontSize: 20, marginHorizontal: 4, }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: 4, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 6, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 8, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginRight: 4, marginTop: 6, marginBottom: 8, maxHeight: 110, paddingHorizontal: 10, paddingVertical: 5, }, }; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, +viewerID: ?string, + +rawThreadInfo: RawThreadInfo, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +colors: Colors, +styles: $ReadOnly, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +isActive: boolean, +keyboardState: ?KeyboardState, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, - +joinThread: (request: ClientThreadJoinRequest) => Promise, + +joinThread: (input: UseJoinThreadInput) => Promise, +inputState: ?InputState, +userMentionsCandidates: $ReadOnlyArray, +chatMentionSearchIndex: ?SentencePrefixSearchIndex, +chatMentionCandidates: ChatMentionCandidates, +parentThreadInfo: ?ThreadInfo, +editedMessagePreview: ?MessagePreviewResult, +editedMessageInfo: ?MessageInfo, +editMessage: (messageID: string, text: string) => Promise, +navigation: ?ChatNavigationProp<'MessageList'>, +overlayContext: ?OverlayContextType, +messageEditingContext: ?MessageEditingContextType, +selectionState: SyncedSelectionData, +setSelectionState: SetState, +suggestions: $ReadOnlyArray, +typeaheadMatchedStrings: ?TypeaheadMatchedStrings, +currentUserIsVoiced: boolean, +currentUserCanJoin: boolean, +threadFrozen: boolean, }; type State = { +text: string, +textEdited: boolean, +buttonsExpanded: boolean, +isExitingDuringEditMode: boolean, }; class ChatInputBar extends React.PureComponent { textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; selectableTextInput: ?React.ElementRef; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: AnimatedViewStyle; cameraRollIconStyle: AnimatedViewStyle; cameraIconStyle: AnimatedViewStyle; expandIconStyle: AnimatedViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: AnimatedViewStyle; clearBeforeRemoveListener: () => void; clearFocusListener: () => void; clearBlurListener: () => void; constructor(props: Props) { super(props); this.state = { text: props.draft, textEdited: false, buttonsExpanded: true, isExitingDuringEditMode: false, }; this.setUpActionIconAnimations(); this.setUpSendIconAnimations(); } setUpActionIconAnimations() { this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolateNode(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [26, 66], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; } setUpSendIconAnimations() { const initialSendButtonContainerOpen = trimMessage(this.props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolateNode(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props): boolean { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props): boolean { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing(): boolean { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { if (this.shouldShowTextInput) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } else { this.setUpSendIconAnimations(); } } componentDidMount() { const { isActive, navigation } = this.props; if (isActive) { this.addEditInputMessageListener(); } if (!navigation) { return; } this.clearBeforeRemoveListener = navigation.addListener( 'beforeRemove', this.onNavigationBeforeRemove, ); this.clearFocusListener = navigation.addListener( 'focus', this.onNavigationFocus, ); this.clearBlurListener = navigation.addListener( 'blur', this.onNavigationBlur, ); } componentWillUnmount() { if (this.props.isActive) { this.removeEditInputMessageListener(); } if (this.clearBeforeRemoveListener) { this.clearBeforeRemoveListener(); } if (this.clearFocusListener) { this.clearFocusListener(); } if (this.clearBlurListener) { this.clearBlurListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.textEdited && this.state.text && this.props.threadInfo.id !== prevProps.threadInfo.id ) { this.props.dispatch({ type: moveDraftActionType, payload: { oldKey: draftKeyFromThreadID(prevProps.threadInfo.id), newKey: draftKeyFromThreadID(this.props.threadInfo.id), }, }); } else if (!this.state.textEdited && this.props.draft !== prevProps.draft) { this.setState({ text: this.props.draft }); } if (this.props.isActive && !prevProps.isActive) { this.addEditInputMessageListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeEditInputMessageListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing(prevProps); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } if ( this.props.messageEditingContext?.editState.editedMessage && !prevProps.messageEditingContext?.editState.editedMessage ) { this.blockNavigation(); } } addEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in addEditInputMessageListener', ); this.props.inputState.addEditInputMessageListener(this.focusAndUpdateText); } removeEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in removeEditInputMessageListener', ); this.props.inputState.removeEditInputMessageListener( this.focusAndUpdateText, ); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } get shouldShowTextInput(): boolean { if (this.props.currentUserIsVoiced) { return true; } // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place // and won't list the viewer as a member, // which will end up hiding the input. // In this case, we will assume that our creation action // will get translated into a join, and as long // as members are voiced, we can show the input. if (!this.props.threadCreationInProgress) { return false; } return checkIfDefaultMembersAreVoiced(this.props.threadInfo); } render(): React.Node { const isMember = viewerIsMember(this.props.threadInfo); let joinButton = null; const threadColor = `#${this.props.threadInfo.color}`; const isEditMode = this.isEditMode(); if ( !isMember && this.props.currentUserCanJoin && !this.props.threadCreationInProgress ) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { const textStyle = colorIsDark(this.props.threadInfo.color) ? this.props.styles.joinButtonTextLight : this.props.styles.joinButtonTextDark; buttonContent = ( Join Chat ); } joinButton = ( ); } let typeaheadTooltip = null; if ( this.props.suggestions.length > 0 && this.props.typeaheadMatchedStrings && !isEditMode ) { typeaheadTooltip = ( ); } let content; const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); if (this.shouldShowTextInput) { content = this.renderInput(); } else if ( this.props.threadFrozen && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( You can’t send messages to a user that you’ve blocked. ); } else if (isMember) { content = ( You don’t have permission to send messages. ); } else if (defaultMembersAreVoiced && this.props.currentUserCanJoin) { content = null; } else { content = ( You don’t have permission to send messages. ); } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); let editedMessage; if (isEditMode && this.props.editedMessagePreview) { const { message } = this.props.editedMessagePreview; editedMessage = ( Editing message {message.text} ); } return ( {typeaheadTooltip} {joinButton} {editedMessage} {content} {keyboardInputHost} ); } renderInput(): React.Node { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; const expandoButtonsViewStyle: Array = [ this.props.styles.innerExpandoButtons, ]; if (this.isEditMode()) { expandoButtonsViewStyle.push({ display: 'none' }); } return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; selectableTextInputRef = ( selectableTextInput: ?React.ElementRef, ) => { this.selectableTextInput = selectableTextInput; }; updateText = (text: string) => { if (this.state.isExitingDuringEditMode) { return; } this.setState({ text, textEdited: true }); this.props.messageEditingContext?.setEditedMessageChanged( this.isMessageEdited(text), ); if (this.isEditMode()) { return; } this.saveDraft(text); }; saveDraft: (text: string) => void = _throttle(text => { this.props.dispatch({ type: updateDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), text, }, }); }, 400); focusAndUpdateTextAndSelection = (text: string, selection: Selection) => { this.selectableTextInput?.prepareForSelectionMutation(text, selection); this.setState({ text, textEdited: true, }); this.props.setSelectionState({ text, selection }); this.saveDraft(text); this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateText = (params: EditInputBarMessageParameters) => { const { message: text, mode } = params; const currentText = this.state.text; if (mode === 'replace') { this.updateText(text); } else if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); } this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateButtonsVisibility = () => { const { textInput } = this; if (!textInput) { return; } this.immediatelyShowSendButton(); this.immediatelyHideButtons(); textInput.focus(); }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } const editedMessage = this.getEditedMessage(); if (editedMessage && editedMessage.id) { await this.editMessage(editedMessage.id, this.state.text); return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = getNextLocalID(); const creatorID = this.props.viewerID; invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); await this.props.inputState.sendTextMessage( { type: messageTypes.TEXT, localID, threadID: this.props.threadInfo.id, text, creatorID, time: Date.now(), }, this.props.threadInfo, this.props.parentThreadInfo, ); }; isEditMode = (): boolean => { const editState = this.props.messageEditingContext?.editState; const isThisThread = editState?.editedMessage?.threadID === this.props.threadInfo.id; return editState?.editedMessage !== null && isThisThread; }; isMessageEdited = (newText?: string): boolean => { let text = newText ?? this.state.text; text = trimMessage(text); const originalText = this.props.editedMessageInfo?.text; return text !== originalText; }; unblockNavigation = () => { const { navigation } = this.props; if (!navigation) { return; } navigation.setParams({ removeEditMode: null }); }; removeEditMode: RemoveEditMode = action => { const { navigation } = this.props; if (!navigation || this.state.isExitingDuringEditMode) { return 'ignore_action'; } if (!this.isMessageEdited()) { this.unblockNavigation(); return 'reduce_action'; } const unblockAndDispatch = () => { this.unblockNavigation(); navigation.dispatch(action); }; const onContinueEditing = () => { this.props.overlayContext?.resetScrollBlockingModalStatus(); }; exitEditAlert({ onDiscard: unblockAndDispatch, onContinueEditing, }); return 'ignore_action'; }; blockNavigation = () => { const { navigation } = this.props; if (!navigation || !navigation.isFocused()) { return; } navigation.setParams({ removeEditMode: this.removeEditMode, }); }; editMessage = async (messageID: string, text: string) => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } text = trimMessage(text); try { await this.props.editMessage(messageID, text); this.exitEditMode(); } catch (error) { Alert.alert( 'Couldn’t edit the message', 'Please try again later', [{ text: 'OK' }], { cancelable: true, }, ); } }; getEditedMessage = (): ?MessageInfo => { const editState = this.props.messageEditingContext?.editState; return editState?.editedMessage; }; onPressExitEditMode = () => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } exitEditAlert({ onDiscard: this.exitEditMode, }); }; scrollToEditedMessage = () => { const editedMessage = this.getEditedMessage(); if (!editedMessage) { return; } const editedMessageKey = messageKey(editedMessage); this.props.inputState?.scrollToMessage(editedMessageKey); }; exitEditMode = () => { this.props.messageEditingContext?.setEditedMessage(null, () => { this.unblockNavigation(); this.updateText(this.props.draft); this.focusAndUpdateButtonsVisibility(); this.updateSendButton(this.props.draft); }); }; onNavigationFocus = () => { this.setState({ isExitingDuringEditMode: false }); }; onNavigationBlur = () => { if (!this.isEditMode()) { return; } this.setState( { text: this.props.draft, isExitingDuringEditMode: true }, this.exitEditMode, ); }; onNavigationBeforeRemove = (e: { +data: { +action: GenericNavigationAction }, +preventDefault: () => void, ... }) => { if (!this.isEditMode()) { return; } const { action } = e.data; e.preventDefault(); const saveExit = () => { this.props.messageEditingContext?.setEditedMessage(null, () => { this.setState({ isExitingDuringEditMode: true }, () => { if (!this.props.navigation) { return; } this.props.navigation.dispatch(action); }); }); }; if (!this.isMessageEdited()) { saveExit(); return; } exitEditAlert({ onDiscard: saveExit, }); }; onPressJoin = () => { void this.props.dispatchActionPromise( joinThreadActionTypes, this.joinAction(), ); }; async joinAction(): Promise { - const query = this.props.calendarQuery(); - return await this.props.joinThread({ - threadID: this.props.threadInfo.id, - calendarQuery: { - startDate: query.startDate, - endDate: query.endDate, - filters: [ - ...query.filters, - { type: 'threads', threadIDs: [this.props.threadInfo.id] }, - ], - }, - }); + let joinThreadInput; + if (this.props.rawThreadInfo.thick) { + joinThreadInput = { + thick: true, + rawThreadInfo: this.props.rawThreadInfo, + }; + } else { + const query = this.props.calendarQuery(); + joinThreadInput = { + thick: false, + threadID: this.props.threadInfo.id, + calendarQuery: { + startDate: query.startDate, + endDate: query.endDate, + filters: [ + ...query.filters, + { type: 'threads', threadIDs: [this.props.threadInfo.id] }, + ], + }, + }; + } + + return await this.props.joinThread(joinThreadInput); } expandButtons = () => { if (this.state.buttonsExpanded || this.isEditMode()) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const createThreadLoadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); type ConnectedChatInputBarBaseProps = { ...BaseProps, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +navigation?: ChatNavigationProp<'MessageList'>, }; function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) { const navContext = React.useContext(NavContext); const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const overlayContext = React.useContext(OverlayContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const draft = useSelector( state => state.draftStore.drafts[draftKeyFromThreadID(props.threadInfo.id)] ?? '', ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const createThreadLoadingStatus = useSelector( createThreadLoadingStatusSelector, ); const threadCreationInProgress = createThreadLoadingStatus === 'loading'; const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const userInfos = useSelector(state => state.userStore.userInfos); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); + const rawThreadInfo = useSelector( + state => state.threadStore.threadInfos[props.threadInfo.id], + ); const callJoinThread = useJoinThread(); const { getChatMentionSearchIndex } = useChatMentionContext(); const chatMentionSearchIndex = getChatMentionSearchIndex(props.threadInfo); const { parentThreadID, community } = props.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const threadFrozen = useThreadFrozenDueToViewerBlock( props.threadInfo, communityThreadInfo, viewerID, userInfos, ); const userMentionsCandidates = useUserMentionsCandidates( props.threadInfo, parentThreadInfo, ); const chatMentionCandidates = useThreadChatMentionCandidates( props.threadInfo, ); const messageEditingContext = React.useContext(MessageEditingContext); const editedMessageInfo = messageEditingContext?.editState.editedMessage; const editedMessagePreview = useMessagePreview( editedMessageInfo, props.threadInfo, getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); const editMessage = useEditMessage(props.threadInfo); const [selectionState, setSelectionState] = React.useState({ text: draft, selection: { start: 0, end: 0 }, }); const typeaheadRegexMatches = React.useMemo( () => getTypeaheadRegexMatches( selectionState.text, selectionState.selection, nativeMentionTypeaheadRegex, ), [selectionState.text, selectionState.selection], ); const typeaheadMatchedStrings: ?TypeaheadMatchedStrings = React.useMemo(() => { if (typeaheadRegexMatches === null) { return null; } return { textBeforeAtSymbol: typeaheadRegexMatches[1] ?? '', query: typeaheadRegexMatches[4] ?? '', }; }, [typeaheadRegexMatches]); const suggestedUsers = useMentionTypeaheadUserSuggestions( userMentionsCandidates, typeaheadMatchedStrings, ); const suggestedChats = useMentionTypeaheadChatSuggestions( chatMentionSearchIndex, chatMentionCandidates, typeaheadMatchedStrings, ); const suggestions: $ReadOnlyArray = React.useMemo( () => [...suggestedUsers, ...suggestedChats], [suggestedUsers, suggestedChats], ); const currentUserIsVoiced = useThreadHasPermission( props.threadInfo, threadPermissions.VOICED, ); const currentUserCanJoin = useThreadHasPermission( props.threadInfo, threadPermissions.JOIN_THREAD, ); return ( ); } type DummyChatInputBarProps = { ...BaseProps, +onHeightMeasured: (height: number) => mixed, }; const noop = () => {}; function DummyChatInputBar(props: DummyChatInputBarProps): React.Node { const { onHeightMeasured, ...restProps } = props; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; onHeightMeasured(height); }, [onHeightMeasured], ); return ( ); } type ChatInputBarProps = { ...BaseProps, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; const ConnectedChatInputBar: React.ComponentType = React.memo(function ConnectedChatInputBar( props: ChatInputBarProps, ) { const { navigation, route, ...restProps } = props; const keyboardState = React.useContext(KeyboardContext); const { threadInfo } = props; const imagePastedCallback = React.useCallback( (imagePastedEvent: ImagePasteEvent) => { if (threadInfo.id !== imagePastedEvent.threadID) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; navigation.navigate<'ImagePasteModal'>({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, thread: threadInfo, }, }); }, [navigation, threadInfo], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); const chatContext = React.useContext(ChatContext); invariant(chatContext, 'should be set'); const { setChatInputBarHeight, deleteChatInputBarHeight } = chatContext; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; setChatInputBarHeight(threadInfo.id, height); }, [threadInfo.id, setChatInputBarHeight], ); React.useEffect(() => { return () => { deleteChatInputBarHeight(threadInfo.id); }; }, [deleteChatInputBarHeight, threadInfo.id]); const openCamera = React.useCallback(() => { keyboardState?.dismissKeyboard(); navigation.navigate<'ChatCameraModal'>({ name: ChatCameraModalRouteName, params: { presentedFrom: route.key, thread: threadInfo, }, }); }, [keyboardState, navigation, route.key, threadInfo]); return ( ); }); export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar }; diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js index 03ca0c4e4..db844e0a8 100644 --- a/web/chat/chat-input-bar.react.js +++ b/web/chat/chat-input-bar.react.js @@ -1,691 +1,708 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import * as React from 'react'; import { joinThreadActionTypes, newThreadActionTypes, useJoinThread, } from 'lib/actions/thread-actions.js'; +import type { UseJoinThreadInput } from 'lib/actions/thread-actions.js'; import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import { useChatMentionContext, useThreadChatMentionCandidates, } from 'lib/hooks/chat-mention-hooks.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { getTypeaheadRegexMatches, type MentionTypeaheadSuggestionItem, type TypeaheadMatchedStrings, useMentionTypeaheadChatSuggestions, useMentionTypeaheadUserSuggestions, useUserMentionsCandidates, } from 'lib/shared/mention-utils.js'; import { trimMessage, getNextLocalID } from 'lib/shared/message-utils.js'; import { checkIfDefaultMembersAreVoiced, threadActualMembers, useThreadFrozenDueToViewerBlock, useThreadHasPermission, viewerIsMember, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; -import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; +import type { + ThreadInfo, + RawThreadInfo, +} from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import { - type ClientThreadJoinRequest, - type ThreadJoinPayload, -} from 'lib/types/thread-types.js'; +import { type ThreadJoinPayload } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import css from './chat-input-bar.css'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import Button from '../components/button.react.js'; import { type InputState, type PendingMultimediaUpload, } from '../input/input-state.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { allowedMimeTypeString } from '../media/file-utils.js'; import Multimedia from '../media/multimedia.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; import { getMentionTypeaheadTooltipActions, getMentionTypeaheadTooltipButtons, webMentionTypeaheadRegex, } from '../utils/typeahead-utils.js'; type BaseProps = { +threadInfo: ThreadInfo, +inputState: InputState, }; type Props = { ...BaseProps, +viewerID: ?string, + +rawThreadInfo: RawThreadInfo, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +isThreadActive: boolean, +dispatchActionPromise: DispatchActionPromise, - +joinThread: (request: ClientThreadJoinRequest) => Promise, + +joinThread: (input: UseJoinThreadInput) => Promise, +typeaheadMatchedStrings: ?TypeaheadMatchedStrings, +suggestions: $ReadOnlyArray, +parentThreadInfo: ?ThreadInfo, +currentUserIsVoiced: boolean, +currentUserCanJoinThread: boolean, +threadFrozen: boolean, }; class ChatInputBar extends React.PureComponent { textarea: ?HTMLTextAreaElement; multimediaInput: ?HTMLInputElement; componentDidMount() { this.updateHeight(); if (this.props.isThreadActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isThreadActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props) { if (this.props.isThreadActive && !prevProps.isThreadActive) { this.addReplyListener(); } else if (!this.props.isThreadActive && prevProps.isThreadActive) { this.removeReplyListener(); } const { inputState } = this.props; const prevInputState = prevProps.inputState; if (inputState.draft !== prevInputState.draft) { this.updateHeight(); } if ( inputState.draft !== prevInputState.draft || inputState.textCursorPosition !== prevInputState.textCursorPosition ) { inputState.setTypeaheadState({ canBeVisible: true, }); } const curUploadIDs = ChatInputBar.unassignedUploadIDs( inputState.pendingUploads, ); const prevUploadIDs = ChatInputBar.unassignedUploadIDs( prevInputState.pendingUploads, ); const { multimediaInput, textarea } = this; if ( multimediaInput && _difference(prevUploadIDs)(curUploadIDs).length > 0 ) { // Whenever a pending upload is removed, we reset the file // HTMLInputElement's value field, so that if the same upload occurs again // the onChange call doesn't get filtered multimediaInput.value = ''; } else if ( textarea && _difference(curUploadIDs)(prevUploadIDs).length > 0 ) { // Whenever a pending upload is added, we focus the textarea textarea.focus(); return; } if ( (this.props.threadInfo.id !== prevProps.threadInfo.id || (inputState.textCursorPosition !== prevInputState.textCursorPosition && this.textarea?.selectionStart === this.textarea?.selectionEnd)) && this.textarea ) { this.textarea.focus(); this.textarea?.setSelectionRange( inputState.textCursorPosition, inputState.textCursorPosition, 'none', ); } } static unassignedUploadIDs( pendingUploads: $ReadOnlyArray, ): Array { return pendingUploads .filter( (pendingUpload: PendingMultimediaUpload) => !pendingUpload.messageID, ) .map((pendingUpload: PendingMultimediaUpload) => pendingUpload.localID); } updateHeight() { const textarea = this.textarea; if (textarea) { textarea.style.height = 'auto'; const newHeight = Math.min(textarea.scrollHeight, 150); textarea.style.height = `${newHeight}px`; } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } render(): React.Node { const isMember = viewerIsMember(this.props.threadInfo); let joinButton = null; if ( !isMember && this.props.currentUserCanJoinThread && !this.props.threadCreationInProgress ) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { buttonContent = ( <>

Join Chat

); } joinButton = (
); } const { pendingUploads, cancelPendingUpload } = this.props.inputState; const multimediaPreviews = pendingUploads.map(pendingUpload => { const { uri, mediaType, thumbHash, dimensions } = pendingUpload; let mediaSource = { thumbHash, dimensions }; if (mediaType !== 'encrypted_photo' && mediaType !== 'encrypted_video') { mediaSource = { ...mediaSource, type: mediaType, uri, thumbnailURI: null, }; } else { const { encryptionKey } = pendingUpload; invariant( encryptionKey, 'encryptionKey should be set for encrypted media', ); mediaSource = { ...mediaSource, type: mediaType, blobURI: uri, encryptionKey, thumbnailBlobURI: null, thumbnailEncryptionKey: null, }; } return ( ); }); const previews = multimediaPreviews.length > 0 ? (
{multimediaPreviews}
) : null; let content; // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place and won't // list the viewer as a member, which will end up hiding the input. In // this case, we will assume that our creation action will get translated, // into a join and as long as members are voiced, we can show the input. const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); let sendButton; if (this.props.inputState.draft.length) { sendButton = ( ); } if ( this.props.currentUserIsVoiced || (this.props.threadCreationInProgress && defaultMembersAreVoiced) ) { content = (