diff --git a/lib/actions/entry-actions.js b/lib/actions/entry-actions.js index fa6626247..92d30e725 100644 --- a/lib/actions/entry-actions.js +++ b/lib/actions/entry-actions.js @@ -1,505 +1,577 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; import { extractKeyserverIDFromID, sortCalendarQueryPerKeyserver, } 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 { dmOperationSpecificationTypes, type OutboundDMOperationSpecification, } from '../shared/dm-ops/dm-op-utils.js'; import { useProcessAndSendDMOperation } from '../shared/dm-ops/process-dm-ops.js'; import { getNextLocalID } from '../shared/message-utils.js'; import { type DMCreateEntryOperation, type DMEditEntryOperation, + type DMDeleteEntryOperation, } from '../types/dm-ops.js'; import type { RawEntryInfo, CalendarQuery, SaveEntryInfo, SaveEntryResult, CreateEntryInfo, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResult, RestoreEntryInfo, RestoreEntryResult, FetchEntryInfosResult, CalendarQueryUpdateResult, } from '../types/entry-types.js'; import type { HistoryRevisionInfo } from '../types/history-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types'; import { thickThreadTypes } from '../types/thread-types-enum.js'; import { dateFromString, dateString as stringFromDate, } from '../utils/date-utils.js'; import { useSelector } from '../utils/redux-utils.js'; const fetchEntriesActionTypes = Object.freeze({ started: 'FETCH_ENTRIES_STARTED', success: 'FETCH_ENTRIES_SUCCESS', failed: 'FETCH_ENTRIES_FAILED', }); const fetchEntries = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((calendarQuery: CalendarQuery) => Promise) => async calendarQuery => { const calendarQueries = sortCalendarQueryPerKeyserver( calendarQuery, allKeyserverIDs, ); const requests: { [string]: CalendarQuery } = {}; for (const keyserverID of allKeyserverIDs) { requests[keyserverID] = calendarQueries[keyserverID]; } const responses = await callKeyserverEndpoint('fetch_entries', requests); let rawEntryInfos: $ReadOnlyArray = []; for (const keyserverID in responses) { rawEntryInfos = rawEntryInfos.concat( responses[keyserverID].rawEntryInfos, ); } return { rawEntryInfos, }; }; function useFetchEntries(): ( calendarQuery: CalendarQuery, ) => Promise { return useKeyserverCall(fetchEntries); } export type UpdateCalendarQueryInput = { +calendarQuery: CalendarQuery, +reduxAlreadyUpdated?: boolean, +keyserverIDs?: $ReadOnlyArray, }; const updateCalendarQueryActionTypes = Object.freeze({ started: 'UPDATE_CALENDAR_QUERY_STARTED', success: 'UPDATE_CALENDAR_QUERY_SUCCESS', failed: 'UPDATE_CALENDAR_QUERY_FAILED', }); const updateCalendarQuery = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): (( input: UpdateCalendarQueryInput, ) => Promise) => async input => { const { calendarQuery, reduxAlreadyUpdated = false } = input; const keyserverIDs = input.keyserverIDs ?? allKeyserverIDs; const calendarQueries = sortCalendarQueryPerKeyserver( calendarQuery, keyserverIDs, ); const requests: { [string]: CalendarQuery } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = calendarQueries[keyserverID]; } const responses = await callKeyserverEndpoint( 'update_calendar_query', requests, ); let rawEntryInfos: $ReadOnlyArray = []; let deletedEntryIDs: $ReadOnlyArray = []; for (const keyserverID in responses) { rawEntryInfos = rawEntryInfos.concat( responses[keyserverID].rawEntryInfos, ); deletedEntryIDs = deletedEntryIDs.concat( responses[keyserverID].deletedEntryIDs, ); } return { rawEntryInfos, deletedEntryIDs, calendarQuery, calendarQueryAlreadyUpdated: reduxAlreadyUpdated, keyserverIDs, }; }; function useUpdateCalendarQuery(): ( input: UpdateCalendarQueryInput, ) => Promise { return useKeyserverCall(updateCalendarQuery); } const createLocalEntryActionType = 'CREATE_LOCAL_ENTRY'; function createLocalEntry( threadID: string, dateString: string, creatorID: string, ): RawEntryInfo { const date = dateFromString(dateString); const localID = getNextLocalID(); const newEntryInfo: RawEntryInfo = { localID, threadID, text: '', year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), creationTime: Date.now(), creatorID, deleted: false, }; return newEntryInfo; } const createEntryActionTypes = Object.freeze({ started: 'CREATE_ENTRY_STARTED', success: 'CREATE_ENTRY_SUCCESS', failed: 'CREATE_ENTRY_FAILED', }); const createEntry = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: CreateEntryInfo) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const calendarQueries = sortCalendarQueryPerKeyserver(input.calendarQuery, [ keyserverID, ]); const requests = { [keyserverID]: { ...input, calendarQuery: calendarQueries[keyserverID], }, }; const response = await callKeyserverEndpoint('create_entry', requests); return { entryID: response[keyserverID].entryID, newMessageInfos: response[keyserverID].newMessageInfos, threadID: input.threadID, localID: input.localID, updatesResult: response[keyserverID].updatesResult, }; }; export type UseCreateEntryInput = $ReadOnly< | { +thick: false, +createEntryInfo: CreateEntryInfo, } | { +thick: true, +threadInfo: ThreadInfo, +createEntryInfo: CreateEntryInfo, }, >; function useCreateEntry(): ( input: UseCreateEntryInput, ) => Promise { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const processAndSendDMOperation = useProcessAndSendDMOperation(); const keyserverCall = useKeyserverCall(createEntry); return React.useCallback( async (input: UseCreateEntryInput) => { if (!input.thick) { const result = await keyserverCall(input.createEntryInfo); return result; } invariant(viewerID, 'viewerID must be set'); const entryID = uuid.v4(); const { createEntryInfo, threadInfo } = input; const op: DMCreateEntryOperation = { type: 'create_entry', threadID: threadInfo.id, creatorID: viewerID, time: createEntryInfo.timestamp, entryID: uuid.v4(), entryDate: createEntryInfo.date, text: createEntryInfo.text, messageID: 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 { entryID, newMessageInfos: [], threadID: createEntryInfo.threadID, localID: createEntryInfo.localID, updatesResult: { viewerUpdates: [], userInfos: [], }, }; }, [keyserverCall, processAndSendDMOperation, viewerID], ); } const saveEntryActionTypes = Object.freeze({ started: 'SAVE_ENTRY_STARTED', success: 'SAVE_ENTRY_SUCCESS', failed: 'SAVE_ENTRY_FAILED', }); const concurrentModificationResetActionType = 'CONCURRENT_MODIFICATION_RESET'; const saveEntry = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: SaveEntryInfo) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.entryID); const calendarQueries = sortCalendarQueryPerKeyserver(input.calendarQuery, [ keyserverID, ]); const requests = { [keyserverID]: { ...input, calendarQuery: calendarQueries[keyserverID], }, }; const response = await callKeyserverEndpoint('update_entry', requests); return { entryID: response[keyserverID].entryID, newMessageInfos: response[keyserverID].newMessageInfos, updatesResult: response[keyserverID].updatesResult, }; }; export type UseSaveEntryInput = $ReadOnly< | { +thick: false, +saveEntryInfo: SaveEntryInfo, } | { +thick: true, +threadInfo: ThreadInfo, +saveEntryInfo: SaveEntryInfo, }, >; function useSaveEntry(): ( input: UseSaveEntryInput, ) => Promise { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const processAndSendDMOperation = useProcessAndSendDMOperation(); const keyserverCall = useKeyserverCall(saveEntry); const entryInfos = useSelector(state => state.entryStore.entryInfos); return React.useCallback( async (input: UseSaveEntryInput) => { if (!input.thick) { const result = await keyserverCall(input.saveEntryInfo); return result; } const { saveEntryInfo, threadInfo } = input; const prevEntry = entryInfos[saveEntryInfo.entryID]; invariant(viewerID, 'viewerID must be set'); const op: DMEditEntryOperation = { type: 'edit_entry', threadID: threadInfo.id, creatorID: viewerID, creationTime: prevEntry.creationTime, time: saveEntryInfo.timestamp, entryID: saveEntryInfo.entryID, entryDate: stringFromDate( prevEntry.year, prevEntry.month, prevEntry.day, ), text: saveEntryInfo.text, messageID: 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 { entryID: saveEntryInfo.entryID, newMessageInfos: [], updatesResult: { viewerUpdates: [], userInfos: [], }, }; }, [keyserverCall, processAndSendDMOperation, viewerID, entryInfos], ); } const deleteEntryActionTypes = Object.freeze({ started: 'DELETE_ENTRY_STARTED', success: 'DELETE_ENTRY_SUCCESS', failed: 'DELETE_ENTRY_FAILED', }); const deleteEntry = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((info: DeleteEntryInfo) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.entryID); const calendarQueries = sortCalendarQueryPerKeyserver(input.calendarQuery, [ keyserverID, ]); const requests = { [keyserverID]: { ...input, calendarQuery: calendarQueries[keyserverID], timestamp: Date.now(), }, }; const response = await callKeyserverEndpoint('delete_entry', requests); return { newMessageInfos: response[keyserverID].newMessageInfos, threadID: response[keyserverID].threadID, updatesResult: response[keyserverID].updatesResult, }; }; +export type UseDeleteEntryInput = $ReadOnly< + | { + +thick: false, + +deleteEntryInfo: DeleteEntryInfo, + } + | { + +thick: true, + +threadInfo: ThreadInfo, + +deleteEntryInfo: DeleteEntryInfo, + }, +>; function useDeleteEntry(): ( - info: DeleteEntryInfo, + input: UseDeleteEntryInput, ) => Promise { - return useKeyserverCall(deleteEntry); + const viewerID = useSelector( + state => state.currentUserInfo && state.currentUserInfo.id, + ); + + const processAndSendDMOperation = useProcessAndSendDMOperation(); + const keyserverCall = useKeyserverCall(deleteEntry); + const entryInfos = useSelector(state => state.entryStore.entryInfos); + + return React.useCallback( + async (input: UseDeleteEntryInput) => { + if (!input.thick) { + const result = await keyserverCall(input.deleteEntryInfo); + return result; + } + const { deleteEntryInfo, threadInfo } = input; + const prevEntry = entryInfos[deleteEntryInfo.entryID]; + + invariant(viewerID, 'viewerID must be set'); + + const op: DMDeleteEntryOperation = { + type: 'delete_entry', + threadID: threadInfo.id, + creatorID: viewerID, + creationTime: prevEntry.creationTime, + time: Date.now(), + entryID: deleteEntryInfo.entryID, + entryDate: stringFromDate( + prevEntry.year, + prevEntry.month, + prevEntry.day, + ), + prevText: deleteEntryInfo.prevText, + messageID: 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, + newMessageInfos: [], + updatesResult: { + viewerUpdates: [], + userInfos: [], + }, + }; + }, + [keyserverCall, processAndSendDMOperation, viewerID, entryInfos], + ); } export type FetchRevisionsForEntryInput = { +entryID: string, }; const fetchRevisionsForEntryActionTypes = Object.freeze({ started: 'FETCH_REVISIONS_FOR_ENTRY_STARTED', success: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS', failed: 'FETCH_REVISIONS_FOR_ENTRY_FAILED', }); const fetchRevisionsForEntry = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: FetchRevisionsForEntryInput, ) => Promise<$ReadOnlyArray>) => async input => { const keyserverID = extractKeyserverIDFromID(input.entryID); const requests = { [keyserverID]: { id: input.entryID, }, }; const response = await callKeyserverEndpoint( 'fetch_entry_revisions', requests, ); return response[keyserverID].result; }; function useFetchRevisionsForEntry(): ( input: FetchRevisionsForEntryInput, ) => Promise<$ReadOnlyArray> { return useKeyserverCall(fetchRevisionsForEntry); } const restoreEntryActionTypes = Object.freeze({ started: 'RESTORE_ENTRY_STARTED', success: 'RESTORE_ENTRY_SUCCESS', failed: 'RESTORE_ENTRY_FAILED', }); const restoreEntry = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: RestoreEntryInfo) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.entryID); const calendarQueries = sortCalendarQueryPerKeyserver(input.calendarQuery, [ keyserverID, ]); const requests = { [keyserverID]: { ...input, calendarQuery: calendarQueries[keyserverID], timestamp: Date.now(), }, }; const response = await callKeyserverEndpoint('restore_entry', requests); return { newMessageInfos: response[keyserverID].newMessageInfos, updatesResult: response[keyserverID].updatesResult, }; }; function useRestoreEntry(): ( input: RestoreEntryInfo, ) => Promise { return useKeyserverCall(restoreEntry); } export { fetchEntriesActionTypes, useFetchEntries, updateCalendarQueryActionTypes, useUpdateCalendarQuery, createLocalEntryActionType, createLocalEntry, createEntryActionTypes, useCreateEntry, saveEntryActionTypes, concurrentModificationResetActionType, useSaveEntry, deleteEntryActionTypes, useDeleteEntry, fetchRevisionsForEntryActionTypes, useFetchRevisionsForEntry, restoreEntryActionTypes, useRestoreEntry, }; diff --git a/lib/shared/dm-ops/delete-entry-spec.js b/lib/shared/dm-ops/delete-entry-spec.js index 2ec510e9a..5fcb0522f 100644 --- a/lib/shared/dm-ops/delete-entry-spec.js +++ b/lib/shared/dm-ops/delete-entry-spec.js @@ -1,97 +1,99 @@ // @flow import uuid from 'uuid'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec.js'; import type { DMDeleteEntryOperation } from '../../types/dm-ops.js'; import type { ThickRawEntryInfo } from '../../types/entry-types.js'; import { messageTypes } from '../../types/message-types-enum.js'; import { updateTypes } from '../../types/update-types-enum.js'; import type { EntryUpdateInfo } from '../../types/update-types.js'; import { dateFromString } from '../../utils/date-utils.js'; import { rawMessageInfoFromMessageData } from '../message-utils.js'; function createMessageDataFromDMOperation(dmOperation: DMDeleteEntryOperation) { - const { threadID, creatorID, time, entryID, entryDate, text } = dmOperation; + const { threadID, creatorID, time, entryID, entryDate, prevText } = + dmOperation; return { type: messageTypes.DELETE_ENTRY, threadID, creatorID, time, entryID, date: entryDate, - text, + text: prevText, }; } const deleteEntrySpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async (dmOperation: DMDeleteEntryOperation) => { const messageData = createMessageDataFromDMOperation(dmOperation); return { messageDatas: [messageData] }; }, processDMOperation: async (dmOperation: DMDeleteEntryOperation) => { const { threadID, creatorID, time, + creationTime, entryID, entryDate: dateString, - text, + prevText, messageID, } = dmOperation; const messageData = createMessageDataFromDMOperation(dmOperation); const rawMessageInfos = [ rawMessageInfoFromMessageData(messageData, messageID), ]; const date = dateFromString(dateString); const rawEntryInfo: ThickRawEntryInfo = { id: entryID, threadID, - text, + text: prevText, year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), - creationTime: time, + creationTime, creatorID, thick: true, deleted: true, lastUpdatedTime: time, }; const entryUpdateInfo: EntryUpdateInfo = { entryInfo: rawEntryInfo, type: updateTypes.UPDATE_ENTRY, id: uuid.v4(), time, }; return { rawMessageInfos, updateInfos: [entryUpdateInfo], }; }, canBeProcessed( dmOperation: DMDeleteEntryOperation, viewerID: string, utilities: ProcessDMOperationUtilities, ) { if (utilities.threadInfos[dmOperation.threadID]) { return { isProcessingPossible: true }; } return { isProcessingPossible: false, reason: { type: 'missing_thread', threadID: dmOperation.threadID, }, }; }, supportsAutoRetry: true, }); export { deleteEntrySpec }; diff --git a/lib/types/dm-ops.js b/lib/types/dm-ops.js index a0bbf11db..1797224dd 100644 --- a/lib/types/dm-ops.js +++ b/lib/types/dm-ops.js @@ -1,529 +1,531 @@ // @flow import t, { type TInterface, type TUnion } from 'tcomb'; import { clientAvatarValidator, type ClientAvatar } from './avatar-types.js'; import { type Media, mediaValidator } from './media-types.js'; import type { RawMessageInfo } from './message-types.js'; import type { NotificationsCreationData } from './notif-types.js'; import type { OutboundP2PMessage } from './sqlite-types.js'; import { type ThreadSubscription, threadSubscriptionValidator, } from './subscription-types.js'; import { type NonSidebarThickThreadType, nonSidebarThickThreadTypes, type ThickThreadType, thickThreadTypeValidator, } from './thread-types-enum.js'; import { threadTimestampsValidator, type ThreadTimestamps, } from './thread-types.js'; import type { ClientUpdateInfo } from './update-types.js'; import { values } from '../utils/objects.js'; import { tColor, tShape, tString, tUserID } from '../utils/validation-utils.js'; export const dmOperationTypes = Object.freeze({ CREATE_THREAD: 'create_thread', CREATE_SIDEBAR: 'create_sidebar', SEND_TEXT_MESSAGE: 'send_text_message', SEND_MULTIMEDIA_MESSAGE: 'send_multimedia_message', SEND_REACTION_MESSAGE: 'send_reaction_message', SEND_EDIT_MESSAGE: 'send_edit_message', ADD_MEMBERS: 'add_members', ADD_VIEWER_TO_THREAD_MEMBERS: 'add_viewer_to_thread_members', JOIN_THREAD: 'join_thread', LEAVE_THREAD: 'leave_thread', REMOVE_MEMBERS: 'remove_members', CHANGE_THREAD_SETTINGS: 'change_thread_settings', CHANGE_THREAD_SUBSCRIPTION: 'change_thread_subscription', CHANGE_THREAD_READ_STATUS: 'change_thread_read_status', CREATE_ENTRY: 'create_entry', DELETE_ENTRY: 'delete_entry', EDIT_ENTRY: 'edit_entry', }); export type DMOperationType = $Values; type MemberIDWithSubscription = { +id: string, +subscription: ThreadSubscription, }; export const memberIDWithSubscriptionValidator: TInterface = tShape({ id: tUserID, subscription: threadSubscriptionValidator, }); export type CreateThickRawThreadInfoInput = { +threadID: string, +threadType: ThickThreadType, +creationTime: number, +parentThreadID?: ?string, +allMemberIDsWithSubscriptions: $ReadOnlyArray, +roleID: string, +unread: boolean, +timestamps: ThreadTimestamps, +name?: ?string, +avatar?: ?ClientAvatar, +description?: ?string, +color?: ?string, +containingThreadID?: ?string, +sourceMessageID?: ?string, +repliesCount?: ?number, +pinnedCount?: ?number, }; export const createThickRawThreadInfoInputValidator: TInterface = tShape({ threadID: t.String, threadType: thickThreadTypeValidator, creationTime: t.Number, parentThreadID: t.maybe(t.String), allMemberIDsWithSubscriptions: t.list(memberIDWithSubscriptionValidator), roleID: t.String, unread: t.Boolean, timestamps: threadTimestampsValidator, name: t.maybe(t.String), avatar: t.maybe(clientAvatarValidator), description: t.maybe(t.String), color: t.maybe(t.String), containingThreadID: t.maybe(t.String), sourceMessageID: t.maybe(t.String), repliesCount: t.maybe(t.Number), pinnedCount: t.maybe(t.Number), }); export type DMCreateThreadOperation = { +type: 'create_thread', +threadID: string, +creatorID: string, +time: number, +threadType: NonSidebarThickThreadType, +memberIDs: $ReadOnlyArray, +roleID: string, +newMessageID: string, }; export const dmCreateThreadOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.CREATE_THREAD), threadID: t.String, creatorID: tUserID, time: t.Number, threadType: t.enums.of(values(nonSidebarThickThreadTypes)), memberIDs: t.list(tUserID), roleID: t.String, newMessageID: t.String, }); export type DMCreateSidebarOperation = { +type: 'create_sidebar', +threadID: string, +creatorID: string, +time: number, +parentThreadID: string, +memberIDs: $ReadOnlyArray, +sourceMessageID: string, +roleID: string, +newSidebarSourceMessageID: string, +newCreateSidebarMessageID: string, }; export const dmCreateSidebarOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.CREATE_SIDEBAR), threadID: t.String, creatorID: tUserID, time: t.Number, parentThreadID: t.String, memberIDs: t.list(tUserID), sourceMessageID: t.String, roleID: t.String, newSidebarSourceMessageID: t.String, newCreateSidebarMessageID: t.String, }); export type DMSendTextMessageOperation = { +type: 'send_text_message', +threadID: string, +creatorID: string, +time: number, +messageID: string, +text: string, }; export const dmSendTextMessageOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.SEND_TEXT_MESSAGE), threadID: t.String, creatorID: tUserID, time: t.Number, messageID: t.String, text: t.String, }); export type DMSendMultimediaMessageOperation = { +type: 'send_multimedia_message', +threadID: string, +creatorID: string, +time: number, +messageID: string, +media: $ReadOnlyArray, }; export const dmSendMultimediaMessageOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.SEND_MULTIMEDIA_MESSAGE), threadID: t.String, creatorID: tUserID, time: t.Number, messageID: t.String, media: t.list(mediaValidator), }); export type DMSendReactionMessageOperation = { +type: 'send_reaction_message', +threadID: string, +creatorID: string, +time: number, +messageID: string, +targetMessageID: string, +reaction: string, +action: 'add_reaction' | 'remove_reaction', }; export const dmSendReactionMessageOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.SEND_REACTION_MESSAGE), threadID: t.String, creatorID: tUserID, time: t.Number, messageID: t.String, targetMessageID: t.String, reaction: t.String, action: t.enums.of(['add_reaction', 'remove_reaction']), }); export type DMSendEditMessageOperation = { +type: 'send_edit_message', +threadID: string, +creatorID: string, +time: number, +messageID: string, +targetMessageID: string, +text: string, }; export const dmSendEditMessageOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.SEND_EDIT_MESSAGE), threadID: t.String, creatorID: tUserID, time: t.Number, messageID: t.String, targetMessageID: t.String, text: t.String, }); type DMAddMembersBase = { +editorID: string, +time: number, +addedUserIDs: $ReadOnlyArray, }; const dmAddMembersBaseValidatorShape = { editorID: tUserID, time: t.Number, addedUserIDs: t.list(tUserID), }; export type DMAddMembersOperation = $ReadOnly<{ +type: 'add_members', +threadID: string, +messageID: string, ...DMAddMembersBase, }>; export const dmAddMembersOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.ADD_MEMBERS), threadID: t.String, messageID: t.String, ...dmAddMembersBaseValidatorShape, }); export type DMAddViewerToThreadMembersOperation = $ReadOnly<{ +type: 'add_viewer_to_thread_members', +messageID: ?string, +existingThreadDetails: CreateThickRawThreadInfoInput, ...DMAddMembersBase, }>; export const dmAddViewerToThreadMembersValidator: TInterface = tShape({ type: tString(dmOperationTypes.ADD_VIEWER_TO_THREAD_MEMBERS), messageID: t.maybe(t.String), existingThreadDetails: createThickRawThreadInfoInputValidator, ...dmAddMembersBaseValidatorShape, }); export type DMJoinThreadOperation = { +type: 'join_thread', +joinerID: string, +time: number, +messageID: string, +existingThreadDetails: CreateThickRawThreadInfoInput, }; export const dmJoinThreadOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.JOIN_THREAD), joinerID: tUserID, time: t.Number, messageID: t.String, existingThreadDetails: createThickRawThreadInfoInputValidator, }); export type DMLeaveThreadOperation = { +type: 'leave_thread', +editorID: string, +time: number, +messageID: string, +threadID: string, }; export const dmLeaveThreadOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.LEAVE_THREAD), editorID: tUserID, time: t.Number, messageID: t.String, threadID: t.String, }); export type DMRemoveMembersOperation = { +type: 'remove_members', +editorID: string, +time: number, +messageID: string, +threadID: string, +removedUserIDs: $ReadOnlyArray, }; export const dmRemoveMembersOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.REMOVE_MEMBERS), editorID: tUserID, time: t.Number, messageID: t.String, threadID: t.String, removedUserIDs: t.list(tUserID), }); export type DMThreadSettingsChanges = { +name?: string, +description?: string, +color?: string, +avatar?: ClientAvatar | null, }; export type DMChangeThreadSettingsOperation = $ReadOnly<{ +type: 'change_thread_settings', +threadID: string, +editorID: string, +time: number, +changes: DMThreadSettingsChanges, +messageIDsPrefix: string, }>; export const dmChangeThreadSettingsOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.CHANGE_THREAD_SETTINGS), threadID: t.String, editorID: tUserID, time: t.Number, changes: tShape({ name: t.maybe(t.String), description: t.maybe(t.String), color: t.maybe(tColor), avatar: t.maybe(clientAvatarValidator), }), messageIDsPrefix: t.String, }); export type DMChangeThreadSubscriptionOperation = { +type: 'change_thread_subscription', +time: number, +threadID: string, +creatorID: string, +subscription: ThreadSubscription, }; export const dmChangeThreadSubscriptionOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.CHANGE_THREAD_SUBSCRIPTION), time: t.Number, threadID: t.String, creatorID: tUserID, subscription: threadSubscriptionValidator, }); export type DMChangeThreadReadStatusOperation = { +type: 'change_thread_read_status', +time: number, +threadID: string, +creatorID: string, +unread: boolean, }; export const dmChangeThreadReadStatusOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.CHANGE_THREAD_READ_STATUS), time: t.Number, threadID: t.String, creatorID: tUserID, unread: t.Boolean, }); export type ComposableDMOperation = | DMSendTextMessageOperation | DMSendMultimediaMessageOperation; export type DMCreateEntryOperation = { +type: 'create_entry', +threadID: string, +creatorID: string, +time: number, +entryID: string, +entryDate: string, +text: string, +messageID: string, }; export const dmCreateEntryOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.CREATE_ENTRY), threadID: t.String, creatorID: t.String, time: t.Number, entryID: t.String, entryDate: t.String, text: t.String, messageID: t.String, }); export type DMDeleteEntryOperation = { +type: 'delete_entry', +threadID: string, +creatorID: string, +time: number, + +creationTime: number, +entryID: string, +entryDate: string, - +text: string, + +prevText: string, +messageID: string, }; export const dmDeleteEntryOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.DELETE_ENTRY), threadID: t.String, creatorID: t.String, time: t.Number, + creationTime: t.Number, entryID: t.String, entryDate: t.String, - text: t.String, + prevText: t.String, messageID: t.String, }); export type DMEditEntryOperation = { +type: 'edit_entry', +threadID: string, +creatorID: string, +time: number, +creationTime: number, +entryID: string, +entryDate: string, +text: string, +messageID: string, }; export const dmEditEntryOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.EDIT_ENTRY), threadID: t.String, creatorID: t.String, creationTime: t.Number, time: t.Number, entryID: t.String, entryDate: t.String, text: t.String, messageID: t.String, }); export type DMOperation = | DMCreateThreadOperation | DMCreateSidebarOperation | DMSendTextMessageOperation | DMSendMultimediaMessageOperation | DMSendReactionMessageOperation | DMSendEditMessageOperation | DMAddMembersOperation | DMAddViewerToThreadMembersOperation | DMJoinThreadOperation | DMLeaveThreadOperation | DMRemoveMembersOperation | DMChangeThreadSettingsOperation | DMChangeThreadSubscriptionOperation | DMChangeThreadReadStatusOperation | DMCreateEntryOperation | DMDeleteEntryOperation | DMEditEntryOperation; export const dmOperationValidator: TUnion = t.union([ dmCreateThreadOperationValidator, dmCreateSidebarOperationValidator, dmSendTextMessageOperationValidator, dmSendMultimediaMessageOperationValidator, dmSendReactionMessageOperationValidator, dmSendEditMessageOperationValidator, dmAddMembersOperationValidator, dmAddViewerToThreadMembersValidator, dmJoinThreadOperationValidator, dmLeaveThreadOperationValidator, dmRemoveMembersOperationValidator, dmChangeThreadSettingsOperationValidator, dmChangeThreadSubscriptionOperationValidator, dmChangeThreadReadStatusOperationValidator, dmCreateEntryOperationValidator, dmDeleteEntryOperationValidator, dmEditEntryOperationValidator, ]); export type DMOperationResult = { rawMessageInfos: Array, updateInfos: Array, }; export const processDMOpsActionType = 'PROCESS_DM_OPS'; export type ProcessDMOpsPayload = { +rawMessageInfos: $ReadOnlyArray, +updateInfos: $ReadOnlyArray, +outboundP2PMessages: ?$ReadOnlyArray, // For messages that could be retried from UI, we need to bind DM `messageID` // with `outboundP2PMessages` to keep track of whether all P2P messages // were queued on Tunnelbroker. +composableMessageID: ?string, +notificationsCreationData: ?NotificationsCreationData, }; export const queueDMOpsActionType = 'QUEUE_DM_OPS'; export type QueueDMOpsPayload = { +operation: DMOperation, +threadID: string, +timestamp: number, }; export const pruneDMOpsQueueActionType = 'PRUNE_DM_OPS_QUEUE'; export type PruneDMOpsQueuePayload = { +pruneMaxTimestamp: number, }; export const clearQueuedThreadDMOpsActionType = 'CLEAR_QUEUED_THREAD_DM_OPS'; export type ClearQueuedThreadDMOpsPayload = { +threadID: string, }; export type QueuedDMOperations = { +operations: { +[threadID: string]: $ReadOnlyArray<{ +operation: DMOperation, +timestamp: number, }>, }, }; diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index c06e650ae..3a02504ff 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,880 +1,893 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import _omit from 'lodash/fp/omit.js'; import * as React from 'react'; import { Keyboard, LayoutAnimation, Platform, Text, TextInput as BaseTextInput, TouchableWithoutFeedback, View, } from 'react-native'; import shallowequal from 'shallowequal'; import tinycolor from 'tinycolor2'; import { concurrentModificationResetActionType, createEntryActionTypes, deleteEntryActionTypes, saveEntryActionTypes, useCreateEntry, useDeleteEntry, useSaveEntry, type UseCreateEntryInput, type UseSaveEntryInput, + type UseDeleteEntryInput, } from 'lib/actions/entry-actions.js'; import { extractKeyserverIDFromIDOptional } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import { useThreadHasPermission } from 'lib/shared/thread-utils.js'; import type { CalendarQuery, CreateEntryPayload, - DeleteEntryInfo, DeleteEntryResult, SaveEntryPayload, SaveEntryResult, } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ResolvedThreadInfo, ThreadInfo, } 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 { threadTypeIsThick } from 'lib/types/thread-types-enum.js'; import { dateString } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { ServerError } from 'lib/utils/errors.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import sleep from 'lib/utils/sleep.js'; import type { EntryInfoWithHeight } from './calendar-screen.react.js'; import type { CalendarNavigationProp } from './calendar.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import Button from '../components/button.react.js'; import SingleLine from '../components/single-line.react.js'; import TextInput from '../components/text-input.react.js'; import Markdown from '../markdown/markdown.react.js'; import { inlineMarkdownRules } from '../markdown/rules.react.js'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { ThreadPickerModalRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { colors, useStyles } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import Alert from '../utils/alert.js'; import { waitForInteractions } from '../utils/timers.js'; function hueDistance(firstColor: string, secondColor: string): number { const firstHue = tinycolor(firstColor).toHsv().h; const secondHue = tinycolor(secondColor).toHsv().h; const distance = Math.abs(firstHue - secondHue); return distance > 180 ? 360 - distance : distance; } const omitEntryInfo = _omit(['entryInfo']); function dummyNodeForEntryHeightMeasurement( entryText: string, ): React.Element { const text = entryText === '' ? ' ' : entryText; return ( {text} ); } const unboundStyles = { actionLinks: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', marginTop: -5, }, button: { padding: 5, }, buttonContents: { flex: 1, flexDirection: 'row', }, container: { backgroundColor: 'listBackground', }, entry: { borderRadius: 8, margin: 5, overflow: 'hidden', }, leftLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', paddingHorizontal: 5, }, leftLinksText: { fontSize: 12, fontWeight: 'bold', paddingLeft: 5, }, pencilIcon: { lineHeight: 13, paddingTop: 1, }, rightLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 5, }, rightLinksText: { fontSize: 12, fontWeight: 'bold', }, text: { fontFamily: 'System', fontSize: 16, }, textContainer: { position: 'absolute', top: 0, paddingBottom: 6, paddingLeft: 10, paddingRight: 10, paddingTop: 5, transform: (Platform.select({ ios: [{ translateY: -1 / 3 }], default: [], }): $ReadOnlyArray<{ +translateY: number }>), }, textInput: { fontFamily: 'System', fontSize: 16, left: ((Platform.OS === 'android' ? 9.8 : 10): number), margin: 0, padding: 0, position: 'absolute', right: 10, top: ((Platform.OS === 'android' ? 4.8 : 0.5): number), }, }; type SharedProps = { +navigation: CalendarNavigationProp<'CalendarScreen'>, +entryInfo: EntryInfoWithHeight, +visible: boolean, +active: boolean, +makeActive: (entryKey: string, active: boolean) => void, +onEnterEditMode: (entryInfo: EntryInfoWithHeight) => void, +onConcludeEditMode: (entryInfo: EntryInfoWithHeight) => void, +onPressWhitespace: () => void, +entryRef: (entryKey: string, entry: ?InternalEntry) => void, }; type BaseProps = { ...SharedProps, +threadInfo: ThreadInfo, }; type Props = { ...SharedProps, +threadInfo: ResolvedThreadInfo, // Redux state +calendarQuery: () => CalendarQuery, +online: boolean, +styles: $ReadOnly, // Nav state +threadPickerActive: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +createEntry: (input: UseCreateEntryInput) => Promise, +saveEntry: (input: UseSaveEntryInput) => Promise, - +deleteEntry: (info: DeleteEntryInfo) => Promise, + +deleteEntry: (input: UseDeleteEntryInput) => Promise, +canEditEntry: boolean, }; type State = { +editing: boolean, +text: string, +loadingStatus: LoadingStatus, +height: number, }; class InternalEntry extends React.Component { textInput: ?React.ElementRef; creating: boolean = false; needsUpdateAfterCreation: boolean = false; needsDeleteAfterSave: boolean = false; nextSaveAttemptIndex: number = 0; mounted: boolean = false; deleted: boolean = false; currentlySaving: ?string; creationPromise: ?Promise; savePromise: ?Promise; constructor(props: Props) { super(props); this.state = { editing: false, text: props.entryInfo.text, loadingStatus: 'inactive', height: props.entryInfo.textHeight, }; this.state = { ...this.state, editing: InternalEntry.isActive(props, this.state), }; } guardedSetState(input: Partial) { if (this.mounted) { this.setState(input); } } shouldComponentUpdate(nextProps: Props, nextState: State): boolean { return ( !shallowequal(nextState, this.state) || !shallowequal(omitEntryInfo(nextProps), omitEntryInfo(this.props)) || !_isEqual(nextProps.entryInfo)(this.props.entryInfo) ); } componentDidUpdate(prevProps: Props, prevState: State) { const wasActive = InternalEntry.isActive(prevProps, prevState); const isActive = InternalEntry.isActive(this.props, this.state); if ( !isActive && (this.props.entryInfo.text !== prevProps.entryInfo.text || this.props.entryInfo.textHeight !== prevProps.entryInfo.textHeight) && (this.props.entryInfo.text !== this.state.text || this.props.entryInfo.textHeight !== this.state.height) ) { this.guardedSetState({ text: this.props.entryInfo.text, height: this.props.entryInfo.textHeight, }); this.currentlySaving = null; } if ( !this.props.active && this.state.text === prevState.text && this.state.height !== prevState.height && this.state.height !== this.props.entryInfo.textHeight ) { const approxMeasuredHeight = Math.round(this.state.height * 1000) / 1000; const approxExpectedHeight = Math.round(this.props.entryInfo.textHeight * 1000) / 1000; console.log( `Entry height for ${entryKey(this.props.entryInfo)} was expected to ` + `be ${approxExpectedHeight} but is actually ` + `${approxMeasuredHeight}. This means Calendar's FlatList isn't ` + 'getting the right item height for some of its nodes, which is ' + 'guaranteed to cause glitchy behavior. Please investigate!!', ); } // Our parent will set the active prop to false if something else gets // pressed or if the Entry is scrolled out of view. In either of those cases // we should complete the edit process. if (!this.props.active && prevProps.active) { this.completeEdit(); } if (this.state.height !== prevState.height || isActive !== wasActive) { LayoutAnimation.easeInEaseOut(); } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } if ( this.state.editing && prevState.editing && (this.state.text.trim() === '') !== (prevState.text.trim() === '') ) { LayoutAnimation.easeInEaseOut(); } } componentDidMount() { this.mounted = true; this.props.entryRef(entryKey(this.props.entryInfo), this); } componentWillUnmount() { this.mounted = false; this.props.entryRef(entryKey(this.props.entryInfo), null); this.props.onConcludeEditMode(this.props.entryInfo); } static isActive(props: Props, state: State): boolean { return ( props.active || state.editing || !props.entryInfo.id || state.loadingStatus !== 'inactive' ); } render(): React.Node { const active = InternalEntry.isActive(this.props, this.state); const { editing } = this.state; const threadColor = `#${this.props.threadInfo.color}`; const darkColor = colorIsDark(this.props.threadInfo.color); let actionLinks = null; if (active) { const actionLinksColor = darkColor ? '#D3D3D3' : '#404040'; const actionLinksTextStyle = { color: actionLinksColor }; const { modalIosHighlightUnderlay: actionLinksUnderlayColor } = darkColor ? colors.dark : colors.light; const loadingIndicatorCanUseRed = hueDistance('red', threadColor) > 50; let editButtonContent = null; if (editing && this.state.text.trim() === '') { // nothing } else if (editing) { editButtonContent = ( SAVE ); } else { editButtonContent = ( EDIT ); } actionLinks = ( ); } const textColor = darkColor ? 'white' : 'black'; let textInput; if (editing) { const textInputStyle = { color: textColor, backgroundColor: threadColor, }; const selectionColor = darkColor ? '#129AFF' : '#036AFF'; textInput = ( ); } let rawText = this.state.text; if (rawText === '' || rawText.slice(-1) === '\n') { rawText += ' '; } const textStyle = { ...this.props.styles.text, color: textColor, opacity: textInput ? 0 : 1, }; // We use an empty View to set the height of the entry, and then position // the Text and TextInput absolutely. This allows to measure height changes // to the Text while controlling the actual height of the entry. const heightStyle = { height: this.state.height }; const entryStyle = { backgroundColor: threadColor }; const opacity = editing ? 1.0 : 0.6; return ( ); } textInputRef: (textInput: ?React.ElementRef) => void = textInput => { this.textInput = textInput; if (textInput && this.state.editing) { void this.enterEditMode(); } }; enterEditMode: () => Promise = async () => { this.setActive(); this.props.onEnterEditMode(this.props.entryInfo); if (Platform.OS === 'android') { // If we don't do this, the TextInput focuses // but the soft keyboard doesn't come up await waitForInteractions(); await sleep(15); } this.focus(); }; focus: () => void = () => { const { textInput } = this; if (!textInput) { return; } textInput.focus(); }; onFocus: () => void = () => { if (this.props.threadPickerActive) { this.props.navigation.goBack(); } }; setActive: () => void = () => this.makeActive(true); completeEdit: () => void = () => { // This gets called from CalendarInputBar (save button above keyboard), // onPressEdit (save button in Entry action links), and in // componentDidUpdate above when Calendar sets this Entry to inactive. // Calendar does this if something else gets pressed or the Entry is // scrolled out of view. Note that an Entry won't consider itself inactive // until it's done updating the server with its state, and if the network // requests fail it may stay "active". if (this.textInput) { this.textInput.blur(); } this.onBlur(); }; onBlur: () => void = () => { if (this.state.text.trim() === '') { this.delete(); } else if (this.props.entryInfo.text !== this.state.text) { this.save(); } this.guardedSetState({ editing: false }); this.makeActive(false); this.props.onConcludeEditMode(this.props.entryInfo); }; save: () => void = () => { this.dispatchSave(this.props.entryInfo.id, this.state.text); }; onTextContainerLayout: (event: LayoutEvent) => void = event => { this.guardedSetState({ height: Math.ceil(event.nativeEvent.layout.height), }); }; onChangeText: (newText: string) => void = newText => { this.guardedSetState({ text: newText }); }; makeActive(active: boolean) { if (!this.props.canEditEntry) { return; } this.props.makeActive(entryKey(this.props.entryInfo), active); } dispatchSave(serverID: ?string, newText: string) { if (this.currentlySaving === newText) { return; } this.currentlySaving = newText; if (newText.trim() === '') { // We don't save the empty string, since as soon as the element becomes // inactive it'll get deleted return; } if (!serverID) { if (this.creating) { // We need the first save call to return so we know the ID of the entry // we're updating, so we'll need to handle this save later this.needsUpdateAfterCreation = true; return; } else { this.creating = true; } } this.guardedSetState({ loadingStatus: 'loading' }); if (!serverID) { this.creationPromise = this.props.dispatchActionPromise( createEntryActionTypes, this.createAction(newText), ); } else { this.savePromise = this.props.dispatchActionPromise( saveEntryActionTypes, this.saveAction(serverID, newText), ); } } async createAction(text: string): Promise { const localID = this.props.entryInfo.localID; invariant(localID, "if there's no serverID, there should be a localID"); const curSaveAttempt = this.nextSaveAttemptIndex++; try { const createEntryInfo = { text, timestamp: this.props.entryInfo.creationTime, date: dateString( this.props.entryInfo.year, this.props.entryInfo.month, this.props.entryInfo.day, ), threadID: this.props.entryInfo.threadID, localID, calendarQuery: this.props.calendarQuery(), }; const useCreateEntryInput = threadTypeIsThick(this.props.threadInfo.type) ? { thick: true, threadInfo: this.props.threadInfo, createEntryInfo, } : { thick: false, createEntryInfo, }; const response = await this.props.createEntry(useCreateEntryInput); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } this.creating = false; this.currentlySaving = null; void (async () => { await this.creationPromise; if (this.needsUpdateAfterCreation) { this.needsUpdateAfterCreation = false; this.dispatchSave(response.entryID, this.state.text); } if (this.needsDeleteAfterSave) { this.needsDeleteAfterSave = false; this.dispatchDelete(response.entryID, this.state.text); } })(); return response; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; this.creating = false; throw e; } } async saveAction( entryID: string, newText: string, ): Promise { const curSaveAttempt = this.nextSaveAttemptIndex++; try { const saveEntryInfo = { entryID, text: newText, prevText: this.props.entryInfo.text, timestamp: Date.now(), calendarQuery: this.props.calendarQuery(), }; const useSaveEntryInput = threadTypeIsThick(this.props.threadInfo.type) ? { thick: true, threadInfo: this.props.threadInfo, saveEntryInfo, } : { thick: false, saveEntryInfo, }; const response = await this.props.saveEntry(useSaveEntryInput); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } this.currentlySaving = null; void (async () => { await this.savePromise; if (this.needsDeleteAfterSave) { this.needsDeleteAfterSave = false; this.dispatchDelete(response.entryID, this.state.text); } })(); return { ...response, threadID: this.props.entryInfo.threadID }; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; if (e instanceof ServerError && e.message === 'concurrent_modification') { const revertedText = e.payload?.db; const onRefresh = () => { this.guardedSetState({ loadingStatus: 'inactive', text: revertedText, }); this.props.dispatch({ type: concurrentModificationResetActionType, payload: { id: entryID, dbText: revertedText }, }); }; Alert.alert( 'Concurrent modification', 'It looks like somebody is attempting to modify that field at the ' + 'same time as you! Please try again.', [{ text: 'OK', onPress: onRefresh }], { cancelable: false }, ); } throw e; } } delete: () => void = () => { this.dispatchDelete(this.props.entryInfo.id, this.props.entryInfo.text); }; onPressEdit: () => void = () => { if (this.state.editing) { this.completeEdit(); } else { this.guardedSetState({ editing: true }); } }; dispatchDelete(serverID: ?string, prevText: string) { if (this.deleted) { return; } this.deleted = true; LayoutAnimation.easeInEaseOut(); const { localID } = this.props.entryInfo; void this.props.dispatchActionPromise( deleteEntryActionTypes, this.deleteAction(serverID, prevText), undefined, { localID, serverID }, ); } async deleteAction( - serverID: ?string, + entryID: ?string, prevText: string, ): Promise { - if (serverID) { - return await this.props.deleteEntry({ - entryID: serverID, + if (entryID) { + const deleteEntryInfo = { + entryID, prevText, calendarQuery: this.props.calendarQuery(), - }); + }; + + const useDeleteEntryInput = threadTypeIsThick(this.props.threadInfo.type) + ? { + thick: true, + threadInfo: this.props.threadInfo, + deleteEntryInfo, + } + : { + thick: false, + deleteEntryInfo, + }; + + return await this.props.deleteEntry(useDeleteEntryInput); } else if (this.creating || this.currentlySaving) { this.needsDeleteAfterSave = true; } return null; } onPressThreadName: () => void = () => { Keyboard.dismiss(); this.props.navigateToThread({ threadInfo: this.props.threadInfo }); }; } registerFetchKey(saveEntryActionTypes); registerFetchKey(deleteEntryActionTypes); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const Entry: React.ComponentType = React.memo( function ConnectedEntry(props: BaseProps) { const navContext = React.useContext(NavContext); const threadPickerActive = activeThreadPickerSelector(navContext); const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callCreateEntry = useCreateEntry(); const callSaveEntry = useSaveEntry(); const callDeleteEntry = useDeleteEntry(); const { threadInfo: unresolvedThreadInfo, ...restProps } = props; const threadInfo = useResolvedThreadInfo(unresolvedThreadInfo); const keyserverID = extractKeyserverIDFromIDOptional(threadInfo.id); const connection = useSelector(state => { if (!keyserverID) { return { status: 'connected', }; } return connectionSelector(keyserverID)(state); }); invariant( connection, `keyserver ${keyserverID ?? 'null'} missing from keyserverStore`, ); const online = connection.status === 'connected'; const canEditEntry = useThreadHasPermission( threadInfo, threadPermissions.EDIT_ENTRIES, ); return ( ); }, ); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/web/calendar/entry.react.js b/web/calendar/entry.react.js index 5c327411c..0df341f68 100644 --- a/web/calendar/entry.react.js +++ b/web/calendar/entry.react.js @@ -1,538 +1,551 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { createEntryActionTypes, useCreateEntry, saveEntryActionTypes, useSaveEntry, deleteEntryActionTypes, useDeleteEntry, concurrentModificationResetActionType, type UseCreateEntryInput, type UseSaveEntryInput, + type UseDeleteEntryInput, } from 'lib/actions/entry-actions.js'; import { type PushModal, useModalContext, } from 'lib/components/modal-provider.react.js'; import { extractKeyserverIDFromIDOptional } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import { useThreadHasPermission } from 'lib/shared/thread-utils.js'; import { type EntryInfo, type SaveEntryResult, type SaveEntryPayload, type CreateEntryPayload, - type DeleteEntryInfo, type DeleteEntryResult, type CalendarQuery, } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ResolvedThreadInfo } 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 { threadTypeIsThick } from 'lib/types/thread-types-enum.js'; import { dateString } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { ServerError } from 'lib/utils/errors.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import css from './calendar.css'; import LoadingIndicator from '../loading-indicator.react.js'; import LogInFirstModal from '../modals/account/log-in-first-modal.react.js'; import ConcurrentModificationModal from '../modals/concurrent-modification-modal.react.js'; import HistoryModal from '../modals/history/history-modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; import { HistoryVector, DeleteVector } from '../vectors.react.js'; type BaseProps = { +innerRef: (key: string, me: Entry) => void, +entryInfo: EntryInfo, +focusOnFirstEntryNewerThan: (time: number) => void, +tabIndex: number, }; type Props = { ...BaseProps, +threadInfo: ResolvedThreadInfo, +loggedIn: boolean, +currentUserCanEditEntry: boolean, +calendarQuery: () => CalendarQuery, +online: boolean, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +createEntry: (input: UseCreateEntryInput) => Promise, +saveEntry: (input: UseSaveEntryInput) => Promise, - +deleteEntry: (info: DeleteEntryInfo) => Promise, + +deleteEntry: (input: UseDeleteEntryInput) => Promise, +pushModal: PushModal, +popModal: () => void, }; type State = { +focused: boolean, +loadingStatus: LoadingStatus, +text: string, }; class Entry extends React.PureComponent { textarea: ?HTMLTextAreaElement; creating: boolean; needsUpdateAfterCreation: boolean; needsDeleteAfterCreation: boolean; nextSaveAttemptIndex: number; mounted: boolean; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { focused: false, loadingStatus: 'inactive', text: props.entryInfo.text, }; this.creating = false; this.needsUpdateAfterCreation = false; this.needsDeleteAfterCreation = false; this.nextSaveAttemptIndex = 0; } guardedSetState(input: Partial) { if (this.mounted) { this.setState(input); } } componentDidMount() { this.mounted = true; this.props.innerRef(entryKey(this.props.entryInfo), this); this.updateHeight(); // Whenever a new Entry is created, focus on it if (!this.props.entryInfo.id) { this.focus(); } } componentDidUpdate(prevProps: Props) { if ( !this.state.focused && this.props.entryInfo.text !== this.state.text && this.props.entryInfo.text !== prevProps.entryInfo.text ) { this.setState({ text: this.props.entryInfo.text }); this.currentlySaving = null; } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } } focus() { invariant( this.textarea instanceof HTMLTextAreaElement, 'textarea ref not set', ); this.textarea.focus(); } onMouseDown: (event: SyntheticEvent) => void = event => { if (this.state.focused && event.target !== this.textarea) { // Don't lose focus when some non-textarea part is clicked event.preventDefault(); } }; componentWillUnmount() { this.mounted = false; } updateHeight: () => void = () => { invariant( this.textarea instanceof HTMLTextAreaElement, 'textarea ref not set', ); this.textarea.style.height = 'auto'; this.textarea.style.height = this.textarea.scrollHeight + 'px'; }; render(): React.Node { let actionLinks = null; if (this.state.focused) { let historyButton = null; if (this.props.entryInfo.id) { historyButton = ( History ); } const rightActionLinksClassName = `${css.rightActionLinks} ${css.actionLinksText}`; actionLinks = (
Delete {historyButton} {this.props.threadInfo.uiName}
); } const darkColor = colorIsDark(this.props.threadInfo.color); const entryClasses = classNames({ [css.entry]: true, [css.darkEntry]: darkColor, [css.focusedEntry]: this.state.focused, }); const style = { backgroundColor: `#${this.props.threadInfo.color}` }; const loadingIndicatorColor = darkColor ? 'white' : 'black'; return (