diff --git a/keyserver/src/creators/farcaster-channel-tag-creator.js b/keyserver/src/creators/farcaster-channel-tag-creator.js index 207fe5c9f..76e5d912c 100644 --- a/keyserver/src/creators/farcaster-channel-tag-creator.js +++ b/keyserver/src/creators/farcaster-channel-tag-creator.js @@ -1,254 +1,254 @@ // @flow import t, { type TInterface } from 'tcomb'; import uuid from 'uuid'; import { farcasterChannelTagBlobHash } from 'lib/shared/community-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { CreateOrUpdateFarcasterChannelTagRequest, CreateOrUpdateFarcasterChannelTagResponse, } from 'lib/types/community-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { BlobOperationResult } from 'lib/utils/blob-service.js'; import { ServerError } from 'lib/utils/errors.js'; import { tShape } from 'lib/utils/validation-utils.js'; import { dbQuery, SQL, MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE, } from '../database/database.js'; import { fetchCommunityInfos } from '../fetchers/community-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { uploadBlobKeyserverWrapper, assignHolder, download, deleteBlob, type BlobDownloadResult, } from '../services/blob.js'; import { Viewer } from '../session/viewer.js'; import { updateThread } from '../updaters/thread-updaters.js'; import { thisKeyserverID } from '../user/identity.js'; -import { neynarClient } from '../utils/fc-cache.js'; +import { fcCache } from '../utils/fc-cache.js'; import { getAndAssertKeyserverURLFacts } from '../utils/urls.js'; async function createOrUpdateFarcasterChannelTag( viewer: Viewer, request: CreateOrUpdateFarcasterChannelTagRequest, ): Promise { const permissionPromise = checkThreadPermission( viewer, request.commCommunityID, threadPermissions.MANAGE_FARCASTER_CHANNEL_TAGS, ); const [hasPermission, communityInfos, blobDownload, keyserverID] = await Promise.all([ permissionPromise, fetchCommunityInfos(viewer, [request.commCommunityID]), getFarcasterChannelTagBlob(request.farcasterChannelID), thisKeyserverID(), ]); if (!hasPermission) { throw new ServerError('invalid_credentials'); } if (communityInfos.length !== 1) { throw new ServerError('invalid_parameters'); } if (blobDownload.found) { throw new ServerError('already_in_use'); } const communityID = `${keyserverID}|${request.commCommunityID}`; const blobHolder = uuid.v4(); const blobResult = await uploadFarcasterChannelTagBlob( communityID, request.farcasterChannelID, blobHolder, ); if (!blobResult.success) { if (blobResult.reason === 'HASH_IN_USE') { throw new ServerError('already_in_use'); } else { throw new ServerError('unknown_error'); } } const query = SQL` START TRANSACTION; SELECT farcaster_channel_id, blob_holder INTO @currentFarcasterChannelID, @currentBlobHolder FROM communities WHERE id = ${request.commCommunityID} FOR UPDATE; UPDATE communities SET farcaster_channel_id = ${request.farcasterChannelID}, blob_holder = ${blobHolder} WHERE id = ${request.commCommunityID}; COMMIT; SELECT @currentFarcasterChannelID AS oldFarcasterChannelID, @currentBlobHolder AS oldBlobHolder; `; try { const [transactionResult] = await dbQuery(query, { multipleStatements: true, }); const selectResult = transactionResult.pop(); const [{ oldFarcasterChannelID, oldBlobHolder }] = selectResult; if (oldFarcasterChannelID && oldBlobHolder) { await deleteBlob( { hash: farcasterChannelTagBlobHash(oldFarcasterChannelID), holder: oldBlobHolder, }, true, ); } } catch (error) { await deleteBlob( { hash: farcasterChannelTagBlobHash(request.farcasterChannelID), holder: blobHolder, }, true, ); if (error.errno === MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('already_in_use'); } throw new ServerError('invalid_parameters'); } const neynarChannelDescriptionPromise = (async () => { - if (!neynarClient) { + if (!fcCache) { return ''; } - const channelInfo = await neynarClient?.fetchFarcasterChannelByID( + const channelInfo = await fcCache?.getFarcasterChannelForChannelID( request.farcasterChannelID, ); return channelInfo?.description ?? ''; })(); const [fcChannelDescription, serverThreadInfos] = await Promise.all([ neynarChannelDescriptionPromise, fetchServerThreadInfos({ threadID: request.commCommunityID }), ]); const threadInfo = serverThreadInfos.threadInfos[request.commCommunityID]; if (!threadInfo) { return { commCommunityID: request.commCommunityID, farcasterChannelID: request.farcasterChannelID, }; } const { avatar, description } = threadInfo; if (avatar && description) { return { commCommunityID: request.commCommunityID, farcasterChannelID: request.farcasterChannelID, }; } let changes = {}; if (!avatar) { changes = { ...changes, avatar: { type: 'farcaster' } }; } if (!description) { changes = { ...changes, description: fcChannelDescription }; } const changeThreadSettingsResult = await updateThread(viewer, { threadID: request.commCommunityID, changes, }); if ( !hasMinCodeVersion(viewer.platformDetails, { native: 433, web: 138, }) ) { return { commCommunityID: request.commCommunityID, farcasterChannelID: request.farcasterChannelID, }; } return { commCommunityID: request.commCommunityID, farcasterChannelID: request.farcasterChannelID, ...changeThreadSettingsResult, }; } function getFarcasterChannelTagBlob( secret: string, ): Promise { const hash = farcasterChannelTagBlobHash(secret); return download(hash); } const farcasterChannelTagBlobValidator: TInterface = tShape({ commCommunityID: t.String, farcasterChannelID: t.String, keyserverURL: t.String, }); type FarcasterChannelTagBlob = { +commCommunityID: string, +farcasterChannelID: string, +keyserverURL: string, }; async function uploadFarcasterChannelTagBlob( commCommunityID: string, farcasterChannelID: string, holder: string, ): Promise { const { baseDomain, basePath } = getAndAssertKeyserverURLFacts(); const keyserverURL = baseDomain + basePath; const payload: FarcasterChannelTagBlob = { commCommunityID, farcasterChannelID, keyserverURL, }; const payloadString = JSON.stringify(payload); const hash = farcasterChannelTagBlobHash(farcasterChannelID); const blob = new Blob([payloadString]); const uploadResult = await uploadBlobKeyserverWrapper(blob, hash); if (!uploadResult.success) { return uploadResult; } return await assignHolder({ holder, hash }); } export { createOrUpdateFarcasterChannelTag, uploadFarcasterChannelTagBlob, getFarcasterChannelTagBlob, farcasterChannelTagBlobValidator, }; diff --git a/keyserver/src/responders/farcaster-webhook-responders.js b/keyserver/src/responders/farcaster-webhook-responders.js index 69530797b..209de0083 100644 --- a/keyserver/src/responders/farcaster-webhook-responders.js +++ b/keyserver/src/responders/farcaster-webhook-responders.js @@ -1,337 +1,337 @@ // @flow import { createHmac } from 'crypto'; import type { $Request } from 'express'; import invariant from 'invariant'; import bots from 'lib/facts/bots.js'; import { inviteLinkURL } from 'lib/facts/links.js'; import { extractKeyserverIDFromID } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { createSidebarThreadName } from 'lib/shared/sidebar-utils.js'; import { type NeynarWebhookCastCreatedEvent } from 'lib/types/farcaster-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type NewThreadResponse } from 'lib/types/thread-types.js'; import { neynarWebhookCastCreatedEventValidator } from 'lib/types/validators/farcaster-webhook-validators.js'; import { ServerError } from 'lib/utils/errors.js'; import { assertWithValidator } from 'lib/utils/validation-utils.js'; import { getFarcasterChannelTagBlob, createOrUpdateFarcasterChannelTag, farcasterChannelTagBlobValidator, } from '../creators/farcaster-channel-tag-creator.js'; import { createOrUpdatePublicLink } from '../creators/invite-link-creator.js'; import createMessages from '../creators/message-creator.js'; import { createThread } from '../creators/thread-creator.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { createBotViewer } from '../session/bots.js'; import { createScriptViewer } from '../session/scripts.js'; import { changeRole, commitMembershipChangeset, } from '../updaters/thread-permission-updaters.js'; import { updateRole, joinThread } from '../updaters/thread-updaters.js'; import { thisKeyserverAdmin, thisKeyserverID } from '../user/identity.js'; import { getFarcasterBotConfig } from '../utils/farcaster-bot.js'; import { getVerifiedUserIDForFID } from '../utils/farcaster-utils.js'; -import { neynarClient } from '../utils/fc-cache.js'; +import { neynarClient, fcCache } from '../utils/fc-cache.js'; import { getNeynarConfig } from '../utils/neynar-utils.js'; const taggedCommFarcasterInputValidator = neynarWebhookCastCreatedEventValidator; const threadHashTagRegex = /\B#createathread\b/i; const noChannelCommunityID = '80887273'; const { commbot } = bots; const commbotViewer = createBotViewer(commbot.userID); async function createCastSidebar( sidebarCastHash: string, channelFarcasterID: ?string, channelCommunityID: string, taggerUserID: ?string, ): Promise { const sidebarCast = await neynarClient?.fetchFarcasterCastByHash(sidebarCastHash); if (!sidebarCast) { return null; } const { author: { username: castAuthor }, text: castText, } = sidebarCast; const warpcastLink = `https://warpcast.com/${castAuthor}/${sidebarCastHash}`; const saidText = `[said](${warpcastLink})`; const channelText = channelFarcasterID ? ` in channel /${channelFarcasterID}` : ''; const quoteText = castText .split('\n') .map(line => `> ${line}`) .join('\n'); const messageText = `${castAuthor} ${saidText}${channelText}:\n${quoteText}`; let viewer = commbotViewer; if (taggerUserID) { viewer = createScriptViewer(taggerUserID); await joinThread(viewer, { threadID: channelCommunityID, }); } else { const changeset = await changeRole( channelCommunityID, [commbot.userID], -1, ); await commitMembershipChangeset(viewer, changeset); } const [{ id: messageID }] = await createMessages(viewer, [ { type: messageTypes.TEXT, threadID: channelCommunityID, creatorID: viewer.id, time: Date.now(), text: messageText, }, ]); invariant( messageID, 'message returned from createMessages always has ID set', ); const sidebarThreadName = createSidebarThreadName(messageText); const response = await createThread( viewer, { type: threadTypes.SIDEBAR, parentThreadID: channelCommunityID, name: sidebarThreadName, sourceMessageID: messageID, }, { addViewerAsGhost: !taggerUserID, }, ); return response; } async function createTaggedFarcasterCommunity( channelID: string, taggerUserID: ?string, ): Promise { const keyserverAdminPromise = thisKeyserverAdmin(); const neynarChannel = - await neynarClient?.fetchFarcasterChannelByID(channelID); + await fcCache?.getFarcasterChannelForChannelID(channelID); if (!neynarChannel) { throw new ServerError('channel_not_found'); } const leadFID = neynarChannel.lead.fid.toString(); const [leadUserID, keyserverAdmin] = await Promise.all([ getVerifiedUserIDForFID(leadFID), keyserverAdminPromise, ]); const communityAdminID = leadUserID ? leadUserID : taggerUserID || keyserverAdmin.id; const initialMemberIDs = [ ...new Set([leadUserID, taggerUserID, communityAdminID].filter(Boolean)), ]; const newThreadResponse = await createThread( commbotViewer, { type: threadTypes.COMMUNITY_ROOT, name: neynarChannel.name, initialMemberIDs, }, { forceAddMembers: true, addViewerAsGhost: true, }, ); const { newThreadID } = newThreadResponse; const fetchThreadResult = await fetchServerThreadInfos({ threadID: newThreadResponse.newThreadID, }); const threadInfo = fetchThreadResult.threadInfos[newThreadID]; if (!threadInfo) { throw new ServerError('fetch_failed'); } const adminRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].name === 'Admins', ); if (!adminRoleID) { throw new ServerError('community_missing_admin'); } await updateRole(commbotViewer, { threadID: newThreadID, memberIDs: [communityAdminID], role: adminRoleID, }); await createOrUpdateFarcasterChannelTag(commbotViewer, { commCommunityID: newThreadResponse.newThreadID, farcasterChannelID: channelID, }); return newThreadResponse; } async function verifyNeynarWebhookSignature( signature: string, event: NeynarWebhookCastCreatedEvent, ): Promise { const neynarSecret = await getNeynarConfig(); if (!neynarSecret?.neynarWebhookSecret) { throw new ServerError('missing_webhook_secret'); } const hmac = createHmac('sha512', neynarSecret.neynarWebhookSecret); hmac.update(JSON.stringify(event)); return hmac.digest('hex') === signature; } async function taggedCommFarcasterResponder(req: $Request): Promise { const { body } = req; const event = assertWithValidator(body, taggedCommFarcasterInputValidator); const { author: { fid: eventTaggerFID }, text: eventText, channel: eventChannel, } = event.data; const foundCreateThreadHashTag = threadHashTagRegex.test(eventText); if (!foundCreateThreadHashTag) { return; } const neynarConfigPromise = getNeynarConfig(); const taggerUserIDPromise = getVerifiedUserIDForFID( eventTaggerFID.toString(), ); const signature = req.header('X-Neynar-Signature'); if (!signature) { throw new ServerError('missing_neynar_signature'); } const [isValidSignature, farcasterBotConfig] = await Promise.all([ verifyNeynarWebhookSignature(signature, event), getFarcasterBotConfig(), ]); if (!isValidSignature) { throw new ServerError('invalid_webhook_signature'); } const eventChannelID = eventChannel?.id; const isAuthoritativeFarcasterBot = farcasterBotConfig.authoritativeFarcasterBot; if (!eventChannelID && !isAuthoritativeFarcasterBot) { return; } let channelCommunityID = noChannelCommunityID; if (eventChannelID) { const blobDownload = await getFarcasterChannelTagBlob(eventChannelID); if (blobDownload.found) { const blobText = await blobDownload.blob.text(); const blobObject = JSON.parse(blobText); const farcasterChannelTagBlob = assertWithValidator( blobObject, farcasterChannelTagBlobValidator, ); const { commCommunityID } = farcasterChannelTagBlob; const commCommunityKeyserverID = extractKeyserverIDFromID(commCommunityID); const keyserverID = await thisKeyserverID(); if (keyserverID !== commCommunityKeyserverID) { return; } channelCommunityID = commCommunityID.split('|')[1]; } else if (!blobDownload.found && blobDownload.status === 404) { if (!isAuthoritativeFarcasterBot) { return; } const taggerUserID = await taggerUserIDPromise; const newThreadResponse = await createTaggedFarcasterCommunity( eventChannelID, taggerUserID, ); channelCommunityID = newThreadResponse.newThreadID; } else { throw new ServerError('blob_fetch_failed'); } } const { hash: castHash, parent_hash: parentHash } = event.data; const taggerUserID = await taggerUserIDPromise; // we use the parent cast to create the sidebar source message if it exists const sidebarCastHash = parentHash ?? castHash; const sidebarThreadResponse = await createCastSidebar( sidebarCastHash, eventChannel?.id, channelCommunityID, taggerUserID, ); if (!sidebarThreadResponse) { return; } const inviteLinkName = Math.random().toString(36).slice(-9); const [inviteLink, neynarConfig] = await Promise.all([ createOrUpdatePublicLink(commbotViewer, { name: inviteLinkName, communityID: channelCommunityID, threadID: sidebarThreadResponse.newThreadID, }), neynarConfigPromise, ]); const introText = 'I created a thread on Comm. Join the conversation here:'; const replyText = `${introText} ${inviteLinkURL(inviteLink.name)}`; if (!neynarConfig?.signerUUID) { throw new ServerError('missing_signer_uuid'); } const postCastResponse = await neynarClient?.postCast( neynarConfig.signerUUID, castHash, replyText, ); if (!postCastResponse?.success) { throw new ServerError('post_cast_failed'); } } export { taggedCommFarcasterResponder, taggedCommFarcasterInputValidator }; diff --git a/keyserver/src/updaters/thread-updaters.js b/keyserver/src/updaters/thread-updaters.js index f5e87006d..d11d3d35c 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 } from '../utils/fc-cache.js'; +import { neynarClient, 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); if (followedChannels) { await Promise.allSettled( followedChannels.map(followedChannel => redisCache.setChannelInfo(followedChannel.id, followedChannel), ), ); } })(), ); - const channelInfo = await neynarClient?.fetchFarcasterChannelByID( + 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/keyserver/src/utils/fc-cache.js b/keyserver/src/utils/fc-cache.js index 2a3c22ebc..34f251c5a 100644 --- a/keyserver/src/utils/fc-cache.js +++ b/keyserver/src/utils/fc-cache.js @@ -1,28 +1,30 @@ // @flow import { getFCNames as baseGetFCNames, type GetFCNames, type BaseFCNamesInfo, } from 'lib/utils/farcaster-helpers.js'; import { FCCache } from 'lib/utils/fc-cache.js'; import { NeynarClient } from 'lib/utils/neynar-client.js'; import { getNeynarConfig } from './neynar-utils.js'; -let getFCNames: ?GetFCNames; let neynarClient: ?NeynarClient; +let fcCache: ?FCCache; +let getFCNames: ?GetFCNames; async function initFCCache() { const neynarSecret = await getNeynarConfig(); const neynarKey = neynarSecret?.key; if (!neynarKey) { return; } neynarClient = new NeynarClient(neynarKey); - const fcCache = new FCCache(neynarClient); + const newFCCache = new FCCache(neynarClient); + fcCache = newFCCache; getFCNames = (users: $ReadOnlyArray): Promise => - baseGetFCNames(fcCache, users); + baseGetFCNames(newFCCache, users); } -export { initFCCache, getFCNames, neynarClient }; +export { initFCCache, neynarClient, fcCache, getFCNames }; diff --git a/native/community-settings/tag-farcaster-channel/tag-farcaster-channel-by-name.react.js b/native/community-settings/tag-farcaster-channel/tag-farcaster-channel-by-name.react.js index 280abd881..cbbcbf1dc 100644 --- a/native/community-settings/tag-farcaster-channel/tag-farcaster-channel-by-name.react.js +++ b/native/community-settings/tag-farcaster-channel/tag-farcaster-channel-by-name.react.js @@ -1,154 +1,154 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text } from 'react-native'; import { NeynarClientContext } from 'lib/components/neynar-client-provider.react.js'; import { tagFarcasterChannelErrorMessages, useCreateFarcasterChannelTag, } from 'lib/shared/community-utils.js'; import type { TagFarcasterChannelNavigationProp } from './tag-farcaster-channel-navigator.react.js'; import PrimaryButton from '../../components/primary-button.react.js'; import TextInput from '../../components/text-input.react.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useStyles, useColors } from '../../themes/colors.js'; export type TagFarcasterChannelByNameParams = { +communityID: string, }; type Props = { +navigation: TagFarcasterChannelNavigationProp<'TagFarcasterChannelByName'>, +route: NavigationRoute<'TagFarcasterChannelByName'>, }; function TagFarcasterChannelByName(prop: Props): React.Node { const { navigation, route } = prop; const { goBack } = navigation; const { communityID } = route.params; const styles = useStyles(unboundStyles); const colors = useColors(); const [channelSelectionText, setChannelSelectionText] = React.useState(''); const [error, setError] = React.useState(null); const neynarClientContext = React.useContext(NeynarClientContext); invariant(neynarClientContext, 'NeynarClientContext is missing'); const { createTag, isLoading } = useCreateFarcasterChannelTag( communityID, setError, goBack, ); const onPressTagChannel = React.useCallback(async () => { const channelInfo = - await neynarClientContext.client.fetchFarcasterChannelByID( + await neynarClientContext.fcCache.getFarcasterChannelForChannelID( channelSelectionText, ); if (!channelInfo) { setError('channel_not_found'); return; } createTag(channelInfo.id); - }, [channelSelectionText, createTag, neynarClientContext.client]); + }, [channelSelectionText, createTag, neynarClientContext.fcCache]); const errorMessage = React.useMemo(() => { if (!error) { return ; } return ( {tagFarcasterChannelErrorMessages[error] ?? 'Unknown error.'} ); }, [error, styles.error, styles.errorPlaceholder]); let submitButtonVariant = 'disabled'; if (isLoading) { submitButtonVariant = 'loading'; } else if (channelSelectionText.length > 0) { submitButtonVariant = 'enabled'; } return ( CHANNEL NAME {errorMessage} ); } const unboundStyles = { container: { paddingTop: 24, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 4, paddingHorizontal: 16, }, panelSectionContainer: { backgroundColor: 'panelForeground', padding: 16, borderBottomColor: 'panelSeparator', borderBottomWidth: 1, borderTopColor: 'panelSeparator', borderTopWidth: 1, }, inputContainer: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 12, borderWidth: 1, borderColor: 'panelSecondaryForegroundBorder', borderRadius: 8, marginBottom: 8, }, input: { color: 'panelForegroundLabel', fontSize: 16, }, error: { fontSize: 12, fontWeight: '400', lineHeight: 18, textAlign: 'center', color: 'redText', }, errorPlaceholder: { height: 18, }, }; export default TagFarcasterChannelByName; 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 52ecf6630..c95b0c644 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,184 +1,182 @@ // @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 } = neynarClientContext; + const { client, 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 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]); 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 neynarClientContext.client.fetchFarcasterChannelByID( - channelNameText, - ); + await fcCache.getFarcasterChannelForChannelID(channelNameText); if (!channelInfo) { setError('channel_not_found'); return; } createTag(channelInfo.id); - }, [channelNameText, createTag, neynarClientContext.client, selectedOption]); + }, [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;