diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js --- a/lib/reducers/message-reducer.js +++ b/lib/reducers/message-reducer.js @@ -1942,12 +1942,17 @@ ]), }; } else if (action.type === processFarcasterOpsActionType) { - const { rawMessageInfos, updateInfos, additionalMessageInfos } = - action.payload; + const { + rawMessageInfos, + updateInfos, + additionalMessageInfos, + truncationStatuses, + } = action.payload; const messagesResult = mergeUpdatesWithMessageInfos( rawMessageInfos, updateInfos, + truncationStatuses, ); const { messageStoreOperations, messageStore: newMessageStore } = diff --git a/lib/shared/farcaster/farcaster-actions.js b/lib/shared/farcaster/farcaster-actions.js --- a/lib/shared/farcaster/farcaster-actions.js +++ b/lib/shared/farcaster/farcaster-actions.js @@ -1,6 +1,9 @@ // @flow -import type { RawMessageInfo } from '../../types/message-types.js'; +import type { + MessageTruncationStatuses, + RawMessageInfo, +} from '../../types/message-types.js'; import type { ClientUpdateInfo } from '../../types/update-types.js'; export const processFarcasterOpsActionType = 'PROCESS_FARCASTER_OPS'; @@ -10,4 +13,5 @@ +additionalMessageInfos?: $ReadOnlyArray, +updateInfos: $ReadOnlyArray, +userIDs?: $ReadOnlyArray, + +truncationStatuses?: MessageTruncationStatuses, }; diff --git a/lib/shared/farcaster/farcaster-hooks.js b/lib/shared/farcaster/farcaster-hooks.js --- a/lib/shared/farcaster/farcaster-hooks.js +++ b/lib/shared/farcaster/farcaster-hooks.js @@ -14,6 +14,7 @@ useFetchFarcasterMessages, } from './farcaster-api.js'; import type { FarcasterConversation } from './farcaster-conversation-types.js'; +import { useFarcasterMessageFetching } from './farcaster-message-fetching-context.js'; import { type FarcasterMessage, farcasterMessageValidator, @@ -33,6 +34,7 @@ FarcasterRawThreadInfo, } from '../../types/minimally-encoded-thread-permissions-types'; import type { Dispatch } from '../../types/redux-types.js'; +import type { ThreadType } from '../../types/thread-types-enum.js'; import { updateTypes } from '../../types/update-types-enum.js'; import type { ClientUpdateInfo } from '../../types/update-types.js'; import { extractFarcasterIDsFromPayload } from '../../utils/conversion-utils.js'; @@ -51,6 +53,7 @@ farcasterThreadIDFromConversationID, userIDFromFID, } from '../id-utils.js'; +import { threadSpecs } from '../threads/thread-specs.js'; const FARCASTER_DATA_BATCH_SIZE = 20; const MAX_RETRIES = 3; @@ -375,9 +378,11 @@ function useFetchMessagesForConversation(): ( conversationID: string, messagesNumberLimit?: number, + cursor?: ?string, ) => Promise<{ +messages: Array, +userIDs: Array, + +newCursor?: ?string, }> { const fetchFarcasterMessages = useFetchFarcasterMessages(); const fetchUsersByFIDs = useGetCommFCUsersForFIDs(); @@ -386,14 +391,15 @@ async ( conversationID: string, messagesNumberLimit: number = 20, + cursor?: ?string, ): Promise<{ +messages: Array, +userIDs: Array, + +newCursor?: ?string, }> => { const result: Array = []; const userIDs: Array = []; try { - let cursor: ?string = null; let totalMessagesFetched = 0; do { @@ -454,6 +460,7 @@ return { messages: result, userIDs: Array.from(new Set(userIDs)), + newCursor: cursor, }; }, [fetchFarcasterMessages, fetchUsersByFIDs], @@ -798,6 +805,42 @@ return { inProgress, progress }; } +function useFarcasterThreadRefresher( + activeChatThreadID: ?string, + threadType: ?ThreadType, + appFocused: boolean, +): void { + const prevActiveThreadID = React.useRef(null); + const prevAppFocused = React.useRef(appFocused); + const farcasterRefreshConversation = useRefreshFarcasterConversation(); + const farcasterMessageFetching = useFarcasterMessageFetching(); + + React.useEffect(() => { + if ( + threadType && + activeChatThreadID && + (prevActiveThreadID.current !== activeChatThreadID || + (appFocused && !prevAppFocused.current)) + ) { + threadSpecs[threadType].protocol().onOpenThread?.( + { threadID: activeChatThreadID }, + { + farcasterRefreshConversation, + farcasterMessageFetching, + }, + ); + } + prevActiveThreadID.current = activeChatThreadID; + prevAppFocused.current = appFocused; + }, [ + activeChatThreadID, + appFocused, + farcasterMessageFetching, + farcasterRefreshConversation, + threadType, + ]); +} + export { useFarcasterConversationsSync, useFetchConversationWithBatching, @@ -807,4 +850,5 @@ useRefreshFarcasterConversation, useAddNewFarcasterMessage, useFarcasterSync, + useFarcasterThreadRefresher, }; diff --git a/lib/shared/farcaster/farcaster-message-fetching-context.js b/lib/shared/farcaster/farcaster-message-fetching-context.js new file mode 100644 --- /dev/null +++ b/lib/shared/farcaster/farcaster-message-fetching-context.js @@ -0,0 +1,217 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; +import { useDispatch } from 'react-redux'; + +import { processFarcasterOpsActionType } from './farcaster-actions.js'; +import { useFetchMessagesForConversation } from './farcaster-hooks.js'; +import { + type MessageTruncationStatus, + messageTruncationStatus, + type RawMessageInfo, +} from '../../types/message-types.js'; +import type { ClientUpdateInfo } from '../../types/update-types.js'; +import { conversationIDFromFarcasterThreadID } from '../id-utils.js'; +import { fetchMessagesFromDB } from '../message-utils.js'; + +type ThreadFetchingState = { + +farcasterCursor: ?string, + +dbExhausted: boolean, + +farcasterExhausted: boolean, +}; + +export type FarcasterMessageFetchingContextType = { + +fetchMoreMessages: ( + threadID: string, + numMessages: number, + currentOffset: number, + ) => Promise, +}; + +const FarcasterMessageFetchingContext: React.Context = + React.createContext(null); + +type ProcessResultsInput = { + +dbResult: ?{ + +threadID: string, + +rawMessageInfos: $ReadOnlyArray, + +truncationStatus: MessageTruncationStatus, + }, + +farcasterResult: ?{ + +messages: $ReadOnlyArray, + +newCursor: ?string, + +userIDs: $ReadOnlyArray, + }, +}; + +type ProcessResultsOutput = { + +combinedMessages: $ReadOnlyArray, + +newCursor: ?string, + +bothExhausted: boolean, + +userIDs: $ReadOnlyArray, +}; + +function processResults(input: ProcessResultsInput): ProcessResultsOutput { + const { dbResult, farcasterResult } = input; + + const dbMessages = dbResult?.rawMessageInfos ?? []; + const farcasterMessages = farcasterResult?.messages ?? []; + + // Deduplicate messages, keeping Farcaster messages over DB messages + const messageMap = new Map(); + + // First add DB messages + for (const message of dbMessages) { + if (message.id) { + messageMap.set(message.id, message); + } + } + + for (const message of farcasterMessages) { + if (message.id) { + messageMap.set(message.id, message); + } + } + + const combinedMessages = Array.from(messageMap.values()); + + const newCursor = farcasterResult?.newCursor ?? null; + const dbExhausted = + dbResult?.truncationStatus === messageTruncationStatus.EXHAUSTIVE; + const farcasterExhausted = !newCursor; + + return { + combinedMessages, + newCursor, + bothExhausted: dbExhausted && farcasterExhausted, + userIDs: farcasterResult?.userIDs ?? [], + }; +} + +type Props = { + +children: React.Node, +}; + +function FarcasterMessageFetchingProvider(props: Props): React.Node { + const { children } = props; + + const [threadStates, setThreadStates] = React.useState<{ + [threadID: string]: ThreadFetchingState, + }>({}); + + const getState = React.useCallback( + (threadID: string): ThreadFetchingState => { + return ( + threadStates[threadID] ?? { + farcasterCursor: null, + dbExhausted: false, + farcasterExhausted: false, + } + ); + }, + [threadStates], + ); + + const updateState = React.useCallback( + (threadID: string, updates: Partial) => { + setThreadStates(prevStates => ({ + ...prevStates, + [threadID]: { + ...getState(threadID), + ...updates, + }, + })); + }, + [getState], + ); + + const fetchFarcasterMessagesForConversation = + useFetchMessagesForConversation(); + const dispatch = useDispatch(); + const fetchMoreMessages = React.useCallback( + async ( + threadID: string, + numMessages: number, + currentOffset: number, + ): Promise => { + const state = + currentOffset === 0 + ? { + farcasterCursor: null, + dbExhausted: false, + farcasterExhausted: false, + } + : getState(threadID); + const conversationID = conversationIDFromFarcasterThreadID(threadID); + + const dbPromise = !state.dbExhausted + ? fetchMessagesFromDB(threadID, numMessages, currentOffset) + : Promise.resolve(null); + + const farcasterPromise = !state.farcasterExhausted + ? fetchFarcasterMessagesForConversation( + conversationID, + numMessages, + state.farcasterCursor ?? null, + ) + : Promise.resolve(null); + + const [dbResult, farcasterResult] = await Promise.allSettled([ + dbPromise, + farcasterPromise, + ]); + + const processedResults = processResults({ + dbResult: dbResult.status === 'fulfilled' ? dbResult.value : null, + farcasterResult: + farcasterResult.status === 'fulfilled' ? farcasterResult.value : null, + }); + + updateState(threadID, { + farcasterCursor: processedResults.newCursor, + dbExhausted: + dbResult.status === 'fulfilled' && + dbResult.value?.truncationStatus === + messageTruncationStatus.EXHAUSTIVE, + farcasterExhausted: !processedResults.newCursor, + }); + + dispatch({ + type: processFarcasterOpsActionType, + payload: { + rawMessageInfos: processedResults.combinedMessages, + updateInfos: ([]: Array), + userIDs: processedResults.userIDs, + truncationStatuses: { + [threadID]: processedResults.bothExhausted + ? messageTruncationStatus.EXHAUSTIVE + : messageTruncationStatus.UNCHANGED, + }, + }, + }); + }, + [getState, fetchFarcasterMessagesForConversation, updateState, dispatch], + ); + + const contextValue = React.useMemo( + () => ({ + fetchMoreMessages, + }), + [fetchMoreMessages], + ); + + return ( + + {children} + + ); +} + +function useFarcasterMessageFetching(): FarcasterMessageFetchingContextType { + const context = React.useContext(FarcasterMessageFetchingContext); + invariant(context, 'FarcasterMessageFetchingContext must be set'); + return context; +} + +export { FarcasterMessageFetchingProvider, useFarcasterMessageFetching }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -4,6 +4,7 @@ import _maxBy from 'lodash/fp/maxBy.js'; import * as React from 'react'; +import { useFarcasterMessageFetching } from './farcaster/farcaster-message-fetching-context.js'; import { codeBlockRegex, type ParserRules } from './markdown.js'; import type { CreationSideEffectsFunc } from './messages/message-spec.js'; import { messageSpecs } from './messages/message-specs.js'; @@ -637,6 +638,7 @@ const callFetchMessagesBeforeCursor = useFetchMessagesBeforeCursor(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); const dispatchActionPromise = useDispatchActionPromise(); + const farcasterMessageFetching = useFarcasterMessageFetching(); React.useEffect(() => { registerFetchKey(fetchMessagesBeforeCursorActionTypes); @@ -664,6 +666,7 @@ keyserverFetchMessagesBeforeCursor: callFetchMessagesBeforeCursor, keyserverFetchMostRecentMessages: callFetchMostRecentMessages, dispatchActionPromise, + farcasterMessageFetching, }, ); }, @@ -671,6 +674,7 @@ callFetchMessagesBeforeCursor, callFetchMostRecentMessages, dispatchActionPromise, + farcasterMessageFetching, messageIDs?.length, oldestMessageServerID, threadID, @@ -705,5 +709,6 @@ isInvalidPinSourceForThread, isUnableToBeRenderedIndependently, findNewestMessageTimePerKeyserver, + fetchMessagesFromDB, useFetchMessages, }; diff --git a/lib/shared/threads/protocols/farcaster-thread-protocol.js b/lib/shared/threads/protocols/farcaster-thread-protocol.js --- a/lib/shared/threads/protocols/farcaster-thread-protocol.js +++ b/lib/shared/threads/protocols/farcaster-thread-protocol.js @@ -2,7 +2,6 @@ import invariant from 'invariant'; -import { fetchMessagesBeforeCursorActionTypes } from '../../../actions/message-actions.js'; import { changeThreadMemberRolesActionTypes, changeThreadSettingsActionTypes, @@ -672,18 +671,11 @@ ) => { const { threadID, numMessagesToFetch, currentNumberOfFetchedMessages } = input; - const promise = (async () => { - return await utils.fetchMessagesFromDB( - threadID, - numMessagesToFetch ?? defaultNumberPerThread, - currentNumberOfFetchedMessages, - ); - })(); - void utils.dispatchActionPromise( - fetchMessagesBeforeCursorActionTypes, - promise, + await utils.farcasterMessageFetching.fetchMoreMessages( + threadID, + numMessagesToFetch ?? defaultNumberPerThread, + currentNumberOfFetchedMessages, ); - await promise; }, createPendingThread: ( @@ -820,8 +812,11 @@ input: ProtocolOnOpenThreadInput, utils: OnOpenThreadUtils, ) => { - const conversationID = conversationIDFromFarcasterThreadID(input.threadID); - void utils.farcasterRefreshConversation(conversationID); + void utils.farcasterMessageFetching.fetchMoreMessages( + input.threadID, + defaultNumberPerThread, + 0, + ); }, threadIDMatchesProtocol: (threadID: string): boolean => { diff --git a/lib/shared/threads/thread-spec.js b/lib/shared/threads/thread-spec.js --- a/lib/shared/threads/thread-spec.js +++ b/lib/shared/threads/thread-spec.js @@ -101,6 +101,7 @@ SendReactionInput, } from '../farcaster/farcaster-api.js'; import type { FarcasterConversation } from '../farcaster/farcaster-conversation-types.js'; +import type { FarcasterMessageFetchingContextType } from '../farcaster/farcaster-message-fetching-context.js'; import type { FetchMessagesFromDBType } from '../message-utils.js'; import type { CreationSideEffectsFunc, @@ -314,6 +315,7 @@ +keyserverFetchMessagesBeforeCursor: FetchMessagesBeforeCursorInput => Promise, +keyserverFetchMostRecentMessages: FetchMostRecentMessagesInput => Promise, +dispatchActionPromise: DispatchActionPromise, + +farcasterMessageFetching: FarcasterMessageFetchingContextType, }; export type ProtocolCreatePendingThreadInput = { @@ -367,6 +369,7 @@ }; export type OnOpenThreadUtils = { +farcasterRefreshConversation: (conversationID: string) => Promise, + +farcasterMessageFetching: FarcasterMessageFetchingContextType, }; export type ProtocolName = 'Comm DM' | 'Farcaster DC' | 'Keyserver'; diff --git a/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -11,16 +11,14 @@ import genesis from 'lib/facts/genesis.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; -import { useRefreshFarcasterConversation } from 'lib/shared/farcaster/farcaster-hooks.js'; +import { useFarcasterThreadRefresher } from 'lib/shared/farcaster/farcaster-hooks.js'; +import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils.js'; import { usePotentialMemberItems, useSearchUsers, } from 'lib/shared/search-utils.js'; import { useExistingThreadInfoFinder } from 'lib/shared/thread-utils.js'; -import { - threadTypeIsPersonal, - threadSpecs, -} from 'lib/shared/threads/thread-specs.js'; +import { threadTypeIsPersonal } from 'lib/shared/threads/thread-specs.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import { pinnedMessageCountText } from 'lib/utils/message-pinning-utils.js'; @@ -434,19 +432,12 @@ colors.panelBackgroundLabel, ]); - const prevActiveThreadID = React.useRef(threadInfo.id); - const farcasterRefreshConversation = useRefreshFarcasterConversation(); - React.useEffect(() => { - if (prevActiveThreadID !== threadInfo.id) { - threadSpecs[threadInfo.type] - .protocol() - .onOpenThread?.( - { threadID: threadInfo.id }, - { farcasterRefreshConversation }, - ); - prevActiveThreadID.current = threadInfo.id; - } - }, [farcasterRefreshConversation, threadInfo.id, threadInfo.type]); + const isAppForegrounded = useIsAppForegrounded(); + useFarcasterThreadRefresher( + threadInfo.id, + threadInfo.type, + isAppForegrounded, + ); let relationshipPrompt = null; if (threadTypeIsPersonal(threadInfo.type)) { diff --git a/native/root.react.js b/native/root.react.js --- a/native/root.react.js +++ b/native/root.react.js @@ -52,6 +52,7 @@ import { IdentitySearchProvider } from 'lib/identity-search/identity-search-context.js'; import { CallKeyserverEndpointProvider } from 'lib/keyserver-conn/call-keyserver-endpoint-provider.react.js'; import KeyserverConnectionsHandler from 'lib/keyserver-conn/keyserver-connections-handler.js'; +import { FarcasterMessageFetchingProvider } from 'lib/shared/farcaster/farcaster-message-fetching-context.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import { actionLogger } from 'lib/utils/action-logger.js'; import { useFullBackupSupportEnabled } from 'lib/utils/services-utils.js'; @@ -340,7 +341,9 @@ - + + + diff --git a/web/app.react.js b/web/app.react.js --- a/web/app.react.js +++ b/web/app.react.js @@ -43,6 +43,7 @@ combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; +import { FarcasterMessageFetchingProvider } from 'lib/shared/farcaster/farcaster-message-fetching-context.js'; import { extractMajorDesktopVersion } from 'lib/shared/version-utils.js'; import type { SecondaryTunnelbrokerConnection } from 'lib/tunnelbroker/secondary-tunnelbroker-connection.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; @@ -235,8 +236,10 @@ - {this.renderMainContent()} - {this.props.modals} + + {this.renderMainContent()} + {this.props.modals} + diff --git a/web/chat/chat-message-list-container.react.js b/web/chat/chat-message-list-container.react.js --- a/web/chat/chat-message-list-container.react.js +++ b/web/chat/chat-message-list-container.react.js @@ -7,9 +7,8 @@ import { NativeTypes } from 'react-dnd-html5-backend'; import { useProtocolSelection } from 'lib/contexts/protocol-selection-context.js'; -import { useRefreshFarcasterConversation } from 'lib/shared/farcaster/farcaster-hooks.js'; +import { useFarcasterThreadRefresher } from 'lib/shared/farcaster/farcaster-hooks.js'; import { threadIsPending } from 'lib/shared/thread-utils.js'; -import { threadSpecs } from 'lib/shared/threads/thread-specs.js'; import { useWatchThread } from 'lib/shared/watch-thread-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; @@ -20,6 +19,7 @@ import ThreadTopBar from './thread-top-bar.react.js'; import { InputStateContext } from '../input/input-state.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; +import { useSelector } from '../redux/redux-utils.js'; import { useThreadInfoForPossiblyPendingThread, useInfosForPendingThread, @@ -150,19 +150,12 @@ ); }, [inputState, isChatCreation, selectedUserInfos, threadInfo]); - const prevActiveThreadID = React.useRef(activeChatThreadID); - const farcasterRefreshConversation = useRefreshFarcasterConversation(); - React.useEffect(() => { - if (prevActiveThreadID !== activeChatThreadID && activeChatThreadID) { - threadSpecs[threadInfo.type] - .protocol() - .onOpenThread?.( - { threadID: activeChatThreadID }, - { farcasterRefreshConversation }, - ); - prevActiveThreadID.current = activeChatThreadID; - } - }, [farcasterRefreshConversation, activeChatThreadID, threadInfo.type]); + const windowActive = useSelector(state => state.windowActive); + useFarcasterThreadRefresher( + activeChatThreadID, + threadInfo.type, + windowActive, + ); return connectDropTarget(