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'; @@ -308,13 +312,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 +332,22 @@ `fetchConversation(${conversationID})`, ); + const conversationInviteesResultPromise = withRetry( + () => + fetchFarcasterConversationInvites({ + conversationId: conversationID, + }), + MAX_RETRIES, + RETRY_DELAY_MS, + addLog, + `fetchConversationInvites(${conversationID})`, + ); + + const [conversationResult, conversationInviteesResult] = await Promise.all([ + conversationResultPromise, + conversationInviteesResultPromise, + ]); + if (!conversationResult) { if (addLog) { addLog( @@ -352,7 +375,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 +418,11 @@ members: threadMembers, }; - return { farcasterConversation, thread, threadMembers }; + return { + farcasterConversation, + thread, + threadMembers, + }; } function useFetchConversationWithBatching(): ( @@ -401,6 +431,8 @@ ) => Promise { const fetchUsersByFIDs = useGetCommFCUsersForFIDs(); const fetchFarcasterConversation = useFetchFarcasterConversation(); + const fetchFarcasterConversationInvites = + useFetchFarcasterConversationInvites(); const { addLog } = useDebugLogs(); return React.useCallback( @@ -412,6 +444,7 @@ const result = await fetchAndProcessConversation( conversationID, fetchFarcasterConversation, + fetchFarcasterConversationInvites, fetchUsersByFIDs, addLog, ); @@ -461,7 +494,12 @@ return null; } }, - [addLog, fetchFarcasterConversation, fetchUsersByFIDs], + [ + addLog, + fetchFarcasterConversation, + fetchFarcasterConversationInvites, + fetchUsersByFIDs, + ], ); } @@ -472,6 +510,8 @@ ) => Promise { const fetchUsersByFIDs = useGetCommFCUsersForFIDs(); const fetchFarcasterConversation = useFetchFarcasterConversation(); + const fetchFarcasterConversationInvites = + useFetchFarcasterConversationInvites(); const fetchFarcasterMessages = useFetchMessagesForConversation(); const { addLog } = useDebugLogs(); @@ -485,6 +525,7 @@ const result = await fetchAndProcessConversation( conversationID, fetchFarcasterConversation, + fetchFarcasterConversationInvites, fetchUsersByFIDs, addLog, ); @@ -567,6 +608,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/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`, @@ -113,20 +126,29 @@ : null; const roles: { [id: string]: RoleInfo } = { [membersRole.id]: membersRole, + [inviteeRole.id]: inviteeRole, }; if (adminsRole) { roles[adminsRole.id] = adminsRole; } - 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; @@ -167,6 +189,7 @@ permissionBlobs, memberIDs: threadData.memberIDs, adminIDs: threadData.adminIDs, + inviteeIDs: threadData.inviteeIDs, currentUserOptions: { isAdmin: threadData.viewerAccess === 'admin', unread: threadData.unread, @@ -198,15 +221,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; @@ -238,6 +265,7 @@ getFarcasterRolePermissionsBlobsFromConversation(conversation), memberIDs, adminIDs, + inviteeIDs, viewerAccess: conversation.viewerContext.access, muted: conversation.viewerContext.muted, unread, @@ -271,6 +299,7 @@ permissionBlobs, memberIDs, adminIDs: new Set(), + inviteeIDs: new Set(), viewerAccess: 'read-write', muted: false, unread: false, @@ -336,6 +365,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 || @@ -351,6 +381,7 @@ permissionBlobs, memberIDs: threadInfo.members.map(member => member.id), adminIDs, + inviteeIDs: new Set(), currentUserOptions: { isAdmin: adminIDs.has(currentUserFID), unread: conversationIsUnread,