diff --git a/lib/permissions/farcaster-permissions.js b/lib/permissions/farcaster-permissions.js new file mode 100644 --- /dev/null +++ b/lib/permissions/farcaster-permissions.js @@ -0,0 +1,77 @@ +// @flow + +import type { RolePermissionBlobs } from './thread-permissions.js'; +import type { FarcasterConversation } from '../shared/farcaster/farcaster-conversation-types.js'; +import { threadPermissions } from '../types/thread-permission-types.js'; + +function getFarcasterRolePermissionsBlobs( + conversation: FarcasterConversation, +): RolePermissionBlobs { + if (conversation.isGroup) { + // These permissions are based on the experimentation: + // Only admins can create invite links + // Admins can decide who can add users (admins or all the members) + // Admins can change a role of a user + // Only admins can edit a thread + let membersPermissions; + if (conversation.groupPreferences?.onlyAdminsCanWrite) { + membersPermissions = { + [threadPermissions.KNOW_OF]: true, + [threadPermissions.VISIBLE]: true, + [threadPermissions.REACT_TO_MESSAGE]: true, + [threadPermissions.LEAVE_THREAD]: true, + }; + } else { + membersPermissions = { + [threadPermissions.KNOW_OF]: true, + [threadPermissions.VISIBLE]: true, + [threadPermissions.VOICED]: true, + [threadPermissions.REACT_TO_MESSAGE]: true, + [threadPermissions.EDIT_MESSAGE]: true, + [threadPermissions.DELETE_OWN_MESSAGES]: true, + [threadPermissions.ADD_MEMBERS]: + !!conversation.groupPreferences?.membersCanInvite, + [threadPermissions.LEAVE_THREAD]: true, + }; + } + const adminPermissions = { + [threadPermissions.KNOW_OF]: true, + [threadPermissions.VISIBLE]: true, + [threadPermissions.VOICED]: true, + [threadPermissions.REACT_TO_MESSAGE]: true, + [threadPermissions.EDIT_MESSAGE]: true, + [threadPermissions.EDIT_THREAD_NAME]: true, + [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, + [threadPermissions.EDIT_THREAD_AVATAR]: true, + [threadPermissions.REMOVE_MEMBERS]: true, + [threadPermissions.CHANGE_ROLE]: true, + [threadPermissions.MANAGE_PINS]: true, + [threadPermissions.MANAGE_INVITE_LINKS]: true, + [threadPermissions.DELETE_OWN_MESSAGES]: true, + [threadPermissions.DELETE_ALL_MESSAGES]: true, + [threadPermissions.ADD_MEMBERS]: true, + [threadPermissions.LEAVE_THREAD]: true, + }; + return { + Admins: adminPermissions, + Members: membersPermissions, + }; + } else { + const permissions = { + [threadPermissions.KNOW_OF]: true, + [threadPermissions.VISIBLE]: true, + [threadPermissions.VOICED]: true, + [threadPermissions.REACT_TO_MESSAGE]: true, + [threadPermissions.EDIT_MESSAGE]: true, + [threadPermissions.EDIT_THREAD_NAME]: true, + [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, + [threadPermissions.EDIT_THREAD_AVATAR]: true, + [threadPermissions.DELETE_OWN_MESSAGES]: true, + }; + return { + Members: permissions, + }; + } +} + +export { getFarcasterRolePermissionsBlobs }; diff --git a/lib/permissions/keyserver-permissions.js b/lib/permissions/keyserver-permissions.js --- a/lib/permissions/keyserver-permissions.js +++ b/lib/permissions/keyserver-permissions.js @@ -8,6 +8,7 @@ OPEN_TOP_LEVEL_DESCENDANT, TOP_LEVEL_DESCENDANT, } from './prefixes.js'; +import type { RolePermissionBlobs } from './thread-permissions.js'; import { threadTypeIsCommunityRoot } from '../shared/threads/thread-specs.js'; import type { ThreadRolePermissionsBlob, @@ -47,10 +48,6 @@ }; } -export type RolePermissionBlobs = { - +Members: ThreadRolePermissionsBlob, - +Admins?: ThreadRolePermissionsBlob, -}; const defaultUserSurfacedPermissions = [ userSurfacedPermissions.REACT_TO_MESSAGES, userSurfacedPermissions.EDIT_MESSAGES, diff --git a/lib/permissions/thread-permissions.js b/lib/permissions/thread-permissions.js --- a/lib/permissions/thread-permissions.js +++ b/lib/permissions/thread-permissions.js @@ -2,7 +2,6 @@ import invariant from 'invariant'; -import { type RolePermissionBlobs } from './keyserver-permissions.js'; import { parseThreadPermissionString, constructThreadPermissionString, @@ -27,6 +26,11 @@ } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; +export type RolePermissionBlobs = { + +Members: ThreadRolePermissionsBlob, + +Admins?: ThreadRolePermissionsBlob, +}; + function permissionLookup( permissions: ?ThreadPermissionsBlob | ?ThreadPermissionsInfo, permission: ThreadPermission, 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,6 +1,6 @@ // @flow -import type { RolePermissionBlobs } from '../../../permissions/keyserver-permissions.js'; +import type { RolePermissionBlobs } from '../../../permissions/thread-permissions.js'; import type { SetThreadUnreadStatusPayload } from '../../../types/activity-types.js'; import type { CreateEntryPayload, 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 @@ -23,7 +23,7 @@ LeaveThreadInput, LeaveThreadResult, } from '../../hooks/thread-hooks.js'; -import type { RolePermissionBlobs } from '../../permissions/keyserver-permissions.js'; +import type { RolePermissionBlobs } from '../../permissions/thread-permissions.js'; import type { ProcessOutboundP2PMessagesResult } from '../../tunnelbroker/peer-to-peer-context.js'; import type { TunnelbrokerSocketState } from '../../tunnelbroker/tunnelbroker-context.js'; import type { diff --git a/lib/utils/farcaster-utils.js b/lib/utils/farcaster-utils.js --- a/lib/utils/farcaster-utils.js +++ b/lib/utils/farcaster-utils.js @@ -7,14 +7,35 @@ import { getConfig } from './config.js'; import { getContentSigningKey } from './crypto-utils.js'; import { useSelector, useDispatch } from './redux-utils.js'; +import { farcasterIDPrefix } from './validation-utils.js'; import { setSyncedMetadataEntryActionType } from '../actions/synced-metadata-actions.js'; import { useUserIdentityCache } from '../components/user-identity-cache.react.js'; +import { getFarcasterRolePermissionsBlobs } from '../permissions/farcaster-permissions.js'; +import { specialRoles } from '../permissions/special-roles.js'; +import { + getAllThreadPermissions, + makePermissionsBlob, +} from '../permissions/thread-permissions.js'; import { getOwnPeerDevices } from '../selectors/user-selectors.js'; +import { generatePendingThreadColor } from '../shared/color-utils.js'; +import type { FarcasterConversation } from '../shared/farcaster/farcaster-conversation-types.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { PeerToPeerContext } from '../tunnelbroker/peer-to-peer-context.js'; import { databaseIdentifier } from '../types/database-identifier-types.js'; +import { + minimallyEncodeRoleInfo, + minimallyEncodeThreadCurrentUserInfo, +} from '../types/minimally-encoded-thread-permissions-types.js'; +import type { + ThreadCurrentUserInfo, + FarcasterRawThreadInfo, + RoleInfo, +} from '../types/minimally-encoded-thread-permissions-types.js'; import { outboundP2PMessageStatuses } from '../types/sqlite-types.js'; import { syncedMetadataNames } from '../types/synced-metadata-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 type { FarcasterConnectionUpdated } from '../types/tunnelbroker/user-actions-peer-to-peer-message-types.js'; const DISABLE_CONNECT_FARCASTER_ALERT = false; @@ -243,6 +264,116 @@ ); } +function farcasterThreadIDFromConversationID(conversationID: string): string { + return `${farcasterIDPrefix}${conversationID}`; +} + +function createPermissionsInfo( + permissionsBlob: ThreadRolePermissionsBlob, + threadID: string, + threadType: FarcasterThreadType, +) { + return getAllThreadPermissions( + makePermissionsBlob(permissionsBlob, null, threadID, threadType), + threadID, + ); +} + +function createFarcasterRawThreadInfo( + conversation: FarcasterConversation, +): FarcasterRawThreadInfo { + const threadID = farcasterThreadIDFromConversationID( + conversation.conversationId, + ); + const threadType = conversation.isGroup + ? farcasterThreadTypes.FARCASTER_GROUP + : farcasterThreadTypes.FARCASTER_PERSONAL; + const permissionBlobs = getFarcasterRolePermissionsBlobs(conversation); + + const membersRole: RoleInfo = { + ...minimallyEncodeRoleInfo({ + id: `${threadID}/member/role`, + name: 'Members', + permissions: permissionBlobs.Members, + isDefault: true, + }), + specialRole: specialRoles.DEFAULT_ROLE, + }; + const adminsRole: ?RoleInfo = permissionBlobs.Admins + ? { + ...minimallyEncodeRoleInfo({ + id: `${threadID}/admin/role`, + name: 'Admins', + permissions: permissionBlobs.Admins, + isDefault: false, + }), + specialRole: specialRoles.ADMIN_ROLE, + } + : null; + const roles: { [id: string]: RoleInfo } = { + [membersRole.id]: membersRole, + }; + if (adminsRole) { + roles[adminsRole.id] = adminsRole; + } + + const removedUsers = new Set(conversation.removedFids); + const userIDs = conversation.participants + .filter(p => !removedUsers.has(p.fid)) + .map(p => `${p.fid}`); + const adminIDs = new Set(conversation.adminFids.map(fid => `${fid}`)); + + const members = userIDs.map(id => ({ + id, + // 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(id) && adminsRole ? adminsRole.id : membersRole.id, + })); + + const currentUserRole = + conversation.viewerContext.access === 'admin' && adminsRole + ? adminsRole + : membersRole; + const currentUser: ThreadCurrentUserInfo = + minimallyEncodeThreadCurrentUserInfo({ + role: currentUserRole.id, + permissions: createPermissionsInfo( + conversation.viewerContext.access === 'admin' && permissionBlobs.Admins + ? permissionBlobs.Admins + : permissionBlobs.Members, + threadID, + threadType, + ), + subscription: { + home: true, + pushNotifs: !conversation.viewerContext.muted, + }, + unread: conversation.viewerContext.unreadCount > 0, + }); + + return { + farcaster: true, + id: threadID, + type: threadType, + name: conversation.name, + avatar: { type: 'farcaster' }, + description: conversation.description, + color: generatePendingThreadColor(userIDs), + parentThreadID: null, + community: null, + creationTime: conversation.createdAt, + repliesCount: 0, + pinnedCount: conversation.pinnedMessages.length, + minimallyEncoded: true, + members, + roles, + currentUser, + }; +} + export { DISABLE_CONNECT_FARCASTER_ALERT, NO_FID_METADATA, @@ -255,4 +386,5 @@ useUnlinkFID, useLinkFarcasterDCs, createFarcasterDCsAuthMessage, + createFarcasterRawThreadInfo, }; diff --git a/lib/utils/validation-utils.js b/lib/utils/validation-utils.js --- a/lib/utils/validation-utils.js +++ b/lib/utils/validation-utils.js @@ -125,7 +125,8 @@ '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'; const thickIDRegExp: RegExp = new RegExp(`^${uuidRegex}$`); -const farcasterThreadIDRegex = 'FARCASTER#(?:(?:[0-9a-z]+)|(?:[0-9]+-[0-9]+))'; +const farcasterIDPrefix = 'FARCASTER#'; +const farcasterThreadIDRegex = `${farcasterIDPrefix}(?:(?:[0-9a-z]+)|(?:[0-9]+-[0-9]+))`; const farcasterThreadIDRegExp: RegExp = new RegExp( `^${farcasterThreadIDRegex}$`, ); @@ -188,4 +189,5 @@ idSchemaRegExp, farcasterThreadIDRegExp, isKeyserverThreadID, + farcasterIDPrefix, };