diff --git a/lib/hooks/input-state-container-hooks.js b/lib/hooks/input-state-container-hooks.js --- a/lib/hooks/input-state-container-hooks.js +++ b/lib/hooks/input-state-container-hooks.js @@ -1,5 +1,6 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { @@ -11,6 +12,7 @@ import { useProcessBlobHolders } from '../actions/holder-actions.js'; import { useSendComposableDMOperation } from '../shared/dm-ops/process-dm-ops.js'; import { useSendFarcasterTextMessage } from '../shared/farcaster/farcaster-api.js'; +import { useFetchConversation } from '../shared/farcaster/farcaster-hooks.js'; import { useMessageCreationSideEffectsFunc } from '../shared/message-utils.js'; import { threadSpecs } from '../shared/threads/thread-specs.js'; import { messageTypes } from '../types/message-types-enum.js'; @@ -21,7 +23,6 @@ } from '../types/message-types.js'; import type { RawTextMessageInfo } from '../types/messages/text.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import { useCurrentUserFID } from '../utils/farcaster-utils.js'; import { useSelector, useDispatch } from '../utils/redux-utils.js'; function useInputStateContainerSendTextMessage(): ( @@ -35,7 +36,10 @@ const sendFarcasterTextMessage = useSendFarcasterTextMessage(); const sideEffectsFunction = useMessageCreationSideEffectsFunc(messageTypes.TEXT); - const currentUserFID = useCurrentUserFID(); + const auxUserStore = useSelector(state => state.auxUserStore); + const viewerID = useSelector(state => state.currentUserInfo?.id); + const farcasterFetchConversation = useFetchConversation(); + const threadInfos = useSelector(state => state.threadStore.threadInfos); return React.useCallback( ( @@ -43,8 +47,9 @@ threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, sidebarCreation: boolean, - ) => - threadSpecs[threadInfo.type].protocol().sendTextMessage( + ) => { + invariant(viewerID, 'Viewer ID should be present'); + return threadSpecs[threadInfo.type].protocol().sendTextMessage( { messageInfo, threadInfo, @@ -54,17 +59,24 @@ { sendKeyserverTextMessage, sendComposableDMOperation, - sideEffectsFunction, sendFarcasterTextMessage, - currentUserFID, + viewerID, + sideEffectsFunction, + auxUserStore, + farcasterFetchConversation, + threadInfos, }, - ), + ); + }, [ - sendComposableDMOperation, sendKeyserverTextMessage, - sideEffectsFunction, + sendComposableDMOperation, sendFarcasterTextMessage, - currentUserFID, + viewerID, + sideEffectsFunction, + auxUserStore, + farcasterFetchConversation, + threadInfos, ], ); } @@ -84,7 +96,10 @@ const dispatch = useDispatch(); const sendFarcasterTextMessage = useSendFarcasterTextMessage(); - const currentUserFID = useCurrentUserFID(); + + const auxUserStore = useSelector(state => state.auxUserStore); + const viewerID = useSelector(state => state.currentUserInfo?.id); + const farcasterFetchConversation = useFetchConversation(); return React.useCallback( async ( @@ -93,6 +108,7 @@ isLegacy: boolean, ) => { const threadInfo = threadInfos[messageInfo.threadID]; + invariant(viewerID, 'Viewer ID should be present'); return threadSpecs[threadInfo.type].protocol().sendMultimediaMessage( { messageInfo, @@ -107,21 +123,26 @@ reassignThickThreadMedia, processHolders, dispatch, - currentUserFID, + viewerID, sendFarcasterTextMessage, + auxUserStore, + farcasterFetchConversation, + threadInfos, }, ); }, [ - dispatch, + threadInfos, + sendMultimediaMessage, legacySendMultimediaMessage, - processHolders, - reassignThickThreadMedia, sendComposableDMOperation, - sendMultimediaMessage, - threadInfos, - currentUserFID, + reassignThickThreadMedia, + processHolders, + dispatch, + viewerID, sendFarcasterTextMessage, + auxUserStore, + farcasterFetchConversation, ], ); } diff --git a/lib/shared/farcaster/farcaster-api.js b/lib/shared/farcaster/farcaster-api.js --- a/lib/shared/farcaster/farcaster-api.js +++ b/lib/shared/farcaster/farcaster-api.js @@ -29,7 +29,7 @@ +groupId: string, +message: string, } - | { +recipientFid: string, +message: string }; + | { +recipientFid: number, +message: string }; type SendFarcasterMessageResultData = { +messageId: string, 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 @@ -1,6 +1,7 @@ // @flow import invariant from 'invariant'; +import uuid from 'uuid'; import { changeThreadMemberRolesActionTypes, @@ -12,6 +13,7 @@ import { getFarcasterRolePermissionsBlobs } from '../../../permissions/farcaster-permissions.js'; import type { RolePermissionBlobs } from '../../../permissions/thread-permissions.js'; import type { SetThreadUnreadStatusPayload } from '../../../types/activity-types.js'; +import type { AuxUserStore } from '../../../types/aux-user-types.js'; import type { CreateEntryPayload, DeleteEntryResult, @@ -20,6 +22,7 @@ import { messageTypes } from '../../../types/message-types-enum.js'; import { defaultNumberPerThread, + messageTruncationStatus, type SendMessagePayload, type SendMultimediaMessagePayload, } from '../../../types/message-types.js'; @@ -44,17 +47,22 @@ import type { ChangeThreadSettingsPayload, ClientDBThreadInfo, + RawThreadInfos, ThreadJoinPayload, } from '../../../types/thread-types.js'; import { updateTypes } from '../../../types/update-types-enum.js'; import { messageIDToCompoundReactionID } from '../../../utils/convert-farcaster-message-to-comm-messages.js'; +import { createFarcasterRawThreadInfoPersonal } from '../../../utils/create-farcaster-raw-thread-info.js'; import { farcasterThreadIDRegExp } from '../../../utils/validation-utils.js'; import { generatePendingThreadColor } from '../../color-utils.js'; import { processFarcasterOpsActionType } from '../../farcaster/farcaster-actions.js'; -import { - type ModifyFarcasterMembershipInput, - type SendReactionInput, +import type { + ModifyFarcasterMembershipInput, + SendReactionInput, + SendFarcasterMessageResult, + SendFarcasterTextMessageInput, } from '../../farcaster/farcaster-api.js'; +import type { FarcasterConversation } from '../../farcaster/farcaster-conversation-types.js'; import { conversationIDFromFarcasterThreadID, extractFIDFromUserID, @@ -98,13 +106,84 @@ ProtocolSendReactionInput, SendReactionUtils, } from '../thread-spec.js'; +import { threadTypeIsPersonal } from '../thread-specs.js'; + +async function sendFarcasterMessage({ + threadInfo, + viewerID, + auxUserStore, + text, + sendFarcasterTextMessage, + threadInfos, + farcasterFetchConversation, +}: { + threadInfo: ThreadInfo | RawThreadInfo, + viewerID: string, + auxUserStore: AuxUserStore, + text: string, + sendFarcasterTextMessage: SendFarcasterTextMessageInput => Promise, + threadInfos: RawThreadInfos, + farcasterFetchConversation: ( + conversationID: string, + ) => Promise, +}) { + const time = Date.now(); + + let request; + if (threadInfo.type === farcasterThreadTypes.FARCASTER_PERSONAL) { + const otherUser = getSingleOtherUser(threadInfo, viewerID); + invariant( + otherUser, + `Farcaster 1:1 conversation should have one more member except ${ + viewerID ?? 'null' + }`, + ); + const targetFid = + auxUserStore.auxUserInfos[otherUser]?.fid ?? + extractFIDFromUserID(otherUser); + + if (!targetFid) { + throw new Error('Missing target fid'); + } + + const recipientFid = parseInt(targetFid, 10); + request = { + recipientFid, + message: text, + }; + } else { + const conversationID = conversationIDFromFarcasterThreadID(threadInfo.id); + request = { + groupId: conversationID, + message: text, + }; + } + + const result = await sendFarcasterTextMessage(request); + + if ( + threadInfo.type === farcasterThreadTypes.FARCASTER_PERSONAL && + !threadInfos[threadInfo.id] + ) { + await farcasterFetchConversation( + conversationIDFromFarcasterThreadID(threadInfo.id), + ); + } + return { time, result }; +} const farcasterThreadProtocol: ThreadProtocol = { sendTextMessage: async ( message: ProtocolSendTextMessageInput, utils: SendTextMessageUtils, ): Promise => { - const { sendFarcasterTextMessage, currentUserFID } = utils; + const { + sendFarcasterTextMessage, + viewerID, + auxUserStore, + farcasterFetchConversation, + threadInfos, + } = utils; const { messageInfo, threadInfo } = message; const { localID } = messageInfo; invariant( @@ -112,35 +191,20 @@ 'localID should be set', ); - const time = Date.now(); - - let request; - if (threadInfo.type === farcasterThreadTypes.FARCASTER_PERSONAL) { - const otherUser = getSingleOtherUser(threadInfo, currentUserFID); - invariant( - otherUser, - `Farcaster 1:1 conversation should have one more member except ${ - currentUserFID ?? 'null' - }`, - ); - request = { - recipientFid: otherUser, - message: messageInfo.text, - }; - } else { - const conversationID = conversationIDFromFarcasterThreadID(threadInfo.id); - request = { - groupId: conversationID, - message: messageInfo.text, - }; - } - - const result = await sendFarcasterTextMessage(request); + const { time, result } = await sendFarcasterMessage({ + threadInfo, + viewerID, + auxUserStore, + text: messageInfo.text, + sendFarcasterTextMessage, + threadInfos, + farcasterFetchConversation, + }); return { localID, serverID: result.result.messageId, - threadID: messageInfo.threadID, + threadID: threadInfo.id, time: time, }; }, @@ -150,15 +214,19 @@ utils: SendMultimediaMessageUtils, ): Promise => { const { messageInfo, threadInfo } = message; - const { sendFarcasterTextMessage, currentUserFID } = utils; + const { + sendFarcasterTextMessage, + viewerID, + auxUserStore, + farcasterFetchConversation, + threadInfos, + } = utils; const { localID, media } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); - const time = Date.now(); - const messageText = media .map(m => { if (m.type === 'photo' || m.type === 'video') { @@ -169,28 +237,15 @@ .filter(Boolean) .join('\n'); - let request; - if (threadInfo.type === farcasterThreadTypes.FARCASTER_PERSONAL) { - const otherUser = getSingleOtherUser(threadInfo, currentUserFID); - invariant( - otherUser, - `Farcaster 1:1 conversation should have one more member except ${ - currentUserFID ?? 'null' - }`, - ); - request = { - recipientFid: otherUser, - message: messageText, - }; - } else { - const conversationID = conversationIDFromFarcasterThreadID(threadInfo.id); - request = { - groupId: conversationID, - message: messageText, - }; - } - - const result = await sendFarcasterTextMessage(request); + const { time, result } = await sendFarcasterMessage({ + threadInfo, + viewerID, + auxUserStore, + text: messageText, + sendFarcasterTextMessage, + threadInfos, + farcasterFetchConversation, + }); return { result: { @@ -751,6 +806,7 @@ createFarcasterGroup, viewerID, auxUserStore, + dispatch, } = params; if (!threadIsPending(threadInfo.id)) { return { @@ -758,6 +814,7 @@ threadType: threadInfo.type, }; } + invariant(viewerID, 'Viewer ID should be present'); const name = threadInfo.name ?? @@ -773,6 +830,47 @@ .filter(Boolean) .map(id => parseInt(id, 10)); + if (threadTypeIsPersonal(threadInfo.type)) { + const viewerFid = + auxUserStore.auxUserInfos[viewerID]?.fid ?? + extractFIDFromUserID(viewerID); + + const otherUserFid = otherMembersFIDs[0]; + + // We can deterministically generate a future conversation ID for 1:1 + const conversationID = [viewerFid, otherUserFid].sort().join('-'); + + // We construct thread here to make it work with ours design, + // when sending message thread is going to be fetched, + // so Farcaster remains as source of truth + const threadID = farcasterThreadIDFromConversationID(conversationID); + dispatch({ + type: processFarcasterOpsActionType, + payload: { + rawMessageInfos: [], + updateInfos: [ + { + type: updateTypes.JOIN_THREAD, + id: uuid.v4(), + time: Date.now(), + threadInfo: createFarcasterRawThreadInfoPersonal({ + ...threadInfo, + id: threadID, + }), + rawMessageInfos: [], + truncationStatus: messageTruncationStatus.UNCHANGED, + rawEntryInfos: [], + }, + ], + }, + }); + + return { + threadID, + threadType: threadInfo.type, + }; + } + let input = { participantFids: otherMembersFIDs, name: name.substring(0, 32), 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 @@ -80,6 +80,7 @@ LeaveThreadPayload, NewThickThreadRequest, NewThreadResult, + RawThreadInfos, ThreadJoinPayload, UpdateThreadRequest, } from '../../types/thread-types.js'; @@ -127,8 +128,13 @@ +sendKeyserverTextMessage: SendTextMessageInput => Promise, +sendComposableDMOperation: OutboundComposableDMOperationSpecification => Promise, +sendFarcasterTextMessage: SendFarcasterTextMessageInput => Promise, - +currentUserFID: ?string, + +viewerID: string, +sideEffectsFunction: CreationSideEffectsFunc, + +auxUserStore: AuxUserStore, + +farcasterFetchConversation: ( + conversationID: string, + ) => Promise, + +threadInfos: RawThreadInfos, }; export type ProtocolSendMultimediaMessageInput = { @@ -144,8 +150,13 @@ +reassignThickThreadMedia: MediaMetadataReassignmentAction, +processHolders: ProcessHolders, +dispatch: Dispatch, + +viewerID: string, +sendFarcasterTextMessage: SendFarcasterTextMessageInput => Promise, - +currentUserFID: ?string, + +auxUserStore: AuxUserStore, + +farcasterFetchConversation: ( + conversationID: string, + ) => Promise, + +threadInfos: RawThreadInfos, }; export type ProtocolEditTextMessageInput = { @@ -341,6 +352,7 @@ conversationID: string, ) => Promise, +auxUserStore: AuxUserStore, + +dispatch: Dispatch, }; export type ProtocolDeleteMessageInput = { 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,6 +1,9 @@ // @flow -import { getFarcasterRolePermissionsBlobsFromConversation } from '../permissions/farcaster-permissions.js'; +import { + getFarcasterRolePermissionsBlobs, + getFarcasterRolePermissionsBlobsFromConversation, +} from '../permissions/farcaster-permissions.js'; import { specialRoles } from '../permissions/special-roles.js'; import { getAllThreadPermissions, @@ -14,6 +17,7 @@ FarcasterRawThreadInfo, RoleInfo, ThreadCurrentUserInfo, + ThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { minimallyEncodeRoleInfo, @@ -34,17 +38,33 @@ ); } -function createFarcasterRawThreadInfo( - conversation: FarcasterConversation, +type FarcasterThreadData = { + +threadID: string, + +isGroup: boolean, + +permissionBlobs: { + +Members: ThreadRolePermissionsBlob, + +Admins: ?ThreadRolePermissionsBlob, + }, + +memberIDs: $ReadOnlyArray, + +adminIDs: $ReadOnlySet, + +viewerAccess: 'read' | 'read-write' | 'admin', + +muted: boolean, + +unread: boolean, + +name: string, + +description: string, + +createdAt: number, + +pinnedCount: number, + +avatar: ?ClientAvatar, +}; + +function innerCreateFarcasterRawThreadInfo( + threadData: FarcasterThreadData, ): FarcasterRawThreadInfo { - const threadID = farcasterThreadIDFromConversationID( - conversation.conversationId, - ); - const threadType = conversation.isGroup + const threadID = threadData.threadID; + const threadType = threadData.isGroup ? farcasterThreadTypes.FARCASTER_GROUP : farcasterThreadTypes.FARCASTER_PERSONAL; - const permissionBlobs = - getFarcasterRolePermissionsBlobsFromConversation(conversation); + const permissionBlobs = threadData.permissionBlobs; const membersRole: RoleInfo = { ...minimallyEncodeRoleInfo({ @@ -73,45 +93,74 @@ roles[adminsRole.id] = adminsRole; } - const removedUsers = new Set(conversation.removedFids); - const userIDs = conversation.participants - .filter(p => !removedUsers.has(p.fid)) - .map(p => `${p.fid}`); - const adminIDs = new Set(conversation.adminFids.map(fid => `${fid}`)); - - const members = userIDs.map(fid => ({ + const members = threadData.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: adminIDs.has(fid) && adminsRole ? adminsRole.id : membersRole.id, + role: + threadData.adminIDs.has(fid) && adminsRole + ? adminsRole.id + : membersRole.id, })); const currentUserRole = - conversation.viewerContext.access === 'admin' && adminsRole + threadData.viewerAccess === 'admin' && adminsRole ? adminsRole : membersRole; const currentUser: ThreadCurrentUserInfo = minimallyEncodeThreadCurrentUserInfo({ role: currentUserRole.id, permissions: createPermissionsInfo( - conversation.viewerContext.access === 'admin' && permissionBlobs.Admins + threadData.viewerAccess === 'admin' && permissionBlobs.Admins ? permissionBlobs.Admins : permissionBlobs.Members, threadID, threadType, ), subscription: { - home: !conversation.viewerContext.muted, - pushNotifs: !conversation.viewerContext.muted, + home: !threadData.muted, + pushNotifs: !threadData.muted, }, - unread: - conversation.viewerContext.unreadCount > 0 || - conversation.viewerContext.manuallyMarkedUnread, + unread: threadData.unread, }); + return { + farcaster: true, + id: threadData.threadID, + type: threadType, + name: threadData.name, + avatar: threadData.avatar, + description: threadData.description, + color: generatePendingThreadColor(threadData.memberIDs), + parentThreadID: null, + community: null, + creationTime: threadData.createdAt, + repliesCount: 0, + pinnedCount: threadData.pinnedCount, + minimallyEncoded: true, + members, + roles, + currentUser, + }; +} + +function createFarcasterRawThreadInfo( + conversation: FarcasterConversation, +): FarcasterRawThreadInfo { + const threadID = farcasterThreadIDFromConversationID( + conversation.conversationId, + ); + const removedUsers = new Set(conversation.removedFids); + const memberIDs = conversation.participants + .filter(p => !removedUsers.has(p.fid)) + .map(p => `${p.fid}`); + const adminIDs = new Set(conversation.adminFids.map(fid => `${fid}`)); + const unread = + conversation.unreadCount > 0 || + conversation.viewerContext.manuallyMarkedUnread; let avatar: ?ClientAvatar; if (conversation.isGroup) { avatar = conversation.photoUrl @@ -121,9 +170,8 @@ const uri = conversation.viewerContext.counterParty?.pfp?.url; avatar = uri ? { type: 'image', uri } : null; } - - let name = conversation.name; - let description = conversation.description; + let name = conversation.name ?? ''; + let description = conversation.description ?? ''; if (!conversation.isGroup) { // For a 1-1 conversation, follow the Farcaster approach and show user's // name as the conversation name @@ -134,25 +182,56 @@ name = otherUserName; description = `Your Direct Cast with ${otherUserName}`; } + const threadData: FarcasterThreadData = { + threadID, + isGroup: conversation.isGroup, + permissionBlobs: + getFarcasterRolePermissionsBlobsFromConversation(conversation), + memberIDs, + adminIDs, + viewerAccess: conversation.viewerContext.access, + muted: conversation.viewerContext.muted, + unread, + createdAt: conversation.createdAt, + pinnedCount: conversation.pinnedMessages.length, + avatar, + name, + description, + }; - return { - farcaster: true, - id: threadID, - type: threadType, + return innerCreateFarcasterRawThreadInfo(threadData); +} + +function createFarcasterRawThreadInfoPersonal( + threadInfo: ThreadInfo, +): FarcasterRawThreadInfo { + const threadType = farcasterThreadTypes.FARCASTER_PERSONAL; + const permissionBlobs = getFarcasterRolePermissionsBlobs( + threadType, + false, + false, + ); + const memberIDs = threadInfo.members.map(member => member.id); + const otherUser = threadInfo.members.find(user => !user.isViewer); + const name = otherUser?.username ?? 'anonymous'; + const description = `Your Direct Cast with ${name}`; + const threadData: FarcasterThreadData = { + threadID: threadInfo.id, + isGroup: false, + permissionBlobs, + memberIDs, + adminIDs: new Set(), + viewerAccess: 'read-write', + muted: false, + unread: false, name, - avatar, description, - color: generatePendingThreadColor(userIDs), - parentThreadID: null, - community: null, - creationTime: conversation.createdAt, - repliesCount: 0, - pinnedCount: conversation.pinnedMessages.length, - minimallyEncoded: true, - members, - roles, - currentUser, + createdAt: threadInfo.creationTime, + pinnedCount: threadInfo.pinnedCount ?? 0, + avatar: null, }; + + return innerCreateFarcasterRawThreadInfo(threadData); } -export { createFarcasterRawThreadInfo }; +export { createFarcasterRawThreadInfo, createFarcasterRawThreadInfoPersonal }; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -595,6 +595,7 @@ createFarcasterGroup: this.props.createFarcasterGroup, farcasterFetchConversation: this.props.fetchConversation, auxUserStore: this.props.auxUserStore, + dispatch: this.props.dispatch, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js --- a/web/input/input-state-container.react.js +++ b/web/input/input-state-container.react.js @@ -612,6 +612,7 @@ createFarcasterGroup: this.props.createFarcasterGroup, farcasterFetchConversation: this.props.fetchConversation, auxUserStore: this.props.auxUserStore, + dispatch: this.props.dispatch, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); }