diff --git a/keyserver/src/updaters/thread-updaters.js b/keyserver/src/updaters/thread-updaters.js index d11d3d35c..c87f7c4f4 100644 --- a/keyserver/src/updaters/thread-updaters.js +++ b/keyserver/src/updaters/thread-updaters.js @@ -1,1111 +1,1111 @@ // @flow import { specialRoles } from 'lib/permissions/special-roles.js'; import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js'; import { getPinnedContentFromMessage } from 'lib/shared/message-utils.js'; import { threadHasAdminRole, roleIsAdminRole, viewerIsMember, getThreadTypeParentRequirement, } from 'lib/shared/thread-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { RawMessageInfo, MessageData } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, type ToggleMessagePinRequest, type ToggleMessagePinResult, } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { ServerError } from 'lib/utils/errors.js'; import { canToggleMessagePin } from 'lib/utils/message-pinning-utils.js'; import { promiseAll, ignorePromiseRejections } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import { validChatNameRegex } from 'lib/utils/validation-utils.js'; import { reportLinkUsage } from './link-updaters.js'; import { updateRoles } from './role-updaters.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, type MembershipChangeset, type MembershipRow, } from './thread-permission-updaters.js'; import createMessages from '../creators/message-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchCommunityFarcasterChannelTag } from '../fetchers/community-fetchers.js'; import { checkIfInviteLinkIsValid } from '../fetchers/link-fetchers.js'; import { fetchMessageInfoByID } from '../fetchers/message-fetchers.js'; import { fetchRoles } from '../fetchers/role-fetchers.js'; import { fetchThreadInfos, fetchServerThreadInfos, determineThreadAncestry, rawThreadInfosFromServerThreadInfos, determineThreadAncestryForPossibleMemberResolution, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, viewerHasPositiveRole, checkThread, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import { verifyUserIDs, verifyUserOrCookieIDs, } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; -import { neynarClient, fcCache } from '../utils/fc-cache.js'; +import { fcCache } from '../utils/fc-cache.js'; import { findUserIdentities } from '../utils/identity-utils.js'; import { redisCache } from '../utils/redis-cache.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; type UpdateRoleOptions = { +silenceNewMessages?: boolean, +forcePermissionRecalculation?: boolean, }; async function updateRole( viewer: Viewer, request: RoleChangeRequest, options?: UpdateRoleOptions, ): Promise { const silenceNewMessages = options?.silenceNewMessages; const forcePermissionRecalculation = options?.forcePermissionRecalculation; if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission, fetchThreadResult] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), fetchServerThreadInfos({ threadID: request.threadID }), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!threadInfo) { throw new ServerError('invalid_parameters'); } const adminRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].name === 'Admins', ); // Ensure that there will always still be at least one admin in a community if (adminRoleID && adminRoleID !== request.role) { const memberRoles = memberIDs.map( memberID => threadInfo.members.find(member => member.id === memberID)?.role, ); const communityAdminsCount = threadInfo.members.filter( member => member.role === adminRoleID, ).length; const changedAdminsCount = memberRoles.filter( memberRole => memberRole === adminRoleID, ).length; if (changedAdminsCount >= communityAdminsCount) { throw new ServerError('invalid_parameters'); } } const query = SQL` SELECT user, role FROM memberships WHERE user IN (${memberIDs}) AND thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonMemberUser = false; let numResults = 0; for (const row of result) { if (row.role <= 0) { nonMemberUser = true; break; } numResults++; } if (nonMemberUser || numResults < memberIDs.length) { throw new ServerError('invalid_parameters'); } const changeset = await changeRole( request.threadID, memberIDs, request.role, { forcePermissionRecalculation: !!forcePermissionRecalculation, }, ); const { viewerUpdates } = await commitMembershipChangeset( viewer, changeset, forcePermissionRecalculation ? { changedThreadIDs: new Set([request.threadID]) } : undefined, ); let newMessageInfos: Array = []; if (!silenceNewMessages) { const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, roleName: threadInfo.roles[request.role].name, }; newMessageInfos = await createMessages(viewer, [messageData]); } return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } async function removeMembers( viewer: Viewer, request: RemoveMembersRequest, ): Promise { const viewerID = viewer.userID; if (request.memberIDs.includes(viewerID)) { throw new ServerError('invalid_parameters'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserOrCookieIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.REMOVE_MEMBERS, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT m.user, m.role, r.id AS default_role FROM memberships m LEFT JOIN roles r ON r.special_role = ${specialRoles.DEFAULT_ROLE} AND r.thread = ${request.threadID} WHERE m.user IN (${memberIDs}) AND m.thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonDefaultRoleUser = false; const actualMemberIDs = []; for (const row of result) { if (row.role <= 0) { continue; } actualMemberIDs.push(row.user.toString()); if (row.role !== row.default_role) { nonDefaultRoleUser = true; } } if (nonDefaultRoleUser) { const hasChangeRolePermission = await checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ); if (!hasChangeRolePermission) { throw new ServerError('invalid_credentials'); } } const changeset = await changeRole(request.threadID, actualMemberIDs, 0); const { viewerUpdates } = await commitMembershipChangeset(viewer, changeset); const newMessageInfos = await (async () => { if (actualMemberIDs.length === 0) { return []; } const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; return await createMessages(viewer, [messageData]); })(); return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [fetchThreadResult, hasPermission] = await Promise.all([ fetchThreadInfos(viewer, { threadID: request.threadID }), checkThreadPermission( viewer, request.threadID, threadPermissions.LEAVE_THREAD, ), ]); const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!viewerIsMember(threadInfo)) { return { updatesResult: { newUpdates: [] }, }; } if (!hasPermission) { throw new ServerError('invalid_parameters'); } const viewerID = viewer.userID; if (threadHasAdminRole(threadInfo)) { let otherUsersExist = false; let otherAdminsExist = false; for (const member of threadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo.roles[role])) { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); const { viewerUpdates } = await commitMembershipChangeset(viewer, changeset); const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; await createMessages(viewer, [messageData]); return { updatesResult: { newUpdates: viewerUpdates } }; } type UpdateThreadOptions = Partial<{ +forceAddMembers: boolean, +forceUpdateRoot: boolean, +silenceMessages: boolean, +ignorePermissions: boolean, }>; async function updateThread( viewer: Viewer, request: UpdateThreadRequest, options?: UpdateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const forceUpdateRoot = options?.forceUpdateRoot ?? false; const silenceMessages = options?.silenceMessages ?? false; const ignorePermissions = (options?.ignorePermissions && viewer.isScriptViewer) ?? false; const changedFields: { [string]: string | number } = {}; const sqlUpdate: { [string]: ?string | number } = {}; const untrimmedName = request.changes.name; if (untrimmedName !== undefined && untrimmedName !== null) { const name = firstLine(untrimmedName); if (name.search(validChatNameRegex) === -1) { throw new ServerError('invalid_chat_name'); } changedFields.name = name; sqlUpdate.name = name; } const { description } = request.changes; if (description !== undefined && description !== null) { changedFields.description = description; sqlUpdate.description = description; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const { parentThreadID } = request.changes; if (parentThreadID !== undefined) { // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const { avatar } = request.changes; if (avatar) { changedFields.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : ''; sqlUpdate.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : null; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } if ( !ignorePermissions && threadType !== null && threadType !== undefined && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && threadType !== threadTypes.COMMUNITY_SECRET_SUBTHREAD ) { throw new ServerError('invalid_parameters'); } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...new Set(request.changes.newMemberIDs)] : null; if ( Object.keys(sqlUpdate).length === 0 && !newMemberIDs && !forceUpdateRoot ) { throw new ServerError('invalid_parameters'); } const serverThreadInfosPromise = fetchServerThreadInfos({ threadID: request.threadID, }); const hasNecessaryPermissionsPromise = (async () => { if (ignorePermissions) { return; } const checks = []; if (sqlUpdate.name !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_NAME, }); } if (sqlUpdate.description !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_DESCRIPTION, }); } if (sqlUpdate.color !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_COLOR, }); } if (sqlUpdate.avatar !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_AVATAR, }); } if (parentThreadID !== undefined || sqlUpdate.type !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_PERMISSIONS, }); } if (newMemberIDs) { checks.push({ check: 'permission', permission: threadPermissions.ADD_MEMBERS, }); } const hasNecessaryPermissions = await checkThread( viewer, request.threadID, checks, ); if (!hasNecessaryPermissions) { throw new ServerError('invalid_credentials'); } })(); const [serverThreadInfos] = await Promise.all([ serverThreadInfosPromise, hasNecessaryPermissionsPromise, ]); const serverThreadInfo = serverThreadInfos.threadInfos[request.threadID]; if (!serverThreadInfo) { throw new ServerError('internal_error'); } // Threads with source message should be visible to everyone, but we can't // guarantee it for COMMUNITY_SECRET_SUBTHREAD threads so we forbid it for // now. In the future, if we want to support this, we would need to unlink the // source message. if ( threadType !== null && threadType !== undefined && threadType !== threadTypes.SIDEBAR && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && serverThreadInfo.sourceMessageID ) { throw new ServerError('invalid_parameters'); } // You can't change the parent thread of a current or former SIDEBAR if (parentThreadID !== undefined && serverThreadInfo.sourceMessageID) { throw new ServerError('invalid_parameters'); } const oldThreadType = serverThreadInfo.type; const oldParentThreadID = serverThreadInfo.parentThreadID; const oldContainingThreadID = serverThreadInfo.containingThreadID; const oldCommunity = serverThreadInfo.community; const oldDepth = serverThreadInfo.depth; const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; let nextParentThreadID = parentThreadID !== undefined ? parentThreadID : oldParentThreadID; // Does the new thread type preclude a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'disabled' && nextParentThreadID !== null ) { nextParentThreadID = null; sqlUpdate.parent_thread_id = null; } // Does the new thread type require a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'required' && nextParentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const determineThreadAncestryPromise = determineThreadAncestry( nextParentThreadID, nextThreadType, ); const confirmParentPermissionPromise = (async () => { if (ignorePermissions || !nextParentThreadID) { return; } if ( nextParentThreadID === oldParentThreadID && (nextThreadType === threadTypes.SIDEBAR) === (oldThreadType === threadTypes.SIDEBAR) ) { return; } const hasParentPermission = await checkThreadPermission( viewer, nextParentThreadID, nextThreadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_parameters'); } })(); const rolesNeedUpdate = forceUpdateRoot || nextThreadType !== oldThreadType; const validateNewMembersPromise = (async () => { if (!newMemberIDs || ignorePermissions) { return; } const defaultRolePermissionsPromise = (async () => { let rolePermissions; if (!rolesNeedUpdate) { const rolePermissionsQuery = SQL` SELECT r.permissions FROM threads t LEFT JOIN roles r ON r.special_role = ${specialRoles.DEFAULT_ROLE} AND r.thread = ${request.threadID} WHERE t.id = ${request.threadID} `; const [result] = await dbQuery(rolePermissionsQuery); if (result.length > 0) { rolePermissions = JSON.parse(result[0].permissions); } } if (!rolePermissions) { rolePermissions = getRolePermissionBlobs(nextThreadType).Members; } return rolePermissions; })(); const [defaultRolePermissions, nextThreadAncestry] = await Promise.all([ defaultRolePermissionsPromise, determineThreadAncestryPromise, ]); const containingThreadIDForPossibleMemberResolution = determineThreadAncestryForPossibleMemberResolution( nextParentThreadID, nextThreadAncestry.containingThreadID, ); const { newMemberIDs: validatedIDs } = await validateCandidateMembers( viewer, { newMemberIDs }, { threadType: nextThreadType, parentThreadID: nextParentThreadID, containingThreadID: containingThreadIDForPossibleMemberResolution, defaultRolePermissions, communityID: nextThreadAncestry.community, }, { requireRelationship: !forceAddMembers }, ); if ( validatedIDs && Number(validatedIDs?.length) < Number(newMemberIDs?.length) ) { throw new ServerError('invalid_credentials'); } })(); const { nextThreadAncestry } = await promiseAll({ nextThreadAncestry: determineThreadAncestryPromise, confirmParentPermissionPromise, validateNewMembersPromise, }); if (nextThreadAncestry.containingThreadID !== oldContainingThreadID) { sqlUpdate.containing_thread_id = nextThreadAncestry.containingThreadID; } if (nextThreadAncestry.community !== oldCommunity) { if (!ignorePermissions) { throw new ServerError('invalid_parameters'); } sqlUpdate.community = nextThreadAncestry.community; } if (nextThreadAncestry.depth !== oldDepth) { sqlUpdate.depth = nextThreadAncestry.depth; } const updateQueryPromise = (async () => { if (Object.keys(sqlUpdate).length === 0) { return; } const { avatar: avatarUpdate, ...nonAvatarUpdates } = sqlUpdate; const updatePromises = []; if (Object.keys(nonAvatarUpdates).length > 0) { const nonAvatarUpdateQuery = SQL` UPDATE threads SET ${nonAvatarUpdates} WHERE id = ${request.threadID} `; updatePromises.push(dbQuery(nonAvatarUpdateQuery)); } if (avatarUpdate !== undefined) { const avatarUploadID = avatar && (avatar.type === 'image' || avatar.type === 'encrypted_image') ? avatar.uploadID : null; const avatarUpdateQuery = SQL` START TRANSACTION; UPDATE uploads SET container = NULL WHERE container = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND user_container IS NULL AND thread IS NULL ) ); UPDATE uploads SET container = ${request.threadID} WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND user_container IS NULL AND thread IS NULL; UPDATE threads SET avatar = ${avatarUpdate} WHERE id = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container = ${request.threadID} AND thread IS NULL ) ); COMMIT; `; updatePromises.push( dbQuery(avatarUpdateQuery, { multipleStatements: true }), ); } await Promise.all(updatePromises); })(); const updateRolesPromise = (async () => { if (rolesNeedUpdate) { await updateRoles(viewer, request.threadID, nextThreadType); } })(); const addMembersChangesetPromise: Promise = (async () => { if (!newMemberIDs) { return undefined; } await Promise.all([updateQueryPromise, updateRolesPromise]); return await changeRole(request.threadID, newMemberIDs, null, { setNewMembersToUnread: true, }); })(); const recalculatePermissionsChangesetPromise: Promise = (async () => { const threadRootChanged = rolesNeedUpdate || nextParentThreadID !== oldParentThreadID; if (!threadRootChanged) { return undefined; } await Promise.all([updateQueryPromise, updateRolesPromise]); return await recalculateThreadPermissions(request.threadID); })(); const [addMembersChangeset, recalculatePermissionsChangeset] = await Promise.all([ addMembersChangesetPromise, recalculatePermissionsChangesetPromise, updateQueryPromise, updateRolesPromise, ]); const membershipRows: Array = []; const relationshipChangeset = new RelationshipChangeset(); if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipChangeset.addAll(recalculateRelationshipChangeset); } let addedMemberIDs; if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipChangeset: addMembersRelationshipChangeset, } = addMembersChangeset; addedMemberIDs = addMembersMembershipRows .filter( row => row.operation === 'save' && row.threadID === request.threadID && Number(row.role) > 0, ) .map(row => row.userID); membershipRows.push(...addMembersMembershipRows); relationshipChangeset.addAll(addMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { viewerUpdates } = await commitMembershipChangeset(viewer, changeset, { // This forces an update for this thread, // regardless of whether any membership rows are changed changedThreadIDs: Object.keys(sqlUpdate).length > 0 ? new Set([request.threadID]) : new Set(), // last_message will be updated automatically if we send a message, // so we only need to handle it here when we silence new messages updateMembershipsLastMessage: silenceMessages, }); let newMessageInfos: Array = []; if (!silenceMessages) { const time = Date.now(); const messageDatas: Array = []; for (const fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (addedMemberIDs && addedMemberIDs.length > 0) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: addedMemberIDs, }); } newMessageInfos = await createMessages(viewer, messageDatas); } return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const communityFarcasterChannelTagPromise = fetchCommunityFarcasterChannelTag( viewer, request.threadID, ); const permissionPromise = (async () => { if (request.inviteLinkSecret) { return await checkIfInviteLinkIsValid( request.inviteLinkSecret, request.threadID, ); } const threadPermissionPromise = checkThreadPermission( viewer, request.threadID, threadPermissions.JOIN_THREAD, ); const [threadPermission, communityFarcasterChannelTag] = await Promise.all([ threadPermissionPromise, communityFarcasterChannelTagPromise, ]); return threadPermission || !!communityFarcasterChannelTag; })(); const [isMember, hasPermission, communityFarcasterChannelTag] = await Promise.all([ viewerHasPositiveRole(viewer, request.threadID), permissionPromise, communityFarcasterChannelTagPromise, ]); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const { calendarQuery } = request; if (isMember) { const response: ThreadJoinResult = { rawMessageInfos: [], truncationStatuses: {}, userInfos: {}, updatesResult: { newUpdates: [], }, }; return response; } if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } let role = null; if (communityFarcasterChannelTag) { role = await fetchUserRoleForThread( viewer, request.threadID, communityFarcasterChannelTag, ); } const changeset = await changeRole(request.threadID, [viewer.userID], role, { defaultSubscription: request.defaultSubscription, }); const membershipResult = await commitMembershipChangeset(viewer, changeset, { calendarQuery, }); if (request.inviteLinkSecret) { ignorePromiseRejections(reportLinkUsage(request.inviteLinkSecret)); } const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; const newMessages = await createMessages(viewer, [messageData]); return { rawMessageInfos: newMessages, truncationStatuses: {}, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; } async function fetchUserRoleForThread( viewer: Viewer, threadID: string, communityFarcasterChannelTag: string, ): Promise { const farcasterID = await getUserFarcasterID(viewer.userID); if (!farcasterID) { return null; } const leadsChannel = await userLeadsChannel( communityFarcasterChannelTag, farcasterID, ); if (!leadsChannel) { return null; } const roleInfos = await fetchRoles(threadID); for (const roleInfo of roleInfos) { if (roleInfo.specialRole === specialRoles.ADMIN_ROLE) { return roleInfo.id; } } return null; } async function getUserFarcasterID(userID: string): Promise { const cachedUserIdentity = await redisCache.getUserIdentity(userID); if (cachedUserIdentity) { return cachedUserIdentity.farcasterID; } const response = await findUserIdentities([userID]); const userIdentity = response.identities[userID]; if (!userIdentity) { return null; } ignorePromiseRejections(redisCache.setUserIdentity(userID, userIdentity)); return userIdentity.farcasterID; } async function userLeadsChannel( communityFarcasterChannelTag: string, farcasterID: string, ) { const cachedChannelInfo = await redisCache.getChannelInfo( communityFarcasterChannelTag, ); if (cachedChannelInfo) { return cachedChannelInfo.lead.fid === parseInt(farcasterID); } // In the background, we fetch and cache followed channels ignorePromiseRejections( (async () => { const followedChannels = - await neynarClient?.fetchFollowedFarcasterChannels(farcasterID); + await fcCache?.getFollowedFarcasterChannelsForFID(farcasterID); if (followedChannels) { await Promise.allSettled( followedChannels.map(followedChannel => redisCache.setChannelInfo(followedChannel.id, followedChannel), ), ); } })(), ); const channelInfo = await fcCache?.getFarcasterChannelForChannelID( communityFarcasterChannelTag, ); if (channelInfo) { return channelInfo.lead.fid === parseInt(farcasterID); } return false; } async function toggleMessagePinForThread( viewer: Viewer, request: ToggleMessagePinRequest, ): Promise { const { messageID, action } = request; const targetMessage = await fetchMessageInfoByID(viewer, messageID); if (!targetMessage) { throw new ServerError('invalid_parameters'); } const { threadID } = targetMessage; const fetchServerThreadInfosResult = await fetchServerThreadInfos({ threadID, }); const { threadInfos: rawThreadInfos } = rawThreadInfosFromServerThreadInfos( viewer, fetchServerThreadInfosResult, ); const rawThreadInfo = rawThreadInfos[threadID]; const canTogglePin = canToggleMessagePin(targetMessage, rawThreadInfo); if (!canTogglePin) { throw new ServerError('invalid_parameters'); } const pinnedValue = action === 'pin' ? 1 : 0; const pinTimeValue = action === 'pin' ? Date.now() : null; const pinnedCountValue = action === 'pin' ? 1 : -1; const query = SQL` UPDATE messages AS m, threads AS t SET m.pinned = ${pinnedValue}, m.pin_time = ${pinTimeValue}, t.pinned_count = t.pinned_count + ${pinnedCountValue} WHERE m.id = ${messageID} AND m.thread = ${threadID} AND t.id = ${threadID} AND m.pinned != ${pinnedValue} `; const [result] = await dbQuery(query); if (result.affectedRows === 0) { return { newMessageInfos: [], threadID, }; } const createMessagesAsync = async () => { const messageData = { type: messageTypes.TOGGLE_PIN, threadID, targetMessageID: messageID, action, pinnedContent: getPinnedContentFromMessage(targetMessage), creatorID: viewer.userID, time: Date.now(), }; const newMessageInfos = await createMessages(viewer, [messageData]); return newMessageInfos; }; const createUpdatesAsync = async () => { const { threadInfos: serverThreadInfos } = fetchServerThreadInfosResult; const time = Date.now(); const updates = []; for (const member of serverThreadInfos[threadID].members) { updates.push({ userID: member.id, time, threadID, type: updateTypes.UPDATE_THREAD, }); } await createUpdates(updates); }; const [newMessageInfos] = await Promise.all([ createMessagesAsync(), createUpdatesAsync(), ]); return { newMessageInfos, threadID, }; } export { updateRole, removeMembers, leaveThread, updateThread, joinThread, toggleMessagePinForThread, }; diff --git a/lib/components/base-auto-join-community-handler.react.js b/lib/components/base-auto-join-community-handler.react.js index 3b27d0662..3f5db4ede 100644 --- a/lib/components/base-auto-join-community-handler.react.js +++ b/lib/components/base-auto-join-community-handler.react.js @@ -1,339 +1,343 @@ // @flow import invariant from 'invariant'; import _pickBy from 'lodash/fp/pickBy.js'; import * as React from 'react'; import { NeynarClientContext } from '../components/neynar-client-provider.react.js'; import blobService from '../facts/blob-service.js'; import { useIsLoggedInToIdentityAndAuthoritativeKeyserver } from '../hooks/account-hooks.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { farcasterChannelTagBlobHash, useJoinCommunity, } from '../shared/community-utils.js'; import type { AuthMetadata } from '../shared/identity-client-context.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import type { KeyserverOverride } from '../shared/invite-links.js'; import type { OngoingJoinCommunityData, JoinCommunityStep, } from '../types/community-types.js'; import type { CalendarQuery } from '../types/entry-types.js'; import type { SetState } from '../types/hook-types.js'; import { defaultThreadSubscription } from '../types/subscription-types.js'; import { getBlobFetchableURL } from '../utils/blob-service.js'; import { useCurrentUserFID } from '../utils/farcaster-utils.js'; import { promiseAll } from '../utils/promises.js'; import { useSelector } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken, createDefaultHTTPRequestHeaders, } from '../utils/services-utils.js'; import sleep from '../utils/sleep.js'; type JoinStatus = 'inactive' | 'joining' | 'joined'; type CommunityToAutoJoin = { +batch: number, +communityID: string, +keyserverOverride: ?KeyserverOverride, +joinStatus: JoinStatus, }; type CommunityDatas = { +[communityID: string]: CommunityToAutoJoin, }; type CommunitiesToAutoJoin = { +curBatch: number, +communityDatas: CommunityDatas, }; type Props = { +calendarQuery: () => CalendarQuery, }; function BaseAutoJoinCommunityHandler(props: Props): React.Node { const { calendarQuery } = props; const isActive = useSelector(state => state.lifecycleState !== 'background'); const loggedIn = useIsLoggedInToIdentityAndAuthoritativeKeyserver(); const fid = useCurrentUserFID(); - const neynarClient = React.useContext(NeynarClientContext)?.client; + const fcCache = React.useContext(NeynarClientContext)?.fcCache; const identityClientContext = React.useContext(IdentityClientContext); invariant(identityClientContext, 'IdentityClientContext should be set'); const { getAuthMetadata } = identityClientContext; const threadInfos = useSelector(state => state.threadStore.threadInfos); const keyserverInfos = useSelector( state => state.keyserverStore.keyserverInfos, ); const [communitiesToAutoJoin, baseSetCommunitiesToAutoJoin] = React.useState(); const prevCanQueryRef = React.useRef(); const canQuery = loggedIn && !!fid; React.useEffect(() => { if (canQuery === prevCanQueryRef.current) { return; } prevCanQueryRef.current = canQuery; - if (!canQuery || !isActive || !fid || !neynarClient) { + if (!canQuery || !isActive || !fid || !fcCache) { return; } void (async () => { const authMetadataPromise: Promise = (async () => { if (!usingCommServicesAccessToken) { return undefined; } return await getAuthMetadata(); })(); const followedFarcasterChannelsPromise = - neynarClient.fetchFollowedFarcasterChannels(fid); + fcCache.getFollowedFarcasterChannelsForFID(fid); const [authMetadata, followedFarcasterChannels] = await Promise.all([ authMetadataPromise, followedFarcasterChannelsPromise, ]); + if (!followedFarcasterChannels) { + return; + } + const headers = authMetadata ? createDefaultHTTPRequestHeaders(authMetadata) : {}; const followedFarcasterChannelIDs = followedFarcasterChannels.map( channel => channel.id, ); const promises: { [string]: Promise>, } = {}; for (const channelID of followedFarcasterChannelIDs) { promises[channelID] = (async () => { const blobHash = farcasterChannelTagBlobHash(channelID); const blobURL = getBlobFetchableURL(blobHash); const blobResult = await fetch(blobURL, { method: blobService.httpEndpoints.GET_BLOB.method, headers, }); if (blobResult.status !== 200) { return null; } const { commCommunityID, keyserverURL } = await blobResult.json(); const keyserverID = extractKeyserverIDFromID(commCommunityID); // The user is already in the community if (threadInfos[commCommunityID]) { return null; } const keyserverOverride = !keyserverInfos[keyserverID] ? { keyserverID, keyserverURL: keyserverURL.replace(/\/$/, ''), } : null; return { communityID: commCommunityID, keyserverOverride, joinStatus: 'inactive', }; })(); } const communitiesObj = await promiseAll(promises); const filteredCommunitiesObj = _pickBy(Boolean)(communitiesObj); const communityDatas: { ...CommunityDatas } = {}; let i = 0; for (const key in filteredCommunitiesObj) { const communityObject = filteredCommunitiesObj[key]; const communityID = communityObject.communityID; communityDatas[communityID] = { ...communityObject, batch: Math.floor(i++ / 5), }; } baseSetCommunitiesToAutoJoin({ communityDatas, curBatch: 0 }); })(); }, [ threadInfos, fid, isActive, - neynarClient, + fcCache, getAuthMetadata, keyserverInfos, canQuery, ]); const potentiallyIncrementBatch: ( ?CommunitiesToAutoJoin, ) => ?CommunitiesToAutoJoin = React.useCallback(input => { if (!input) { return input; } let shouldIncrementBatch = false; const { curBatch, communityDatas } = input; for (const communityToAutoJoin of Object.values(communityDatas)) { const { batch, joinStatus } = communityToAutoJoin; if (batch !== curBatch) { continue; } if (joinStatus !== 'joined') { // One of the current batch isn't complete yet return input; } // We have at least one complete in the current batch shouldIncrementBatch = true; } // If we get here, all of the current batch is complete if (shouldIncrementBatch) { return { communityDatas, curBatch: curBatch + 1 }; } return input; }, []); const setCommunitiesToAutoJoin: SetState = React.useCallback( next => { if (typeof next !== 'function') { baseSetCommunitiesToAutoJoin(potentiallyIncrementBatch(next)); return; } baseSetCommunitiesToAutoJoin(prev => { const result = next(prev); return potentiallyIncrementBatch(result); }); }, [potentiallyIncrementBatch], ); const joinHandlers = React.useMemo(() => { if (!communitiesToAutoJoin) { return null; } const { curBatch, communityDatas } = communitiesToAutoJoin; return Object.values(communityDatas).map(communityData => { const { batch, communityID, keyserverOverride, joinStatus } = communityData; if (batch !== curBatch || joinStatus === 'joined') { return null; } return ( ); }); }, [calendarQuery, communitiesToAutoJoin, setCommunitiesToAutoJoin]); return joinHandlers; } type JoinHandlerProps = { +communityID: string, +keyserverOverride: ?KeyserverOverride, +calendarQuery: () => CalendarQuery, +joinStatus: JoinStatus, +setCommunitiesToAutoJoin: SetState, }; function JoinHandler(props: JoinHandlerProps) { const { communityID, keyserverOverride, calendarQuery, joinStatus, setCommunitiesToAutoJoin, } = props; const [ongoingJoinData, setOngoingJoinData] = React.useState(null); const [step, setStep] = React.useState('inactive'); const joinCommunity = useJoinCommunity({ communityID, keyserverOverride, calendarQuery, ongoingJoinData, setOngoingJoinData, step, setStep, defaultSubscription: defaultThreadSubscription, }); const setJoinStatus = React.useCallback( (newJoinStatus: JoinStatus) => { setCommunitiesToAutoJoin(prev => { if (!prev) { return null; } return { ...prev, communityDatas: { ...prev.communityDatas, [communityID]: { ...prev.communityDatas[communityID], joinStatus: newJoinStatus, }, }, }; }); }, [communityID, setCommunitiesToAutoJoin], ); React.useEffect(() => { if (joinStatus !== 'inactive') { return; } void (async () => { try { setJoinStatus('joining'); await sleep(1000); await joinCommunity(); } finally { setJoinStatus('joined'); } })(); }, [joinStatus, communityID, setJoinStatus, joinCommunity]); return null; } export { BaseAutoJoinCommunityHandler }; diff --git a/lib/utils/fc-cache.js b/lib/utils/fc-cache.js index 5e7be2c47..cefc533d3 100644 --- a/lib/utils/fc-cache.js +++ b/lib/utils/fc-cache.js @@ -1,241 +1,332 @@ // @flow import { NeynarClient, type FarcasterUser } from './neynar-client.js'; import sleep from './sleep.js'; import type { NeynarChannel } from '../types/farcaster-types.js'; const cacheTimeout = 24 * 60 * 60 * 1000; // one day const failedQueryCacheTimeout = 5 * 60 * 1000; // five minutes const queryTimeout = 10 * 1000; // ten seconds async function throwOnTimeout(identifier: string) { await sleep(queryTimeout); throw new Error(`Farcaster fetch for ${identifier} timed out`); } type FarcasterUsernameQueryCacheEntry = { +fid: string, +expirationTime: number, +farcasterUser: ?FarcasterUser | Promise, }; type FarcasterChannelQueryCacheEntry = { +channelID: string, +expirationTime: number, +farcasterChannel: ?NeynarChannel | Promise, }; +type FollowedFarcasterChannelsQueryCacheEntry = { + +fid: string, + +expirationTime: number, + +followedFarcasterChannels: + | ?$ReadOnlyArray + | Promise>, +}; + class FCCache { client: NeynarClient; // Maps from FIDs to a cache entry for its Farcaster user farcasterUsernameQueryCache: Map = new Map(); // Maps from Farcaster channel IDs to a cache entry for the channel's info farcasterChannelQueryCache: Map = new Map(); + // Maps from FIDs to a cache entry for the Farcaster user's followed channels + followedFarcasterChannelsQueryCache: Map< + string, + FollowedFarcasterChannelsQueryCacheEntry, + > = new Map(); + constructor(client: NeynarClient) { this.client = client; } getFarcasterUsersForFIDs( fids: $ReadOnlyArray, ): Promise> { const cacheMatches = fids.map(fid => this.getCachedFarcasterUserEntryForFID(fid), ); const cacheResultsPromise = Promise.all( cacheMatches.map(match => Promise.resolve(match ? match.farcasterUser : match), ), ); if (cacheMatches.every(Boolean)) { return cacheResultsPromise; } const needFetch = []; for (let i = 0; i < fids.length; i++) { const fid = fids[i]; const cacheMatch = cacheMatches[i]; if (!cacheMatch) { needFetch.push(fid); } } const fetchFarcasterUsersPromise = (async () => { let farcasterUsers: $ReadOnlyArray; try { farcasterUsers = await Promise.race([ this.client.getFarcasterUsers(needFetch), throwOnTimeout(`users for ${JSON.stringify(needFetch)}`), ]); } catch (e) { console.log(e); farcasterUsers = new Array(needFetch.length).fill(null); } const resultMap = new Map(); for (let i = 0; i < needFetch.length; i++) { const fid = needFetch[i]; const farcasterUser = farcasterUsers[i]; resultMap.set(fid, farcasterUser); } return resultMap; })(); for (let i = 0; i < needFetch.length; i++) { const fid = needFetch[i]; const fetchFarcasterUserPromise = (async () => { const resultMap = await fetchFarcasterUsersPromise; return resultMap.get(fid) ?? null; })(); this.farcasterUsernameQueryCache.set(fid, { fid, expirationTime: Date.now() + queryTimeout * 2, farcasterUser: fetchFarcasterUserPromise, }); } return (async () => { const [resultMap, cacheResults] = await Promise.all([ fetchFarcasterUsersPromise, cacheResultsPromise, ]); for (let i = 0; i < needFetch.length; i++) { const fid = needFetch[i]; const farcasterUser = resultMap.get(fid); const timeout = farcasterUser === null ? failedQueryCacheTimeout : cacheTimeout; this.farcasterUsernameQueryCache.set(fid, { fid, expirationTime: Date.now() + timeout, farcasterUser, }); } const results = []; for (let i = 0; i < fids.length; i++) { const cachedResult = cacheResults[i]; if (cachedResult) { results.push(cachedResult); } else { results.push(resultMap.get(fids[i])); } } return results; })(); } getCachedFarcasterUserEntryForFID( fid: string, ): ?FarcasterUsernameQueryCacheEntry { const cacheResult = this.farcasterUsernameQueryCache.get(fid); if (!cacheResult) { return undefined; } const { expirationTime } = cacheResult; if (expirationTime <= Date.now()) { this.farcasterUsernameQueryCache.delete(fid); return undefined; } return cacheResult; } getCachedFarcasterUserForFID(fid: string): ?FarcasterUser { const cacheResult = this.getCachedFarcasterUserEntryForFID(fid); if (!cacheResult) { return undefined; } const { farcasterUser } = cacheResult; if ( typeof farcasterUser !== 'object' || farcasterUser instanceof Promise || !farcasterUser ) { return undefined; } return farcasterUser; } getFarcasterChannelForChannelID(channelID: string): Promise { const cachedChannelEntry = this.getCachedFarcasterChannelEntryForChannelID(channelID); if (cachedChannelEntry) { return Promise.resolve(cachedChannelEntry.farcasterChannel); } const fetchFarcasterChannelPromise = (async () => { let farcasterChannel; try { farcasterChannel = await Promise.race([ this.client.fetchFarcasterChannelByID(channelID), throwOnTimeout(`channel for ${channelID}`), ]); } catch (e) { console.log(e); return null; } this.farcasterChannelQueryCache.set(channelID, { channelID, expirationTime: Date.now() + cacheTimeout, farcasterChannel, }); return farcasterChannel; })(); this.farcasterChannelQueryCache.set(channelID, { channelID, expirationTime: Date.now() + queryTimeout * 2, farcasterChannel: fetchFarcasterChannelPromise, }); return fetchFarcasterChannelPromise; } getCachedFarcasterChannelEntryForChannelID( channelID: string, ): ?FarcasterChannelQueryCacheEntry { const cacheResult = this.farcasterChannelQueryCache.get(channelID); if (!cacheResult) { return undefined; } const { expirationTime } = cacheResult; if (expirationTime <= Date.now()) { this.farcasterUsernameQueryCache.delete(channelID); return undefined; } return cacheResult; } getCachedFarcasterChannelForChannelID(channelID: string): ?NeynarChannel { const cacheResult = this.getCachedFarcasterChannelEntryForChannelID(channelID); if (!cacheResult) { return undefined; } const { farcasterChannel } = cacheResult; if ( typeof farcasterChannel !== 'object' || farcasterChannel instanceof Promise || !farcasterChannel ) { return undefined; } return farcasterChannel; } + + getFollowedFarcasterChannelsForFID( + fid: string, + ): Promise> { + const cachedChannelEntry = + this.getCachedFollowedFarcasterChannelsEntryForFID(fid); + + if (cachedChannelEntry) { + return Promise.resolve(cachedChannelEntry.followedFarcasterChannels); + } + + const fetchFollowedFarcasterChannelsPromise = (async () => { + let followedFarcasterChannels; + try { + followedFarcasterChannels = await Promise.race([ + this.client.fetchFollowedFarcasterChannels(fid), + throwOnTimeout(`followed channels for ${fid}`), + ]); + } catch (e) { + console.log(e); + return null; + } + + this.followedFarcasterChannelsQueryCache.set(fid, { + fid, + expirationTime: Date.now() + cacheTimeout, + followedFarcasterChannels, + }); + + return followedFarcasterChannels; + })(); + + this.followedFarcasterChannelsQueryCache.set(fid, { + fid, + expirationTime: Date.now() + queryTimeout * 2, + followedFarcasterChannels: fetchFollowedFarcasterChannelsPromise, + }); + + return fetchFollowedFarcasterChannelsPromise; + } + + getCachedFollowedFarcasterChannelsEntryForFID( + fid: string, + ): ?FollowedFarcasterChannelsQueryCacheEntry { + const cacheResult = this.followedFarcasterChannelsQueryCache.get(fid); + if (!cacheResult) { + return undefined; + } + + const { expirationTime } = cacheResult; + if (expirationTime <= Date.now()) { + this.followedFarcasterChannelsQueryCache.delete(fid); + return undefined; + } + + return cacheResult; + } + + getCachedFollowedFarcasterChannelsForFID( + fid: string, + ): ?$ReadOnlyArray { + const cacheResult = this.getCachedFollowedFarcasterChannelsEntryForFID(fid); + if (!cacheResult) { + return undefined; + } + + const { followedFarcasterChannels } = cacheResult; + if ( + typeof followedFarcasterChannels !== 'object' || + followedFarcasterChannels instanceof Promise || + !followedFarcasterChannels + ) { + return undefined; + } + + return followedFarcasterChannels; + } } export { FCCache }; diff --git a/native/community-settings/tag-farcaster-channel/tag-channel-button.react.js b/native/community-settings/tag-farcaster-channel/tag-channel-button.react.js index fb49f9adf..7e31482a2 100644 --- a/native/community-settings/tag-farcaster-channel/tag-channel-button.react.js +++ b/native/community-settings/tag-farcaster-channel/tag-channel-button.react.js @@ -1,164 +1,169 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import { useNavigation } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { Text, View, Platform, ActivityIndicator } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { NeynarClientContext } from 'lib/components/neynar-client-provider.react.js'; import { useCreateFarcasterChannelTag } from 'lib/shared/community-utils.js'; import type { NeynarChannel } from 'lib/types/farcaster-types.js'; import type { SetState } from 'lib/types/hook-types.js'; import { useCurrentUserFID } from 'lib/utils/farcaster-utils.js'; import Button from '../../components/button.react.js'; import { TagFarcasterChannelByNameRouteName } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles, useColors } from '../../themes/colors.js'; type Props = { +communityID: string, +setError: SetState, }; function TagChannelButton(props: Props): React.Node { const { communityID, setError } = props; const { navigate } = useNavigation(); const colors = useColors(); const styles = useStyles(unboundStyles); const fid = useCurrentUserFID(); invariant(fid, 'FID should be set'); const [channelOptions, setChannelOptions] = React.useState< $ReadOnlyArray, >([]); const neynarClientContext = React.useContext(NeynarClientContext); invariant(neynarClientContext, 'NeynarClientContext is missing'); - const { client } = neynarClientContext; + const { fcCache } = neynarClientContext; React.useEffect(() => { void (async () => { - const channels = await client.fetchFollowedFarcasterChannels(fid); + const channels = await fcCache.getFollowedFarcasterChannelsForFID(fid); + if (!channels) { + return; + } - const sortedChannels = channels.sort((a, b) => a.id.localeCompare(b.id)); + const sortedChannels = [...channels].sort((a, b) => + a.id.localeCompare(b.id), + ); setChannelOptions(sortedChannels); })(); - }, [client, fid]); + }, [fcCache, fid]); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const { showActionSheetWithOptions } = useActionSheet(); const insets = useSafeAreaInsets(); const { createTag, isLoading } = useCreateFarcasterChannelTag( communityID, setError, ); const onOptionSelected = React.useCallback( (selectedIndex: ?number) => { if ( selectedIndex === undefined || selectedIndex === null || selectedIndex > channelOptions.length ) { return; } setError(null); // This is the "Other" option if (selectedIndex === 0) { navigate<'TagFarcasterChannelByName'>({ name: TagFarcasterChannelByNameRouteName, params: { communityID }, }); return; } const channel = channelOptions[selectedIndex - 1]; createTag(channel.id); }, [channelOptions, communityID, createTag, navigate, setError], ); const onPressTag = React.useCallback(() => { const channelNames = [ 'Other', ...channelOptions.map(channel => `/${channel.id}`), ]; const options = Platform.OS === 'ios' ? [...channelNames, 'Cancel'] : channelNames; const cancelButtonIndex = Platform.OS === 'ios' ? options.length - 1 : -1; const containerStyle = { paddingBottom: insets.bottom, }; showActionSheetWithOptions( { options, cancelButtonIndex, containerStyle, userInterfaceStyle: activeTheme ?? 'dark', }, onOptionSelected, ); }, [ activeTheme, channelOptions, insets.bottom, onOptionSelected, showActionSheetWithOptions, ]); const buttonContent = React.useMemo(() => { if (isLoading) { return ( ); } return Tag channel; }, [colors.panelForegroundLabel, isLoading, styles.buttonText]); return ( ); } const unboundStyles = { button: { borderRadius: 8, paddingVertical: 12, paddingHorizontal: 24, backgroundColor: 'purpleButton', }, buttonText: { color: 'whiteText', textAlign: 'center', fontWeight: '500', fontSize: 16, lineHeight: 24, }, buttonContainer: { height: 24, }, }; export default TagChannelButton; diff --git a/web/tag-farcaster-channel/create-farcaster-channel-tag-modal.react.js b/web/tag-farcaster-channel/create-farcaster-channel-tag-modal.react.js index c95b0c644..f9e8d3efb 100644 --- a/web/tag-farcaster-channel/create-farcaster-channel-tag-modal.react.js +++ b/web/tag-farcaster-channel/create-farcaster-channel-tag-modal.react.js @@ -1,182 +1,185 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { NeynarClientContext } from 'lib/components/neynar-client-provider.react.js'; import { tagFarcasterChannelErrorMessages, useCreateFarcasterChannelTag, } from 'lib/shared/community-utils.js'; import { useCurrentUserFID } from 'lib/utils/farcaster-utils.js'; import css from './create-farcaster-channel-tag-modal.css'; import Button, { buttonThemes } from '../components/button.react.js'; import Dropdown, { type DropdownOption } from '../components/dropdown.react.js'; import Input from '../modals/input.react.js'; import Modal from '../modals/modal.react.js'; type Props = { +communityID: string, }; function CreateFarcasterChannelTagModal(props: Props): React.Node { const { communityID } = props; const { popModal } = useModalContext(); const fid = useCurrentUserFID(); invariant(fid, 'FID should be set'); const neynarClientContext = React.useContext(NeynarClientContext); invariant(neynarClientContext, 'NeynarClientContext is missing'); - const { client, fcCache } = neynarClientContext; + const { fcCache } = neynarClientContext; const [channelOptions, setChannelOptions] = React.useState< $ReadOnlyArray, >([]); const [selectedOption, setSelectedOption] = React.useState(null); const [channelNameText, setChannelNameText] = React.useState(''); const [error, setError] = React.useState(null); React.useEffect(() => { void (async () => { - const channels = await client.fetchFollowedFarcasterChannels(fid); + const channels = await fcCache.getFollowedFarcasterChannelsForFID(fid); + if (!channels) { + return; + } - const sortedChannels = channels + const sortedChannels = [...channels] .sort((a, b) => a.id.localeCompare(b.id)) .map(channel => ({ id: channel.id, name: `/${channel.id}`, })); const options = [{ id: 'other', name: 'Other' }, ...sortedChannels]; setChannelOptions(options); })(); - }, [client, fid]); + }, [fcCache, fid]); const onChangeSelectedOption = React.useCallback((option: string) => { setError(null); setChannelNameText(''); setSelectedOption(option); }, []); const onChangeChannelNameText = React.useCallback( (event: SyntheticEvent) => { setChannelNameText(event.currentTarget.value); }, [], ); const { createTag, isLoading } = useCreateFarcasterChannelTag( communityID, setError, popModal, ); const onClickTagChannel = React.useCallback(async () => { if (!selectedOption) { return; } if (selectedOption !== 'other') { createTag(selectedOption); return; } const channelInfo = await fcCache.getFarcasterChannelForChannelID(channelNameText); if (!channelInfo) { setError('channel_not_found'); return; } createTag(channelInfo.id); }, [channelNameText, createTag, fcCache, selectedOption]); const buttonDisabled = isLoading || !selectedOption || (selectedOption === 'other' && !channelNameText); const primaryButton = React.useMemo(() => { return ( ); }, [onClickTagChannel, buttonDisabled]); const tagFarcasterChannelByNameContainerClassName = classNames( css.tagFarcasterChannelByNameContainer, { [css.tagFarcasterChannelByNameContainerVisible]: selectedOption === 'other', }, ); const errorMessageClassName = classNames(css.errorMessage, { [css.errorMessageVisible]: error, }); const errorMessage = error && tagFarcasterChannelErrorMessages[error] ? tagFarcasterChannelErrorMessages[error] : 'Unknown error.'; const createFarcasterChannelTagModal = React.useMemo( () => (
Farcaster channel
Channel name
{errorMessage}
), [ channelNameText, channelOptions, errorMessage, errorMessageClassName, onChangeChannelNameText, onChangeSelectedOption, popModal, primaryButton, selectedOption, tagFarcasterChannelByNameContainerClassName, ], ); return createFarcasterChannelTagModal; } export default CreateFarcasterChannelTagModal;