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 @@ -1,5 +1,6 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; @@ -13,7 +14,10 @@ useFetchFarcasterInbox, useFetchFarcasterMessages, } from './farcaster-api.js'; -import type { FarcasterConversation } from './farcaster-conversation-types.js'; +import type { + FarcasterConversation, + FarcasterInboxConversation, +} from './farcaster-conversation-types.js'; import { useFarcasterMessageFetching } from './farcaster-message-fetching-context.js'; import { type FarcasterMessage, @@ -40,7 +44,10 @@ import type { ClientUpdateInfo } from '../../types/update-types.js'; import { extractFarcasterIDsFromPayload } from '../../utils/conversion-utils.js'; import { convertFarcasterMessageToCommMessages } from '../../utils/convert-farcaster-message-to-comm-messages.js'; -import { createFarcasterRawThreadInfo } from '../../utils/create-farcaster-raw-thread-info.js'; +import { + createFarcasterRawThreadInfo, + createUpdatedThread, +} from '../../utils/create-farcaster-raw-thread-info.js'; import { getMessageForException } from '../../utils/errors.js'; import { useCurrentUserSupportsDCs, @@ -531,23 +538,17 @@ ); } -function useFarcasterConversationsSync(): ( - limit: number, - onProgress?: (completed: number, total: number) => void, -) => Promise { +function useFetchInboxes(): ( + category?: 'archived' | 'request', +) => Promise<$ReadOnlyArray> { const fetchFarcasterInbox = useFetchFarcasterInbox(); - const dispatch = useDispatch(); - const fetchConversationWithMessages = useFetchConversationWithMessages(); - const setFarcasterDCsLoaded = useSetFarcasterDCsLoaded(); const { addLog } = useDebugLogs(); - const threadInfos = useSelector(state => state.threadStore.threadInfos); - - const fetchInboxes = React.useCallback( + return React.useCallback( async ( category?: 'archived' | 'request', - ): Promise<$ReadOnlyArray> => { - const allConversations: Array = []; + ): Promise<$ReadOnlyArray> => { + const allConversations: Array = []; let currentCursor = null; while (true) { @@ -565,10 +566,7 @@ RETRY_DELAY_MS, ); - const ids = result.conversations.map( - conversation => conversation.conversationId, - ); - allConversations.push(...ids); + allConversations.push(...result.conversations); if (next?.cursor) { currentCursor = next.cursor; @@ -593,8 +591,28 @@ }, [addLog, fetchFarcasterInbox], ); +} - const removeDeadThreads = React.useCallback( +function useFetchInboxIDs(): ( + category?: 'archived' | 'request', +) => Promise<$ReadOnlyArray> { + const fetchInboxes = useFetchInboxes(); + return React.useCallback( + async (category?: 'archived' | 'request') => { + const conversations = await fetchInboxes(category); + return conversations.map(conversation => conversation.conversationId); + }, + [fetchInboxes], + ); +} + +function useRemoveDeadThreads(): ( + conversations: $ReadOnlyArray, +) => void { + const dispatch = useDispatch(); + const threadInfos = useSelector(state => state.threadStore.threadInfos); + + return React.useCallback( (conversations: $ReadOnlyArray) => { const conversationsSet = new Set(conversations); const time = Date.now(); @@ -624,6 +642,19 @@ }, [dispatch, threadInfos], ); +} + +function useFarcasterConversationsSync(): ( + limit: number, + onProgress?: (completed: number, total: number) => void, +) => Promise { + const dispatch = useDispatch(); + const fetchConversationWithMessages = useFetchConversationWithMessages(); + const setFarcasterDCsLoaded = useSetFarcasterDCsLoaded(); + const { addLog } = useDebugLogs(); + const threadInfos = useSelector(state => state.threadStore.threadInfos); + const fetchInboxes = useFetchInboxIDs(); + const removeDeadThreads = useRemoveDeadThreads(); return React.useCallback( async ( @@ -670,7 +701,7 @@ setFarcasterDCsLoaded(true); } catch (e) { addLog( - 'Farcaster: Failed to sync conversations', + 'Farcaster: Failed to sync conversations (full sync)', JSON.stringify({ error: getMessageForException(e), }), @@ -691,6 +722,113 @@ ); } +function useLightweightFarcasterConversationsSync(): ( + onProgress?: (completed: number, total: number) => void, +) => Promise { + const dispatch = useDispatch(); + const fetchConversationWithMessages = useFetchConversationWithMessages(); + const { addLog } = useDebugLogs(); + const threadInfos = useSelector(state => state.threadStore.threadInfos); + const fetchInboxes = useFetchInboxes(); + const removeDeadThreads = useRemoveDeadThreads(); + const viewerID = useSelector( + state => state.currentUserInfo && state.currentUserInfo.id, + ); + + return React.useCallback( + async (onProgress?: (completed: number, total: number) => void) => { + try { + invariant(viewerID, 'Viewer ID should be set'); + const inboxResults = await Promise.all([fetchInboxes()]); + const conversations = inboxResults.flat(); + const conversationIDs = conversations.map( + conversation => conversation.conversationId, + ); + + removeDeadThreads(conversationIDs); + + const threadIDs = new Set(Object.keys(threadInfos)); + const newConversationIDs = conversationIDs.filter( + conversationID => + !threadIDs.has(farcasterThreadIDFromConversationID(conversationID)), + ); + const existingConversationIDs = conversationIDs.filter(conversationID => + threadIDs.has(farcasterThreadIDFromConversationID(conversationID)), + ); + + onProgress?.(0, conversations.length); + + const updates = conversations + .map(conversation => { + const threadID = farcasterThreadIDFromConversationID( + conversation.conversationId, + ); + const thread = threadInfos[threadID]; + if (thread && thread.farcaster) { + return createUpdatedThread(thread, conversation, viewerID); + } + return null; + }) + .map(result => + result && result.result === 'updated' ? result.threadInfo : null, + ) + .filter(Boolean) + .map(thread => ({ + type: updateTypes.UPDATE_THREAD, + time: thread.creationTime, + threadInfo: thread, + id: uuid.v4(), + })); + dispatch({ + type: processFarcasterOpsActionType, + payload: { + rawMessageInfos: [], + updateInfos: updates, + }, + }); + onProgress?.(existingConversationIDs.length, conversations.length); + + if (newConversationIDs.length > 0) { + await processInBatchesWithReduxBatching( + newConversationIDs, + FARCASTER_DATA_BATCH_SIZE, + (conversationID, batchedUpdates) => + fetchConversationWithMessages( + conversationID, + Number.POSITIVE_INFINITY, + batchedUpdates, + ), + dispatch, + completed => + onProgress?.( + existingConversationIDs.length + completed, + conversations.length, + ), + ); + } + } catch (e) { + addLog( + 'Farcaster: Failed to sync conversations (lightweight)', + JSON.stringify({ + error: getMessageForException(e), + }), + new Set([logTypes.FARCASTER]), + ); + throw e; + } + }, + [ + addLog, + dispatch, + fetchConversationWithMessages, + fetchInboxes, + removeDeadThreads, + threadInfos, + viewerID, + ], + ); +} + function useAddNewFarcasterMessage(): FarcasterMessage => Promise { const dispatch = useDispatch(); const fetchUsersByFIDs = useGetCommFCUsersForFIDs(); @@ -900,6 +1038,7 @@ export { useFarcasterConversationsSync, + useLightweightFarcasterConversationsSync, useFetchConversationWithBatching, useFetchConversationWithMessages, useFetchConversation, diff --git a/lib/utils/create-farcaster-raw-thread-info.js b/lib/utils/create-farcaster-raw-thread-info.js --- a/lib/utils/create-farcaster-raw-thread-info.js +++ b/lib/utils/create-farcaster-raw-thread-info.js @@ -1,5 +1,6 @@ // @flow +import { values } from './objects.js'; import { getFarcasterRolePermissionsBlobs, getFarcasterRolePermissionsBlobsFromConversation, @@ -10,7 +11,10 @@ makePermissionsBlob, } from '../permissions/thread-permissions.js'; import { generatePendingThreadColor } from '../shared/color-utils.js'; -import type { FarcasterConversation } from '../shared/farcaster/farcaster-conversation-types.js'; +import type { + FarcasterConversation, + FarcasterInboxConversation, +} from '../shared/farcaster/farcaster-conversation-types.js'; import { farcasterThreadIDFromConversationID } from '../shared/id-utils.js'; import { stringForUserExplicit } from '../shared/user-utils.js'; import type { ClientAvatar } from '../types/avatar-types.js'; @@ -25,7 +29,10 @@ minimallyEncodeThreadCurrentUserInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { ThreadRolePermissionsBlob } from '../types/thread-permission-types.js'; -import { farcasterThreadTypes } from '../types/thread-types-enum.js'; +import { + threadTypes, + farcasterThreadTypes, +} from '../types/thread-types-enum.js'; import type { FarcasterThreadType } from '../types/thread-types-enum.js'; function createPermissionsInfo( @@ -58,15 +65,29 @@ +avatar: ?ClientAvatar, }; -function innerCreateFarcasterRawThreadInfo( - threadData: FarcasterThreadData, -): FarcasterRawThreadInfo { - const threadID = threadData.threadID; - const threadType = threadData.isGroup - ? farcasterThreadTypes.FARCASTER_GROUP - : farcasterThreadTypes.FARCASTER_PERSONAL; - const permissionBlobs = threadData.permissionBlobs; - +function createMembersAndCurrentUser(options: { + +threadID: string, + +permissionBlobs: { + +Members: ThreadRolePermissionsBlob, + +Admins: ?ThreadRolePermissionsBlob, + }, + +memberIDs: $ReadOnlyArray, + +adminIDs: $ReadOnlySet, + +currentUserOptions: { + +isAdmin: boolean, + +unread: boolean, + +muted: boolean, + }, + +threadType: FarcasterThreadType, +}) { + const { + threadID, + permissionBlobs, + memberIDs, + adminIDs, + currentUserOptions, + threadType, + } = options; const membersRole: RoleInfo = { ...minimallyEncodeRoleInfo({ id: `${threadID}/member/role`, @@ -94,39 +115,58 @@ roles[adminsRole.id] = adminsRole; } - const members = threadData.memberIDs.map(fid => ({ + const members = memberIDs.map(fid => ({ id: fid, // This flag was introduced for sidebars to show who replied to a thread. // Now it doesn't seem to be used anywhere. Regardless, for Farcaster // threads its value doesn't matter. isSender: true, minimallyEncoded: true, - role: - threadData.adminIDs.has(fid) && adminsRole - ? adminsRole.id - : membersRole.id, + role: adminIDs.has(fid) && adminsRole ? adminsRole.id : membersRole.id, })); const currentUserRole = - threadData.viewerAccess === 'admin' && adminsRole - ? adminsRole - : membersRole; + currentUserOptions.isAdmin && adminsRole ? adminsRole : membersRole; const currentUser: ThreadCurrentUserInfo = minimallyEncodeThreadCurrentUserInfo({ role: currentUserRole.id, permissions: createPermissionsInfo( - threadData.viewerAccess === 'admin' && permissionBlobs.Admins + currentUserOptions.isAdmin && permissionBlobs.Admins ? permissionBlobs.Admins : permissionBlobs.Members, threadID, threadType, ), subscription: { - home: !threadData.muted, - pushNotifs: !threadData.muted, + home: !currentUserOptions.muted, + pushNotifs: !currentUserOptions.muted, }, - unread: threadData.unread, + unread: currentUserOptions.unread, }); + return { members, currentUser, roles }; +} + +function innerCreateFarcasterRawThreadInfo( + threadData: FarcasterThreadData, +): FarcasterRawThreadInfo { + const threadID = threadData.threadID; + const threadType = threadData.isGroup + ? farcasterThreadTypes.FARCASTER_GROUP + : farcasterThreadTypes.FARCASTER_PERSONAL; + const permissionBlobs = threadData.permissionBlobs; + + const { members, roles, currentUser } = createMembersAndCurrentUser({ + threadID, + permissionBlobs, + memberIDs: threadData.memberIDs, + adminIDs: threadData.adminIDs, + currentUserOptions: { + isAdmin: threadData.viewerAccess === 'admin', + unread: threadData.unread, + muted: threadData.muted, + }, + threadType, + }); return { farcaster: true, @@ -235,4 +275,121 @@ return innerCreateFarcasterRawThreadInfo(threadData); } -export { createFarcasterRawThreadInfo, createFarcasterRawThreadInfoPersonal }; +function createUpdatedThread( + threadInfo: FarcasterRawThreadInfo, + conversation: FarcasterInboxConversation, + viewerID: string, +): + | { +result: 'unchanged' } + | { +result: 'updated', +threadInfo: FarcasterRawThreadInfo } { + let updatedThreadInfo = threadInfo; + + if (conversation.name !== threadInfo.name) { + updatedThreadInfo = { ...updatedThreadInfo, name: conversation.name ?? '' }; + } + + if (conversation.description !== threadInfo.description) { + updatedThreadInfo = { + ...updatedThreadInfo, + description: conversation.description ?? '', + }; + } + + let avatarURI = null; + if (conversation.isGroup) { + avatarURI = conversation.photoUrl; + } else { + avatarURI = conversation.viewerContext.counterParty?.pfp?.url; + } + if (!avatarURI && threadInfo.avatar) { + updatedThreadInfo = { ...updatedThreadInfo, avatar: null }; + } else if (avatarURI && avatarURI !== threadInfo.avatar?.uri) { + updatedThreadInfo = { + ...updatedThreadInfo, + avatar: { type: 'image', uri: avatarURI }, + }; + } + + const conversationIsUnread = + conversation.viewerContext.unreadCount > 0 || + conversation.viewerContext.manuallyMarkedUnread; + + if (conversation.isGroup) { + const adminIDs = new Set(conversation.adminFids.map(fid => `${fid}`)); + const adminsRole = values(threadInfo.roles).find( + role => role.specialRole === specialRoles.ADMIN_ROLE, + ); + const threadAdminUserIDs = threadInfo.members + .filter(member => member.role === adminsRole?.id) + .map(member => member.id); + if ( + (threadAdminUserIDs.some(id => !adminIDs.has(id)) || + adminIDs.size !== threadAdminUserIDs.length) && + adminsRole + ) { + const permissionBlobs = getFarcasterRolePermissionsBlobs( + threadTypes.FARCASTER_GROUP, + false, + false, + ); + const { members, roles, currentUser } = createMembersAndCurrentUser({ + threadID: threadInfo.id, + permissionBlobs, + memberIDs: threadInfo.members.map(member => member.id), + adminIDs, + currentUserOptions: { + isAdmin: adminIDs.has(viewerID), + unread: conversationIsUnread, + muted: conversation.viewerContext.muted, + }, + threadType: threadTypes.FARCASTER_GROUP, + }); + updatedThreadInfo = { + ...updatedThreadInfo, + members, + roles, + currentUser, + }; + } + } + + if (updatedThreadInfo.currentUser.unread !== conversationIsUnread) { + updatedThreadInfo = { + ...updatedThreadInfo, + currentUser: { + ...updatedThreadInfo.currentUser, + unread: conversationIsUnread, + }, + }; + } + + const threadIsMuted = + !threadInfo.currentUser.subscription.home || + !threadInfo.currentUser.subscription.pushNotifs; + if (threadIsMuted !== conversation.viewerContext.muted) { + updatedThreadInfo = { + ...updatedThreadInfo, + currentUser: { + ...updatedThreadInfo.currentUser, + subscription: { + home: !conversation.viewerContext.muted, + pushNotifs: !conversation.viewerContext.muted, + }, + }, + }; + } + + if (threadInfo === updatedThreadInfo) { + return { result: 'unchanged' }; + } + return { + result: 'updated', + threadInfo: updatedThreadInfo, + }; +} + +export { + createFarcasterRawThreadInfo, + createFarcasterRawThreadInfoPersonal, + createUpdatedThread, +};