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 @@ -10,6 +10,7 @@ import { useMediaMetadataReassignment } from './upload-hooks.js'; 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 { useMessageCreationSideEffectsFunc } from '../shared/message-utils.js'; import { threadSpecs } from '../shared/threads/thread-specs.js'; import { messageTypes } from '../types/message-types-enum.js'; @@ -32,6 +33,7 @@ const sendComposableDMOperation = useSendComposableDMOperation(); const sideEffectsFunction = useMessageCreationSideEffectsFunc(messageTypes.TEXT); + const sendFarcasterTextMessage = useSendFarcasterTextMessage(); return React.useCallback( ( @@ -51,9 +53,15 @@ sendKeyserverTextMessage, sendComposableDMOperation, sideEffectsFunction, + sendFarcasterTextMessage, }, ), - [sendComposableDMOperation, sendKeyserverTextMessage, sideEffectsFunction], + [ + sendComposableDMOperation, + sendFarcasterTextMessage, + sendKeyserverTextMessage, + sideEffectsFunction, + ], ); } diff --git a/lib/shared/farcaster/farcaster-api.js b/lib/shared/farcaster/farcaster-api.js new file mode 100644 --- /dev/null +++ b/lib/shared/farcaster/farcaster-api.js @@ -0,0 +1,85 @@ +// @flow + +import * as React from 'react'; + +import type { FarcasterMessage } from './farcaster-message-types.js'; +import { useTunnelbroker } from '../../tunnelbroker/tunnelbroker-context.js'; + +export type SendFarcasterTextMessageInput = + | { + +groupId: string, + +message: string, + } + | { +recipientFid: string, +message: string }; +export type SendFarcasterMessageResult = { + +result: { + +messageId: string, + }, +}; +function useSendFarcasterTextMessage(): ( + input: SendFarcasterTextMessageInput, +) => Promise { + const { sendFarcasterRequest } = useTunnelbroker(); + return React.useCallback( + async (input: SendFarcasterTextMessageInput) => { + const response = await sendFarcasterRequest({ + apiVersion: 'fc', + endpoint: 'message', + method: { type: 'PUT' }, + payload: JSON.stringify(input), + }); + + //FIXME: add validators to avoid crashing clients + const result: SendFarcasterMessageResult = JSON.parse(response); + return result; + }, + [sendFarcasterRequest], + ); +} + +export type FetchFarcasterMessageInput = { + +conversationId: string, + +cursor?: number, + +limit?: number, + +messageId?: string, +}; +export type FetchFarcasterMessageResult = { + +result: { + +messages: $ReadOnlyArray, + }, +}; +function useFetchFarcasterMessage(): ( + input: FetchFarcasterMessageInput, +) => Promise { + const { sendFarcasterRequest } = useTunnelbroker(); + return React.useCallback( + async (input: FetchFarcasterMessageInput) => { + const { conversationId, cursor, limit, messageId } = input; + const params = new URLSearchParams({ + conversationId: conversationId.toString(), + }); + if (cursor !== undefined && cursor !== null) { + params.set('cursor', cursor.toString()); + } + if (limit !== undefined && limit !== null) { + params.set('limit', limit.toString()); + } + if (messageId !== undefined && messageId !== null) { + params.set('messageId', messageId.toString()); + } + + const response = await sendFarcasterRequest({ + apiVersion: 'v2', + endpoint: 'direct-cast-conversation-messages', + method: { type: 'GET' }, + payload: params.toString(), + }); + //FIXME: add validators to avoid crashing clients + const result: FetchFarcasterMessageResult = JSON.parse(response); + return result; + }, + [sendFarcasterRequest], + ); +} + +export { useSendFarcasterTextMessage, useFetchFarcasterMessage }; diff --git a/lib/shared/farcaster/farcaster-message-types.js b/lib/shared/farcaster/farcaster-message-types.js new file mode 100644 --- /dev/null +++ b/lib/shared/farcaster/farcaster-message-types.js @@ -0,0 +1,70 @@ +// @flow + +type FarcasterBaseMessage = { + +conversationId: string, + +hasMention: boolean, + +isDeleted: boolean, + +isPinned: boolean, + +viewerContext: ViewerContext, + +senderContext: UserContext, + +reactions: $ReadOnlyArray, + +senderFid: number, + +serverTimestamp: number, + +message: string, + +messageId: string, +}; + +type ViewerContext = { + +focused: boolean, + +isLastReadMessage: boolean, + +reactions: $ReadOnlyArray, +}; + +type UserContext = { + +displayName: string, + +fid: number, + +username: number, + +pfp: { + +url: string, + +verified: boolean, + }, +}; + +//TODO: confirm this type +type ReactionType = string; +type MentionType = string; + +type FarcasterMedia = { + +height: number, + +width: number, + +mimeType: string, + +staticRaster: string, +}; + +export type FarcasterTextMessage = { + ...FarcasterBaseMessage, + +type: 'text', + +isProgrammatic: boolean, + +mentions: $ReadOnlyArray, +}; + +export type FarcasterMultimediaMessage = { + ...FarcasterBaseMessage, + +type: 'text', + +isProgrammatic: boolean, + +mentions: $ReadOnlyArray, + +metadata: { + +medias: $ReadOnlyArray, + }, +}; + +export type FarcasterGroupMembershipAdditionMessage = { + ...FarcasterBaseMessage, + +type: 'group_membership_addition', + +actionTargetUserContext: UserContext, +}; + +export type FarcasterMessage = + | FarcasterTextMessage + | FarcasterMultimediaMessage + | FarcasterGroupMembershipAdditionMessage; 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 { useFetchFarcasterMessage } from './farcaster/farcaster-api.js'; import { codeBlockRegex, type ParserRules } from './markdown.js'; import type { CreationSideEffectsFunc } from './messages/message-spec.js'; import { messageSpecs } from './messages/message-specs.js'; @@ -603,6 +604,7 @@ const callFetchMessagesBeforeCursor = useFetchMessagesBeforeCursor(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); const dispatchActionPromise = useDispatchActionPromise(); + const fetchFarcasterMessages = useFetchFarcasterMessage(); React.useEffect(() => { registerFetchKey(fetchMessagesBeforeCursorActionTypes); @@ -630,6 +632,7 @@ keyserverFetchMessagesBeforeCursor: callFetchMessagesBeforeCursor, keyserverFetchMostRecentMessages: callFetchMostRecentMessages, dispatchActionPromise, + fetchFarcasterMessages, }, ); }, @@ -637,6 +640,7 @@ callFetchMessagesBeforeCursor, callFetchMostRecentMessages, dispatchActionPromise, + fetchFarcasterMessages, messageIDs?.length, oldestMessageServerID, threadID, 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,5 +1,9 @@ // @flow +import invariant from 'invariant'; +import uuid from 'uuid'; + +import { fetchMessagesBeforeCursorActionTypes } from '../../../actions/message-actions.js'; import type { RolePermissionBlobs } from '../../../permissions/keyserver-permissions.js'; import type { SetThreadUnreadStatusPayload } from '../../../types/activity-types.js'; import type { @@ -7,9 +11,13 @@ DeleteEntryResult, SaveEntryResult, } from '../../../types/entry-types.js'; +import type { Media } from '../../../types/media-types.js'; +import { messageTypes } from '../../../types/message-types-enum.js'; import { type SendMessagePayload, type SendMultimediaMessagePayload, + messageTruncationStatus, + type RawMessageInfo, } from '../../../types/message-types.js'; import type { MemberInfoSansPermissions, @@ -24,11 +32,44 @@ } from '../../../types/thread-types.js'; import { pendingThickSidebarURLPrefix } from '../../../utils/validation-utils.js'; import { messageNotifyTypes } from '../../messages/message-spec.js'; -import type { ThreadProtocol } from '../thread-spec.js'; +import type { + ThreadProtocol, + ProtocolSendTextMessageInput, + SendTextMessageUtils, + ProtocolFetchMessageInput, + FetchMessageUtils, +} from '../thread-spec.js'; + +function getUserID(fid: number): string { + //TODO: this should be Comm's userID, blocked on ENG-10946 + return fid.toString(); +} const farcasterThreadProtocol: ThreadProtocol = { - sendTextMessage: async (): Promise => { - throw new Error('sendTextMessage method is not yet implemented'); + sendTextMessage: async ( + message: ProtocolSendTextMessageInput, + utils: SendTextMessageUtils, + ): Promise => { + const { sendFarcasterTextMessage } = utils; + const { messageInfo } = message; + const { localID } = messageInfo; + invariant( + localID !== null && localID !== undefined, + 'localID should be set', + ); + + const time = Date.now(); + const result = await sendFarcasterTextMessage({ + groupId: 'efa192faf954b2f8', + message: messageInfo.text, + }); + + return { + localID, + serverID: result.result.messageId, + threadID: messageInfo.threadID, + time: time, + }; }, sendMultimediaMessage: async (): Promise => { @@ -85,8 +126,96 @@ throw new Error('convertClientDBThreadInfo method is not yet implemented'); }, - fetchMessages: async (): Promise => { - throw new Error('fetchMessages method is not yet implemented'); + fetchMessages: async ( + input: ProtocolFetchMessageInput, + utils: FetchMessageUtils, + ): Promise => { + console.log('fetchMessages method is not yet implemented'); + const { fetchFarcasterMessages } = utils; + //TODO: currentNumberOfFetchedMessages should be offset, + // but this is not supported by Farcaster + const { threadID, numMessagesToFetch, currentNumberOfFetchedMessages } = + input; + + //TODO: use threadID + let payload = { + conversationId: 'efa192faf954b2f8', + }; + if (currentNumberOfFetchedMessages) { + payload = { + ...payload, + limit: numMessagesToFetch, + }; + } + + const promise = (async () => { + const { + result: { messages }, + } = await fetchFarcasterMessages(payload); + + const rawMessageInfos: $ReadOnlyArray = messages + .map(msg => { + if (msg.type === 'group_membership_addition') { + return ({ + id: msg.messageId, + type: messageTypes.ADD_MEMBERS, + threadID, + creatorID: getUserID(msg.senderFid), + time: parseInt(msg.serverTimestamp, 10), + addedUserIDs: [getUserID(msg.actionTargetUserContext.fid)], + }: RawMessageInfo); + } + + if (msg.type === 'text' && !!msg?.metadata?.medias) { + return ({ + id: msg.messageId, + type: messageTypes.MULTIMEDIA, + threadID, + creatorID: getUserID(msg.senderFid), + time: parseInt(msg.serverTimestamp, 10), + media: msg?.metadata?.medias.map( + med => + ({ + id: uuid.v4(), + uri: med.staticRaster, + type: 'photo', + thumbHash: null, + dimensions: { + height: med.height, + width: med.width, + }, + }: Media), + ), + }: RawMessageInfo); + } + + if (msg.type === 'text') { + return ({ + id: msg.messageId, + type: messageTypes.TEXT, + threadID, + creatorID: getUserID(msg.senderFid), + time: parseInt(msg.serverTimestamp, 10), + text: msg.message, + }: RawMessageInfo); + } + + return null; + }) + .filter(Boolean); + + return { + threadID, + //TODO: confirm this + truncationStatus: messageTruncationStatus.EXHAUSTIVE, + rawMessageInfos, + }; + })(); + void utils.dispatchActionPromise( + fetchMessagesBeforeCursorActionTypes, + promise, + ); + await promise; }, createPendingThread: (): RawThreadInfo => { 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 @@ -84,6 +84,12 @@ OutboundComposableDMOperationSpecification, OutboundDMOperationSpecification, } from '../dm-ops/dm-op-types.js'; +import type { + FetchFarcasterMessageInput, + FetchFarcasterMessageResult, + SendFarcasterMessageResult, + SendFarcasterTextMessageInput, +} from '../farcaster/farcaster-api.js'; import type { FetchThickMessagesType } from '../message-utils.js'; import type { CreationSideEffectsFunc, @@ -109,6 +115,9 @@ +sendKeyserverTextMessage: SendTextMessageInput => Promise, +sendComposableDMOperation: OutboundComposableDMOperationSpecification => Promise, +sideEffectsFunction: CreationSideEffectsFunc, + +sendFarcasterTextMessage: ( + input: SendFarcasterTextMessageInput, + ) => Promise, }; export type ProtocolSendMultimediaMessageInput = { @@ -242,6 +251,9 @@ +keyserverFetchMessagesBeforeCursor: FetchMessagesBeforeCursorInput => Promise, +keyserverFetchMostRecentMessages: FetchMostRecentMessagesInput => Promise, +dispatchActionPromise: DispatchActionPromise, + +fetchFarcasterMessages: ( + input: FetchFarcasterMessageInput, + ) => Promise, }; export type ProtocolCreatePendingThreadInput = {