diff --git a/lib/permissions/special-roles.js b/lib/permissions/special-roles.js --- a/lib/permissions/special-roles.js +++ b/lib/permissions/special-roles.js @@ -15,6 +15,7 @@ export const specialRoles = Object.freeze({ DEFAULT_ROLE: 1, ADMIN_ROLE: 2, + INVITEE_ROLE: 3, }); export type SpecialRole = $Values; @@ -25,6 +26,7 @@ export const defaultSpecialRoles = Object.freeze({ Members: specialRoles.DEFAULT_ROLE, Admins: specialRoles.ADMIN_ROLE, + Invitees: specialRoles.INVITEE_ROLE, }); function patchRoleInfoWithSpecialRole(role: RoleInfo): RoleInfo { 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 @@ -6,9 +6,11 @@ import type { FarcasterConversation, + FarcasterConversationInvitee, FarcasterInboxConversation, } from './farcaster-conversation-types.js'; import { + farcasterConversationInviteeValidator, farcasterConversationValidator, farcasterInboxConversationValidator, } from './farcaster-conversation-types.js'; @@ -276,6 +278,57 @@ ); } +export type FetchFarcasterConversationInvitesInput = { + +conversationId: string, +}; + +type FetchFarcasterConversationInvitesResultData = { + +invites: Array, + ... +}; +const fetchFarcasterConversationInvitesResultDataValidator: TInterface = + tShapeInexact({ + invites: t.list(farcasterConversationInviteeValidator), + }); + +export type FetchFarcasterConversationInvitesResult = { + +result: FetchFarcasterConversationInvitesResultData, + ... +}; +const fetchFarcasterConversationInvitesResultValidator: TInterface = + tShapeInexact({ + result: fetchFarcasterConversationInvitesResultDataValidator, + }); + +function useFetchFarcasterConversationInvites(): ( + input: FetchFarcasterConversationInvitesInput, +) => Promise { + const { sendFarcasterRequest } = useTunnelbroker(); + return React.useCallback( + async (input: FetchFarcasterConversationInvitesInput) => { + const { conversationId } = input; + const params = new URLSearchParams({ + conversationId, + }); + + const response = await sendFarcasterRequest({ + apiVersion: 'v2', + endpoint: 'direct-cast-group-invites', + method: { type: 'GET' }, + payload: params.toString(), + }); + const parsedResult = JSON.parse(response); + const result: FetchFarcasterConversationInvitesResult = + assertWithValidator( + parsedResult, + fetchFarcasterConversationInvitesResultValidator, + ); + return result; + }, + [sendFarcasterRequest], + ); +} + export type UpdateFarcasterGroupNameAndDescriptionInput = { +conversationId: string, +name: string, @@ -601,4 +654,5 @@ useModifyFarcasterMembershipInput, useSendFarcasterReaction, useAcceptInvite, + useFetchFarcasterConversationInvites, }; diff --git a/lib/shared/farcaster/farcaster-conversation-types.js b/lib/shared/farcaster/farcaster-conversation-types.js --- a/lib/shared/farcaster/farcaster-conversation-types.js +++ b/lib/shared/farcaster/farcaster-conversation-types.js @@ -183,4 +183,23 @@ viewerContext: farcasterConversationViewerContextValidator, }); -export { farcasterInboxConversationValidator, farcasterConversationValidator }; +export type FarcasterConversationInvitee = { + +inviter?: FarcasterDCUserBase, + +invitee: FarcasterDCUserBase, + +inviteTimestamp: number, + +role?: 'member' | 'admin', + ... +}; +const farcasterConversationInviteeValidator: TInterface = + tShapeInexact({ + inviter: t.maybe(FarcasterDCUserBaseValidator), + invitee: FarcasterDCUserBaseValidator, + inviteTimestamp: t.Number, + role: t.maybe(t.enums.of(['member', 'admin'])), + }); + +export { + farcasterInboxConversationValidator, + farcasterConversationValidator, + farcasterConversationInviteeValidator, +}; 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 @@ -9,8 +9,12 @@ type ProcessFarcasterOpsPayload, } from './farcaster-actions.js'; import { + type FetchFarcasterConversationInput, + type FetchFarcasterConversationInvitesInput, + type FetchFarcasterConversationInvitesResult, type FetchFarcasterConversationResult, useFetchFarcasterConversation, + useFetchFarcasterConversationInvites, useFetchFarcasterInbox, useFetchFarcasterMessages, } from './farcaster-api.js'; @@ -65,6 +69,7 @@ import { conversationIDFromFarcasterThreadID, farcasterThreadIDFromConversationID, + isFarcasterPersonalConversationID, userIDFromFID, } from '../id-utils.js'; import { threadSpecs } from '../threads/thread-specs.js'; @@ -308,13 +313,16 @@ }; async function fetchAndProcessConversation( conversationID: string, - fetchFarcasterConversation: (input: { - conversationId: string, - }) => Promise, + fetchFarcasterConversation: ( + input: FetchFarcasterConversationInput, + ) => Promise, + fetchFarcasterConversationInvites: ( + input: FetchFarcasterConversationInvitesInput, + ) => Promise, fetchUsersByFIDs: GetCommFCUsersForFIDs, addLog?: AddLogCallback, ): Promise { - const conversationResult = await withRetry( + const conversationResultPromise = withRetry( () => fetchFarcasterConversation({ conversationId: conversationID, @@ -325,6 +333,46 @@ `fetchConversation(${conversationID})`, ); + const fetchFarcasterInvites = async () => { + try { + return await fetchFarcasterConversationInvites({ + conversationId: conversationID, + }); + } catch (error) { + const messageForException = getMessageForException(error); + if ( + messageForException?.includes('Not authorized to view pending invites') + ) { + // This is normal for many Farcaster group conversations + return null; + } + throw error; + } + }; + + const conversationInviteesResultPromise: Promise = + (async () => { + if (isFarcasterPersonalConversationID(conversationID)) { + // Personal conversations don't have invites + return null; + } + try { + return await withRetry( + fetchFarcasterInvites, + MAX_RETRIES, + RETRY_DELAY_MS, + ); + } catch (error) { + // Errors in fetching invites should not fail the entire conversation + return null; + } + })(); + + const [conversationResult, conversationInviteesResult] = await Promise.all([ + conversationResultPromise, + conversationInviteesResultPromise, + ]); + if (!conversationResult) { if (addLog) { addLog( @@ -352,7 +400,10 @@ } const farcasterConversation = conversationResult.result.conversation; - let thread = createFarcasterRawThreadInfo(farcasterConversation); + let thread = createFarcasterRawThreadInfo( + farcasterConversation, + conversationInviteesResult?.result?.invites ?? [], + ); const fids = thread.members.map(member => member.id); if (fids.length === 0 && addLog) { @@ -392,7 +443,11 @@ members: threadMembers, }; - return { farcasterConversation, thread, threadMembers }; + return { + farcasterConversation, + thread, + threadMembers, + }; } function useFetchConversationWithBatching(): ( @@ -401,6 +456,8 @@ ) => Promise { const fetchUsersByFIDs = useGetCommFCUsersForFIDs(); const fetchFarcasterConversation = useFetchFarcasterConversation(); + const fetchFarcasterConversationInvites = + useFetchFarcasterConversationInvites(); const { addLog } = useDebugLogs(); return React.useCallback( @@ -412,6 +469,7 @@ const result = await fetchAndProcessConversation( conversationID, fetchFarcasterConversation, + fetchFarcasterConversationInvites, fetchUsersByFIDs, addLog, ); @@ -461,7 +519,12 @@ return null; } }, - [addLog, fetchFarcasterConversation, fetchUsersByFIDs], + [ + addLog, + fetchFarcasterConversation, + fetchFarcasterConversationInvites, + fetchUsersByFIDs, + ], ); } @@ -472,6 +535,8 @@ ) => Promise { const fetchUsersByFIDs = useGetCommFCUsersForFIDs(); const fetchFarcasterConversation = useFetchFarcasterConversation(); + const fetchFarcasterConversationInvites = + useFetchFarcasterConversationInvites(); const fetchFarcasterMessages = useFetchMessagesForConversation(); const { addLog } = useDebugLogs(); @@ -485,6 +550,7 @@ const result = await fetchAndProcessConversation( conversationID, fetchFarcasterConversation, + fetchFarcasterConversationInvites, fetchUsersByFIDs, addLog, ); @@ -567,6 +633,7 @@ addLog, fetchFarcasterConversation, fetchFarcasterMessages, + fetchFarcasterConversationInvites, fetchUsersByFIDs, ], ); diff --git a/lib/shared/farcaster/farcaster-user-types.js b/lib/shared/farcaster/farcaster-user-types.js --- a/lib/shared/farcaster/farcaster-user-types.js +++ b/lib/shared/farcaster/farcaster-user-types.js @@ -46,34 +46,11 @@ pfp: t.maybe(farcasterProfilePictureValidator), }); -type FarcasterDCUserViewerContext = { - +canSendDirectCasts?: boolean, - +canAddToGroupDirectly?: boolean, - +nerfed?: boolean, - +invisible?: boolean, - +blocking?: boolean, - +blockedBy?: boolean, - +enableNotifications?: boolean, - ... -}; -const farcasterDCUserViewerContextValidator: TInterface = - tShapeInexact({ - canSendDirectCasts: t.maybe(t.Boolean), - canAddToGroupDirectly: t.maybe(t.Boolean), - nerfed: t.maybe(t.Boolean), - invisible: t.maybe(t.Boolean), - blocking: t.maybe(t.Boolean), - blockedBy: t.maybe(t.Boolean), - enableNotifications: t.maybe(t.Boolean), - }); - export type FarcasterDCUser = { +fid: number, +username?: string, +displayName: string, +pfp?: FarcasterProfilePicture, - +referrerUsername?: string, - +viewerContext?: FarcasterDCUserViewerContext, ... }; const farcasterDCUserValidator: TInterface = tShapeInexact({ @@ -81,8 +58,6 @@ username: t.maybe(t.String), displayName: t.String, pfp: t.maybe(farcasterProfilePictureValidator), - referrerUsername: t.maybe(t.String), - viewerContext: t.maybe(farcasterDCUserViewerContextValidator), }); export { diff --git a/lib/shared/id-utils.js b/lib/shared/id-utils.js --- a/lib/shared/id-utils.js +++ b/lib/shared/id-utils.js @@ -149,6 +149,12 @@ return threadID.replace(farcasterIDPrefix, ''); } +const farcasterPersonalConversationIDRegExp: RegExp = /^\d+-\d+$/; + +function isFarcasterPersonalConversationID(conversationID: string): boolean { + return farcasterPersonalConversationIDRegExp.test(conversationID); +} + const farcasterUserIDPrefix = 'FID#'; function userIDFromFID(farcasterID: string): string { @@ -192,6 +198,7 @@ farcasterIDPrefix, farcasterThreadIDFromConversationID, conversationIDFromFarcasterThreadID, + isFarcasterPersonalConversationID, userIDFromFID, extractFIDFromUserID, farcasterPersonalConversationID, 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 @@ -1062,6 +1062,15 @@ return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } +function roleIsInviteeRole( + roleInfo: ?ClientLegacyRoleInfo | ?RoleInfo, +): boolean { + if (roleInfo?.specialRole === specialRoles.INVITEE_ROLE) { + return true; + } + return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Invitees'); +} + function threadHasAdminRole( threadInfo: ?( | LegacyRawThreadInfo @@ -1452,7 +1461,7 @@ return React.useMemo(() => { const { role } = memberInfo; - if (!canEdit || !role) { + if (!canEdit || !role || roleIsInviteeRole(threadInfo.roles[role])) { return []; } @@ -1858,4 +1867,5 @@ deviceListIsNonEmpty, deviceListCanBeRequestedForUser, expectedAccountDeletionUpdateTimeout, + roleIsInviteeRole, }; 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 @@ -15,6 +15,7 @@ import type { FarcasterConversation, FarcasterInboxConversation, + FarcasterConversationInvitee, } from '../shared/farcaster/farcaster-conversation-types.js'; import { farcasterThreadIDFromConversationID } from '../shared/id-utils.js'; import { stringForUserExplicit } from '../shared/user-utils.js'; @@ -56,6 +57,7 @@ }, +memberIDs: $ReadOnlyArray, +adminIDs: $ReadOnlySet, + +inviteeIDs: $ReadOnlySet, +viewerAccess: 'read' | 'read-write' | 'admin', +muted: boolean, +unread: boolean, @@ -75,6 +77,7 @@ }, +memberIDs: $ReadOnlyArray, +adminIDs: $ReadOnlySet, + +inviteeIDs: $ReadOnlySet, +currentUserOptions: { +isAdmin: boolean, +unread: boolean, @@ -88,9 +91,19 @@ permissionBlobs, memberIDs, adminIDs, + inviteeIDs, currentUserOptions, threadType, } = options; + const inviteeRole: RoleInfo = { + ...minimallyEncodeRoleInfo({ + id: `${threadID}/invitee/role`, + name: 'Invitees', + permissions: permissionBlobs.Members, + isDefault: false, + }), + specialRole: specialRoles.INVITEE_ROLE, + }; const membersRole: RoleInfo = { ...minimallyEncodeRoleInfo({ id: `${threadID}/member/role`, @@ -117,16 +130,27 @@ if (adminsRole) { roles[adminsRole.id] = adminsRole; } + if (threadType === farcasterThreadTypes.FARCASTER_GROUP) { + roles[inviteeRole.id] = inviteeRole; + } - 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: adminIDs.has(fid) && adminsRole ? adminsRole.id : membersRole.id, - })); + const members = memberIDs.map(fid => { + let role = membersRole.id; + if (inviteeIDs.has(fid)) { + role = inviteeRole.id; + } else if (adminIDs.has(fid) && adminsRole) { + role = adminsRole.id; + } + return { + 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, + }; + }); let currentUserRole = null; let permissionsBlob; @@ -169,6 +193,7 @@ permissionBlobs, memberIDs: threadData.memberIDs, adminIDs: threadData.adminIDs, + inviteeIDs: threadData.inviteeIDs, currentUserOptions: { isAdmin: threadData.viewerAccess === 'admin', unread: threadData.unread, @@ -200,15 +225,19 @@ function createFarcasterRawThreadInfo( conversation: FarcasterConversation, + invitees: $ReadOnlyArray, ): FarcasterRawThreadInfo { const threadID = farcasterThreadIDFromConversationID( conversation.conversationId, ); const removedUsers = new Set(conversation.removedFids); - const memberIDs = conversation.participants + const invitedUsers = invitees.map(invitee => invitee.invitee); + const allParticipants = [...conversation.participants, ...invitedUsers]; + const memberIDs = allParticipants .filter(p => !removedUsers.has(p.fid)) .map(p => `${p.fid}`); const adminIDs = new Set(conversation.adminFids.map(fid => `${fid}`)); + const inviteeIDs = new Set(invitees.map(invitee => `${invitee.invitee.fid}`)); const unread = conversation.unreadCount > 0 || conversation.viewerContext.manuallyMarkedUnread; @@ -240,6 +269,7 @@ getFarcasterRolePermissionsBlobsFromConversation(conversation), memberIDs, adminIDs, + inviteeIDs, viewerAccess: conversation.viewerContext.access, muted: conversation.viewerContext.muted, unread, @@ -273,6 +303,7 @@ permissionBlobs, memberIDs, adminIDs: new Set(), + inviteeIDs: new Set(), viewerAccess: 'read-write', muted: false, unread: false, @@ -338,6 +369,7 @@ const threadAdminUserIDs = threadInfo.members .filter(member => member.role === adminsRole?.id) .map(member => member.id); + //TODO: update this condition and compute proper `inviteeIDs` if ( adminsRole && (adminIDs.size !== threadAdminUserIDs.length || @@ -353,6 +385,7 @@ permissionBlobs, memberIDs: threadInfo.members.map(member => member.id), adminIDs, + inviteeIDs: new Set(), currentUserOptions: { isAdmin: adminIDs.has(currentUserFID), unread: conversationIsUnread,