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 @@ -18,6 +18,7 @@ import { messageTypes } from '../types/message-types-enum.js'; import type { RawMultimediaMessageInfo, + ReplyParameters, SendMessagePayload, SendMultimediaMessagePayload, } from '../types/message-types.js'; @@ -31,6 +32,7 @@ parentThreadInfo: ?ThreadInfo, sidebarCreation: boolean, threadCreation: boolean, + reply?: ?ReplyParameters, ) => Promise { const sendKeyserverTextMessage = useSendTextMessage(); const sendComposableDMOperation = useSendComposableDMOperation(); @@ -49,6 +51,7 @@ parentThreadInfo: ?ThreadInfo, sidebarCreation: boolean, threadCreation: boolean, + reply?: ?ReplyParameters, ) => { invariant(viewerID, 'Viewer ID should be present'); return threadSpecs[threadInfo.type].protocol().sendTextMessage( @@ -58,6 +61,7 @@ parentThreadInfo, sidebarCreation, threadCreation, + reply, }, { sendKeyserverTextMessage, 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 @@ -37,8 +37,9 @@ | { +groupId: string, +message: string, + +inReplyToMessageId?: string, } - | { +recipientFid: number, +message: string }; + | { +recipientFid: number, +message: string, +inReplyToMessageId?: 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 @@ -23,6 +23,7 @@ import { defaultNumberPerThread, messageTruncationStatus, + type ReplyParameters, type SendMessagePayload, type SendMultimediaMessagePayload, } from '../../../types/message-types.js'; @@ -126,6 +127,7 @@ rawThreadInfos, farcasterFetchConversation, threadCreation, + reply, }: { threadInfo: ThreadInfo | RawThreadInfo, viewerID: string, @@ -137,9 +139,17 @@ conversationID: string, ) => Promise, threadCreation: boolean, + reply?: ?ReplyParameters, }) { const time = Date.now(); + let messageText = text; + let targetMessageID = null; + if (reply && text.startsWith(reply.messagePrefix)) { + messageText = text.slice(reply.messagePrefix.length); + targetMessageID = reply.targetMessageID; + } + let request; if (threadInfo.type === farcasterThreadTypes.FARCASTER_PERSONAL) { const otherUser = getSingleOtherUser(threadInfo, viewerID); @@ -158,13 +168,15 @@ const recipientFID = parseInt(targetFID, 10); request = { recipientFid: recipientFID, - message: text, + message: messageText, + ...(targetMessageID ? { inReplyToMessageId: targetMessageID } : {}), }; } else { const conversationID = conversationIDFromFarcasterThreadID(threadInfo.id); request = { groupId: conversationID, - message: text, + message: messageText, + ...(targetMessageID ? { inReplyToMessageId: targetMessageID } : {}), }; } @@ -193,7 +205,7 @@ farcasterFetchConversation, rawThreadInfos, } = utils; - const { messageInfo, threadInfo, threadCreation } = message; + const { messageInfo, threadInfo, threadCreation, reply } = message; const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, @@ -209,6 +221,7 @@ rawThreadInfos, farcasterFetchConversation, threadCreation, + reply, }); return { 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 @@ -57,6 +57,7 @@ FetchPinnedMessagesResult, RawMessageInfo, MessageInfo, + ReplyParameters, } from '../../types/message-types.js'; import type { RawTextMessageInfo } from '../../types/messages/text.js'; import type { @@ -132,6 +133,7 @@ +parentThreadInfo: ?ThreadInfo, +sidebarCreation: boolean, +threadCreation: boolean, + +reply?: ?ReplyParameters, }; export type SendTextMessageUtils = { +sendKeyserverTextMessage: SendTextMessageInput => Promise, diff --git a/lib/types/message-types.js b/lib/types/message-types.js --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -224,6 +224,11 @@ // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code +export type ReplyParameters = { + +targetMessageID: string, + +messagePrefix: string, +}; + export type ValidRawSidebarSourceMessageInfo = | RawTextMessageInfo | RawCreateThreadMessageInfo diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -13,6 +13,7 @@ import { chatMessageItemHasEngagement } from 'lib/shared/chat-message-item-utils.js'; import { getMessageLabel } from 'lib/shared/edit-messages-utils.js'; +import { messageID } from 'lib/shared/id-utils.js'; import { createMessageReply } from 'lib/shared/markdown.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; @@ -159,15 +160,15 @@ sendFailed, ]); - const editInputMessage = inputState?.editInputMessage; const reply = React.useCallback(() => { - invariant(editInputMessage, 'editInputMessage should be set in reply'); + invariant(inputState, 'Input state should be set in reply'); invariant(item.messageInfo.text, 'text should be set in reply'); - editInputMessage({ - message: createMessageReply(item.messageInfo.text), - mode: 'prepend', + const messagePrefix = createMessageReply(item.messageInfo.text); + inputState.replyToMessage({ + targetMessageID: messageID(item.messageInfo), + messagePrefix, }); - }, [editInputMessage, item.messageInfo.text]); + }, [inputState, item.messageInfo]); const triggerReply = swipeOptions === 'reply' || swipeOptions === 'both' ? reply : undefined; diff --git a/native/chat/text-message-tooltip-modal.react.js b/native/chat/text-message-tooltip-modal.react.js --- a/native/chat/text-message-tooltip-modal.react.js +++ b/native/chat/text-message-tooltip-modal.react.js @@ -4,6 +4,7 @@ import invariant from 'invariant'; import * as React from 'react'; +import { messageID } from 'lib/shared/id-utils.js'; import { createMessageReply } from 'lib/shared/markdown.js'; import { useDeleteMessage } from 'lib/utils/delete-message-utils.js'; @@ -51,11 +52,17 @@ 'inputState should be set in TextMessageTooltipModal.onPressReply', ); navigateToThread({ threadInfo }); - inputState.editInputMessage({ - message: createMessageReply(text), - mode: 'prepend', + inputState.replyToMessage({ + targetMessageID: messageID(route.params.item.messageInfo), + messagePrefix: createMessageReply(text), }); - }, [inputState, navigateToThread, threadInfo, text]); + }, [ + inputState, + navigateToThread, + route.params.item.messageInfo, + text, + threadInfo, + ]); const renderReplyIcon = React.useCallback( (style: TextStyle) => , [], 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 @@ -73,6 +73,7 @@ import { type RawMessageInfo, type RawMultimediaMessageInfo, + type ReplyParameters, type SendMessagePayload, type SendMultimediaMessagePayload, } from 'lib/types/message-types.js'; @@ -165,6 +166,7 @@ parentThreadInfo: ?ThreadInfo, sidebarCreation: boolean, threadCreation: boolean, + reply?: ?ReplyParameters, ) => Promise, +newThinThread: ( request: ClientNewThinThreadRequest, @@ -181,11 +183,13 @@ }; type State = { +pendingUploads: PendingMultimediaUploads, + +reply: ?ReplyParameters, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, + reply: null, }; sendCallbacks: Array<() => void> = []; activeURIs: Map = new Map(); @@ -436,6 +440,7 @@ sendTextMessage: this.sendTextMessage, sendMultimediaMessage: this.sendMultimediaMessage, editInputMessage: this.editInputMessage, + replyToMessage: this.replyToMessage, addEditInputMessageListener: this.addEditInputMessageListener, removeEditInputMessageListener: this.removeEditInputMessageListener, messageHasUploadFailure: this.messageHasUploadFailure, @@ -622,6 +627,8 @@ const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); + const reply = this.state.reply; + try { const result = await this.props.sendTextMessage( messageInfo, @@ -629,8 +636,10 @@ parentThreadInfo, sidebarCreation, threadCreation, + reply, ); this.pendingSidebarCreationMessageLocalIDs.delete(localID); + this.setState({ reply: null }); return result; } catch (e) { if (e instanceof SendMessageError) { @@ -1371,6 +1380,16 @@ ); }; + replyToMessage = (params: ReplyParameters) => { + this.editInputMessage({ + message: params.messagePrefix, + mode: 'prepend', + }); + this.setState({ + reply: params, + }); + }; + addEditInputMessageListener = ( callbackEditInputBar: (params: EditInputBarMessageParameters) => void, ) => { diff --git a/native/input/input-state.js b/native/input/input-state.js --- a/native/input/input-state.js +++ b/native/input/input-state.js @@ -3,6 +3,7 @@ import * as React from 'react'; import type { NativeMediaSelection } from 'lib/types/media-types.js'; +import type { ReplyParameters } from 'lib/types/message-types.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; @@ -39,6 +40,7 @@ threadInfo: ThreadInfo, ) => Promise, +editInputMessage: (params: EditInputBarMessageParameters) => void, + +replyToMessage: (params: ReplyParameters) => void, +addEditInputMessageListener: ( (params: EditInputBarMessageParameters) => void, ) => void, diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js --- a/web/chat/chat-input-bar.react.js +++ b/web/chat/chat-input-bar.react.js @@ -38,6 +38,7 @@ import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; +import type { ReplyParameters } from 'lib/types/message-types.js'; import type { ThreadInfo, RawThreadInfo, @@ -427,7 +428,8 @@ ); }; - focusAndUpdateText = (text: string) => { + focusAndUpdateText = (params: ReplyParameters) => { + const text = params.messagePrefix; // We need to call focus() first on Safari, otherwise the cursor // ends up at the start instead of the end for some reason const { textarea } = this; 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 @@ -74,6 +74,7 @@ type RawMultimediaMessageInfo, type SendMessagePayload, type SendMultimediaMessagePayload, + type ReplyParameters, } from 'lib/types/message-types.js'; import type { RawImagesMessageInfo } from 'lib/types/messages/images.js'; import type { RawMediaMessageInfo } from 'lib/types/messages/media.js'; @@ -155,6 +156,7 @@ parentThreadInfo: ?ThreadInfo, sidebarCreation: boolean, threadCreation: boolean, + reply?: ?ReplyParameters, ) => Promise, +newThinThread: ( request: ClientNewThinThreadRequest, @@ -180,6 +182,7 @@ }, textCursorPositions: { [threadID: string]: number }, typeaheadState: TypeaheadState, + reply: ?ReplyParameters, }; type State = $ReadOnly; @@ -202,8 +205,9 @@ close: null, accept: null, }, + reply: null, }; - replyCallbacks: Array<(message: string) => void> = []; + replyCallbacks: Array<(params: ReplyParameters) => void> = []; pendingThreadCreations: Map< string, Promise<{ @@ -690,7 +694,7 @@ threadInfo, assignedUploads[localMessageID], ), - addReply: (message: string) => this.addReply(message), + addReply: (params: ReplyParameters) => this.addReply(params), addReplyListener: this.addReplyListener, removeReplyListener: this.removeReplyListener, registerSendCallback: this.props.registerSendCallback, @@ -1420,6 +1424,8 @@ const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); + const reply = this.state.reply; + try { const result = await this.props.sendTextMessage( messageInfo, @@ -1427,8 +1433,10 @@ parentThreadInfo, sidebarCreation, threadCreation, + reply, ); this.pendingSidebarCreationMessageLocalIDs.delete(localID); + this.setState({ reply: null }); return result; } catch (e) { if (e instanceof SendMessageError) { @@ -1659,15 +1667,16 @@ void this.uploadFiles(threadInfo, uploadsToRetry); } - addReply = (message: string) => { - this.replyCallbacks.forEach(addReplyCallback => addReplyCallback(message)); + addReply = (params: ReplyParameters) => { + this.replyCallbacks.forEach(addReplyCallback => addReplyCallback(params)); + this.setState({ reply: params }); }; - addReplyListener = (callbackReply: (message: string) => void) => { + addReplyListener = (callbackReply: (params: ReplyParameters) => void) => { this.replyCallbacks.push(callbackReply); }; - removeReplyListener = (callbackReply: (message: string) => void) => { + removeReplyListener = (callbackReply: (params: ReplyParameters) => void) => { this.replyCallbacks = this.replyCallbacks.filter( candidate => candidate !== callbackReply, ); diff --git a/web/input/input-state.js b/web/input/input-state.js --- a/web/input/input-state.js +++ b/web/input/input-state.js @@ -8,6 +8,7 @@ type MediaMissionStep, type MediaType, } from 'lib/types/media-types.js'; +import type { ReplyParameters } from 'lib/types/message-types.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { RelativeMemberInfo, @@ -80,9 +81,9 @@ localMessageID: string, threadInfo: ThreadInfo, ) => void, - +addReply: (text: string) => void, - +addReplyListener: ((message: string) => void) => void, - +removeReplyListener: ((message: string) => void) => void, + +addReply: (params: ReplyParameters) => void, + +addReplyListener: ((params: ReplyParameters) => void) => void, + +removeReplyListener: ((params: ReplyParameters) => void) => void, +registerSendCallback: (() => mixed) => void, +unregisterSendCallback: (() => mixed) => void, }; diff --git a/web/tooltips/tooltip-action-utils.js b/web/tooltips/tooltip-action-utils.js --- a/web/tooltips/tooltip-action-utils.js +++ b/web/tooltips/tooltip-action-utils.js @@ -17,6 +17,7 @@ chatMessageItemEngagementTargetMessageInfo, } from 'lib/shared/chat-message-item-utils.js'; import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js'; +import { messageID } from 'lib/shared/id-utils.js'; import { createMessageReply } from 'lib/shared/markdown.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { useSidebarExistsOrCanBeCreated } from 'lib/shared/sidebar-utils.js'; @@ -209,7 +210,10 @@ if (!messageInfo.text) { return; } - addReply(createMessageReply(messageInfo.text)); + addReply({ + targetMessageID: messageID(messageInfo), + messagePrefix: createMessageReply(messageInfo.text), + }); }; return { actionButtonContent: buttonContent,