diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -29,7 +29,10 @@ type SidebarItem, } from '../shared/sidebar-item-utils.js'; import { threadInChatList, threadIsPending } from '../shared/thread-utils.js'; -import { threadTypeIsSidebar } from '../shared/threads/thread-specs.js'; +import { + threadSpecs, + threadTypeIsSidebar, +} from '../shared/threads/thread-specs.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type ComposableMessageInfo, @@ -450,22 +453,29 @@ targetMessageEditMap.set(messageInfo.targetMessageID, messageInfo.text); } + const threadInfo = threadInfos[threadID]; const targetMessagePinStatusMap = new Map(); - // Once again, we iterate backwards to put the order of messages in - // chronological order (i.e. oldest to newest) to handle pinned messages. - // This is important because we want to make sure that the most recent pin - // action is the one that is used to determine whether a message - // is pinned or not. - for (let i = messages.length - 1; i >= 0; i--) { - const messageInfo = messages[i]; - if (messageInfo.type !== messageTypes.TOGGLE_PIN) { - continue; - } + if (threadSpecs[threadInfo.type].protocol().pinsStoredOnServer) { + // Once again, we iterate backwards to put the order of messages in + // chronological order (i.e. oldest to newest) to handle pinned messages. + // This is important because we want to make sure that the most recent pin + // action is the one that is used to determine whether a message + // is pinned or not. + for (let i = messages.length - 1; i >= 0; i--) { + const messageInfo = messages[i]; + if (messageInfo.type !== messageTypes.TOGGLE_PIN) { + continue; + } - targetMessagePinStatusMap.set( - messageInfo.targetMessageID, - messageInfo.action === 'pin', - ); + targetMessagePinStatusMap.set( + messageInfo.targetMessageID, + messageInfo.action === 'pin', + ); + } + } else { + for (const messageID of threadInfo.pinnedMessageIDs ?? []) { + targetMessagePinStatusMap.set(messageID, true); + } } const targetMessageDeleteStatusMap = new Map(); @@ -602,7 +612,6 @@ return result; })(); - const threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo?.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; 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 @@ -925,6 +925,40 @@ ); } +export type PinMessageInput = { + +conversationId: string, + +messageId: string, +}; + +function usePinMessage(): (input: PinMessageInput) => Promise { + const { sendFarcasterRequest } = useTunnelbroker(); + const { addLog } = useDebugLogs(); + return React.useCallback( + async (input: PinMessageInput) => { + try { + await sendFarcasterRequest({ + apiVersion: 'v2', + endpoint: 'direct-cast-pin-message', + method: { type: 'POST' }, + payload: JSON.stringify(input), + }); + } catch (error) { + addLog( + 'Farcaster API: Failed to pin a message', + JSON.stringify({ + conversationId: input.conversationId, + messageId: input.messageId, + error: getMessageForException(error), + }), + new Set([logTypes.FARCASTER, logTypes.ERROR]), + ); + throw error; + } + }, + [addLog, sendFarcasterRequest], + ); +} + export { useSendFarcasterTextMessage, useFetchFarcasterMessages, @@ -942,4 +976,5 @@ useAcceptInvite, useFetchFarcasterConversationInvites, useAcceptOneOnOneInvite, + usePinMessage, }; 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 @@ -429,6 +429,8 @@ +farcasterConversation: FarcasterConversation, +thread: FarcasterRawThreadInfo, +threadMembers: Array, + +pinnedMessages: Array, + +userIDs: Array, }; async function fetchAndProcessConversation( conversationID: string, @@ -553,10 +555,23 @@ members: threadMembers, }; + const userFIDs = farcasterConversation.pinnedMessages.flatMap(message => + extractFarcasterIDsFromPayload(farcasterMessageValidator, message), + ); + const fetchedUserInfos = await fetchUsersByFIDs(userFIDs); + const pinnedMessages = farcasterConversation.pinnedMessages.flatMap(message => + convertFarcasterMessageToCommMessages(message, fetchedUserInfos, addLog), + ); + const userIDs = Array.from(fetchedUserInfos.entries()).map( + ([fid, user]) => user?.userID ?? userIDFromFID(fid), + ); + return { farcasterConversation, thread, threadMembers, + pinnedMessages, + userIDs, }; } @@ -615,6 +630,8 @@ threadMembers.forEach(member => batchedUpdates.addUserID(member.id)); } batchedUpdates.addUpdateInfo(update); + batchedUpdates.addAdditionalMessageInfos(result.pinnedMessages); + batchedUpdates.addUserIDs(result.userIDs); return farcasterConversation; } catch (e) { @@ -735,6 +752,8 @@ rawEntryInfos: [], }; batchedUpdates.addUpdateInfo(update); + batchedUpdates.addAdditionalMessageInfos(result.pinnedMessages); + batchedUpdates.addUserIDs(result.userIDs); return farcasterConversation; } catch (e) { diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -952,6 +952,14 @@ if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } + + if (rawThreadInfo.pinnedMessageIDs) { + threadInfo = { + ...threadInfo, + pinnedMessageIDs: rawThreadInfo.pinnedMessageIDs, + }; + } + return threadInfo; } diff --git a/lib/shared/threads/protocols/dm-thread-protocol.js b/lib/shared/threads/protocols/dm-thread-protocol.js --- a/lib/shared/threads/protocols/dm-thread-protocol.js +++ b/lib/shared/threads/protocols/dm-thread-protocol.js @@ -954,6 +954,7 @@ supportsThreadRefreshing: false, supportsMessageEdit: true, supportsRelationships: true, + pinsStoredOnServer: false, }); function pendingThreadType(numberOfOtherMembers: number) { 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 @@ -726,6 +726,13 @@ }; } + if (clientDBThreadInfo.pinnedMessageIDs) { + rawThreadInfo = { + ...rawThreadInfo, + pinnedMessageIDs: JSON.parse(clientDBThreadInfo.pinnedMessageIDs), + }; + } + return rawThreadInfo; }, @@ -1032,10 +1039,11 @@ supportsThreadRefreshing: true, temporarilyDisabledFeatures: { changingThreadAvatar: true, - pinningMessages: true, + pinningMessages: false, }, supportsMessageEdit: false, supportsRelationships: false, + pinsStoredOnServer: false, }; function pendingThreadType(numberOfOtherMembers: number) { diff --git a/lib/shared/threads/protocols/keyserver-thread-protocol.js b/lib/shared/threads/protocols/keyserver-thread-protocol.js --- a/lib/shared/threads/protocols/keyserver-thread-protocol.js +++ b/lib/shared/threads/protocols/keyserver-thread-protocol.js @@ -757,6 +757,7 @@ supportsThreadRefreshing: false, supportsMessageEdit: true, supportsRelationships: true, + pinsStoredOnServer: true, }); function pendingThreadType(numberOfOtherMembers: number) { 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 @@ -559,6 +559,7 @@ }, +supportsMessageEdit: boolean, +supportsRelationships: boolean, + +pinsStoredOnServer: boolean, }; export type ThreadSpec< diff --git a/lib/types/minimally-encoded-thread-permissions-types.js b/lib/types/minimally-encoded-thread-permissions-types.js --- a/lib/types/minimally-encoded-thread-permissions-types.js +++ b/lib/types/minimally-encoded-thread-permissions-types.js @@ -279,6 +279,7 @@ +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, + +pinnedMessageIDs?: $ReadOnlyArray, }>; export type ResolvedThreadInfo = $ReadOnly<{ diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -193,6 +193,7 @@ +currentUser: LegacyThreadCurrentUserInfo, +repliesCount: number, +pinnedCount?: number, + +pinnedMessageIDs?: $ReadOnlyArray, }; export type LegacyRawThreadInfo = @@ -271,6 +272,7 @@ currentUser: legacyThreadCurrentUserInfoValidator, repliesCount: t.Number, pinnedCount: t.maybe(t.Number), + pinnedMessageIDs: t.maybe(t.list(tID)), }); export const legacyThreadInfoValidator: TUnion< @@ -347,6 +349,7 @@ +repliesCount: number, +pinnedCount?: number, +timestamps?: ?string, + +pinnedMessageIDs?: ?string, }; export type ThreadDeletionRequest = { diff --git a/lib/utils/convert-farcaster-message-to-comm-messages.js b/lib/utils/convert-farcaster-message-to-comm-messages.js --- a/lib/utils/convert-farcaster-message-to-comm-messages.js +++ b/lib/utils/convert-farcaster-message-to-comm-messages.js @@ -23,7 +23,7 @@ function convertFarcasterMessageToCommMessages( farcasterMessage: FarcasterMessage, fcUserInfos: FCUserInfos, - addLog: AddLogCallback, + addLog: ?AddLogCallback, ): $ReadOnlyArray { const senderFid = farcasterMessage.senderFid.toString(); const creatorID = @@ -194,8 +194,19 @@ time: parseInt(farcasterMessage.serverTimestamp, 10), removedUserIDs: [removedUserID], }); + } else if (farcasterMessage.type === 'pin_message') { + result.push({ + type: messageTypes.TOGGLE_PIN, + id: farcasterMessage.messageId, + threadID, + targetMessageID: farcasterMessage.message, + action: 'pin', + pinnedContent: 'a message', + creatorID, + time: parseInt(farcasterMessage.serverTimestamp, 10), + }); } else { - addLog( + addLog?.( 'Unsupported Farcaster message', `Unsupported message type: ${farcasterMessage.type}`, new Set([logTypes.FARCASTER]), 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 @@ -14,8 +14,8 @@ import { generatePendingThreadColor } from '../shared/color-utils.js'; import type { FarcasterConversation, - FarcasterInboxConversation, FarcasterConversationInvitee, + FarcasterInboxConversation, } from '../shared/farcaster/farcaster-conversation-types.js'; import { farcasterThreadIDFromConversationID, @@ -38,8 +38,8 @@ 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 type { FarcasterThreadType } from '../types/thread-types-enum.js'; +import { farcasterThreadTypes } from '../types/thread-types-enum.js'; function createPermissionsInfo( permissionsBlob: ThreadRolePermissionsBlob, @@ -69,6 +69,7 @@ +description: string, +createdAt: number, +pinnedCount: number, + +pinnedMessageIDs: $ReadOnlyArray, +avatar: ?ClientAvatar, +category: 'default' | 'archived' | 'request', }; @@ -224,6 +225,7 @@ members, roles, currentUser, + pinnedMessageIDs: threadData.pinnedMessageIDs, }; } @@ -264,6 +266,10 @@ ? ensNameForFarcasterUsername(otherUserName) : 'anonymous'; } + const pinnedMessageIDs = conversation.pinnedMessages.map( + message => message.messageId, + ); + const threadData: FarcasterThreadData = { threadID, isGroup: conversation.isGroup, @@ -277,6 +283,7 @@ unread, createdAt: conversation.createdAt, pinnedCount: conversation.pinnedMessages.length, + pinnedMessageIDs, avatar, name, description, @@ -315,6 +322,7 @@ pinnedCount: threadInfo.pinnedCount ?? 0, avatar: null, category: 'default', + pinnedMessageIDs: threadInfo.pinnedMessageIDs ?? [], }; return innerCreateFarcasterRawThreadInfo(threadData); diff --git a/lib/utils/thread-ops-utils.js b/lib/utils/thread-ops-utils.js --- a/lib/utils/thread-ops-utils.js +++ b/lib/utils/thread-ops-utils.js @@ -44,6 +44,9 @@ timestamps: rawThreadInfo.timestamps ? JSON.stringify(rawThreadInfo.timestamps) : null, + pinnedMessageIDs: rawThreadInfo.pinnedMessageIDs + ? JSON.stringify(rawThreadInfo.pinnedMessageIDs) + : null, }; } diff --git a/lib/utils/thread-ops-utils.test.js b/lib/utils/thread-ops-utils.test.js --- a/lib/utils/thread-ops-utils.test.js +++ b/lib/utils/thread-ops-utils.test.js @@ -386,6 +386,7 @@ avatar: null, pinnedCount: 0, timestamps: null, + pinnedMessageIDs: null, }; describe('convertRawThreadInfoToClientDBThreadInfo', () => {