diff --git a/lib/hooks/invite-links.js b/lib/hooks/invite-links.js
--- a/lib/hooks/invite-links.js
+++ b/lib/hooks/invite-links.js
@@ -2,27 +2,23 @@
 
 import * as React from 'react';
 
-import { addKeyserverActionType } from '../actions/keyserver-actions.js';
 import {
   useCreateOrUpdatePublicLink,
   createOrUpdatePublicLinkActionTypes,
   useDisableInviteLink,
   disableInviteLinkLinkActionTypes,
 } from '../actions/link-actions.js';
-import {
-  joinThreadActionTypes,
-  useJoinThread,
-} from '../actions/thread-actions.js';
 import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js';
 import { createLoadingStatusSelector } from '../selectors/loading-selectors.js';
 import { threadInfoSelector } from '../selectors/thread-selectors.js';
-import { isLoggedInToKeyserver } from '../selectors/user-selectors.js';
+import { useJoinCommunity } from '../shared/community-utils.js';
 import type { KeyserverOverride } from '../shared/invite-links.js';
-import { useIsKeyserverURLValid } from '../shared/keyserver-utils.js';
-import { permissionsAndAuthRelatedRequestTimeout } from '../shared/timeouts.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 { defaultKeyserverInfo } from '../types/keyserver-types.js';
 import type {
   InviteLink,
   InviteLinkVerificationResponse,
@@ -30,7 +26,7 @@
 import type { LoadingStatus } from '../types/loading-types.js';
 import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
 import { useDispatchActionPromise } from '../utils/redux-promise-utils.js';
-import { useDispatch, useSelector } from '../utils/redux-utils.js';
+import { useSelector } from '../utils/redux-utils.js';
 
 const createOrUpdatePublicLinkStatusSelector = createLoadingStatusSelector(
   createOrUpdatePublicLinkActionTypes,
@@ -178,223 +174,29 @@
     navigateToThread,
   } = params;
 
-  const dispatch = useDispatch();
-  const callJoinThread = useJoinThread();
-
   const communityID = verificationResponse.community?.id;
   let keyserverID = keyserverOverride?.keyserverID;
   if (!keyserverID && communityID) {
     keyserverID = extractKeyserverIDFromID(communityID);
   }
 
-  const isKeyserverKnown = useSelector(state =>
-    keyserverID ? !!state.keyserverStore.keyserverInfos[keyserverID] : false,
-  );
-  const isAuthenticated = useSelector(isLoggedInToKeyserver(keyserverID));
-
-  const keyserverURL = keyserverOverride?.keyserverURL;
-  const isKeyserverURLValid = useIsKeyserverURLValid(keyserverURL);
-
-  const [ongoingJoinData, setOngoingJoinData] = React.useState<?{
-    +resolve: () => mixed,
-    +reject: () => mixed,
-    +communityID: string,
-    +threadID: ?string,
-  }>(null);
-  const timeoutRef = React.useRef<?TimeoutID>();
+  const [ongoingJoinData, setOngoingJoinData] =
+    React.useState<?OngoingJoinCommunityData>(null);
 
-  React.useEffect(() => {
-    return () => {
-      if (timeoutRef.current) {
-        clearTimeout(timeoutRef.current);
-      }
-    };
-  }, []);
-  const [step, setStep] = React.useState<
-    | 'inactive'
-    | 'add_keyserver'
-    | 'auth_to_keyserver'
-    | 'join_community'
-    | 'join_thread'
-    | 'finished',
-  >('inactive');
+  const [step, setStep] = React.useState<JoinCommunityStep>('inactive');
 
-  const createJoinPromise = React.useCallback(() => {
-    return new Promise<void>((resolve, reject) => {
-      if (
-        !keyserverID ||
-        !communityID ||
-        keyserverID !== extractKeyserverIDFromID(communityID)
-      ) {
-        reject();
-        setLinkStatus('invalid');
-        setOngoingJoinData(null);
-        return;
-      }
-      const timeoutID = setTimeout(() => {
-        reject();
-        setOngoingJoinData(oldData => {
-          if (oldData) {
-            setLinkStatus('timed_out');
-          }
-          return null;
-        });
-      }, permissionsAndAuthRelatedRequestTimeout);
-      timeoutRef.current = timeoutID;
-      const resolveAndClearTimeout = () => {
-        clearTimeout(timeoutID);
-        resolve();
-      };
-      const rejectAndClearTimeout = () => {
-        clearTimeout(timeoutID);
-        reject();
-      };
-      setOngoingJoinData({
-        resolve: resolveAndClearTimeout,
-        reject: rejectAndClearTimeout,
-        communityID,
-        threadID: verificationResponse.thread?.id,
-      });
-      setStep('add_keyserver');
-    });
-  }, [
+  const joinCommunity = useJoinCommunity({
     communityID,
-    keyserverID,
-    setLinkStatus,
-    verificationResponse.thread?.id,
-  ]);
-
-  React.useEffect(() => {
-    void (async () => {
-      if (!ongoingJoinData || step !== 'add_keyserver') {
-        return;
-      }
-      if (isKeyserverKnown) {
-        setStep('auth_to_keyserver');
-        return;
-      }
-
-      const isValid = await isKeyserverURLValid();
-      if (!isValid || !keyserverURL) {
-        setLinkStatus('invalid');
-        ongoingJoinData.reject();
-        setOngoingJoinData(null);
-        return;
-      }
-      dispatch({
-        type: addKeyserverActionType,
-        payload: {
-          keyserverAdminUserID: keyserverID,
-          newKeyserverInfo: defaultKeyserverInfo(keyserverURL),
-        },
-      });
-    })();
-  }, [
-    dispatch,
-    isKeyserverKnown,
-    isKeyserverURLValid,
-    keyserverID,
-    keyserverURL,
-    ongoingJoinData,
-    setLinkStatus,
-    step,
-  ]);
-
-  React.useEffect(() => {
-    if (step === 'auth_to_keyserver' && ongoingJoinData && isAuthenticated) {
-      setStep('join_community');
-    }
-  }, [isAuthenticated, ongoingJoinData, step]);
-
-  const dispatchActionPromise = useDispatchActionPromise();
-  React.useEffect(() => {
-    void (async () => {
-      if (!ongoingJoinData || step !== 'join_community') {
-        return;
-      }
-      const threadID = ongoingJoinData.communityID;
-      const query = calendarQuery();
-      const joinThreadPromise = callJoinThread({
-        threadID,
-        calendarQuery: {
-          startDate: query.startDate,
-          endDate: query.endDate,
-          filters: [
-            ...query.filters,
-            { type: 'threads', threadIDs: [threadID] },
-          ],
-        },
-        inviteLinkSecret: inviteSecret,
-      });
-      void dispatchActionPromise(joinThreadActionTypes, joinThreadPromise);
-
-      try {
-        await joinThreadPromise;
-        setStep('join_thread');
-      } catch (e) {
-        setLinkStatus(status => (status === 'valid' ? 'invalid' : status));
-        ongoingJoinData.reject();
-        setOngoingJoinData(null);
-      }
-    })();
-  }, [
+    keyserverOverride,
     calendarQuery,
-    callJoinThread,
-    dispatchActionPromise,
-    inviteSecret,
     ongoingJoinData,
-    setLinkStatus,
+    setOngoingJoinData,
     step,
-  ]);
-
-  React.useEffect(() => {
-    void (async () => {
-      if (!ongoingJoinData || step !== 'join_thread') {
-        return;
-      }
-      const threadID = ongoingJoinData.threadID;
-      if (!threadID) {
-        setStep('finished');
-        ongoingJoinData.resolve();
-        setOngoingJoinData(null);
-        return;
-      }
-
-      const query = calendarQuery();
-      const joinThreadPromise = callJoinThread({
-        threadID,
-        calendarQuery: {
-          startDate: query.startDate,
-          endDate: query.endDate,
-          filters: [
-            ...query.filters,
-            { type: 'threads', threadIDs: [threadID] },
-          ],
-        },
-        inviteLinkSecret: inviteSecret,
-      });
-      void dispatchActionPromise(joinThreadActionTypes, joinThreadPromise);
-
-      try {
-        await joinThreadPromise;
-        setStep('finished');
-        ongoingJoinData.resolve();
-      } catch (e) {
-        setLinkStatus(status => (status === 'valid' ? 'invalid' : status));
-        ongoingJoinData.reject();
-      } finally {
-        setOngoingJoinData(null);
-      }
-    })();
-  }, [
-    calendarQuery,
-    callJoinThread,
-    dispatchActionPromise,
+    setStep,
     inviteSecret,
-    ongoingJoinData,
     setLinkStatus,
-    step,
-  ]);
+    threadID: verificationResponse.thread?.id,
+  });
 
   const threadInfos = useSelector(threadInfoSelector);
   React.useEffect(() => {
@@ -430,10 +232,10 @@
 
   return React.useMemo(
     () => ({
-      join: createJoinPromise,
+      join: joinCommunity,
       joinLoadingStatus,
     }),
-    [createJoinPromise, joinLoadingStatus],
+    [joinCommunity, joinLoadingStatus],
   );
 }
 
diff --git a/lib/shared/community-utils.js b/lib/shared/community-utils.js
--- a/lib/shared/community-utils.js
+++ b/lib/shared/community-utils.js
@@ -9,14 +9,31 @@
   deleteFarcasterChannelTagActionTypes,
   useDeleteFarcasterChannelTag,
 } from '../actions/community-actions.js';
+import { addKeyserverActionType } from '../actions/keyserver-actions.js';
+import {
+  joinThreadActionTypes,
+  useJoinThread,
+} from '../actions/thread-actions.js';
+import type { LinkStatus } from '../hooks/invite-links.js';
+import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js';
 import { createLoadingStatusSelector } from '../selectors/loading-selectors.js';
+import { isLoggedInToKeyserver } from '../selectors/user-selectors.js';
+import type { KeyserverOverride } from '../shared/invite-links.js';
+import { useIsKeyserverURLValid } from '../shared/keyserver-utils.js';
+import { permissionsAndAuthRelatedRequestTimeout } from '../shared/timeouts.js';
+import type {
+  JoinCommunityStep,
+  OngoingJoinCommunityData,
+} from '../types/community-types.js';
+import type { CalendarQuery } from '../types/entry-types.js';
 import type { SetState } from '../types/hook-types.js';
+import { defaultKeyserverInfo } from '../types/keyserver-types.js';
 import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
 import { threadPermissions } from '../types/thread-permission-types.js';
 import { threadTypes } from '../types/thread-types-enum.js';
 import { useCurrentUserFID } from '../utils/farcaster-utils.js';
 import { useDispatchActionPromise } from '../utils/redux-promise-utils.js';
-import { useSelector } from '../utils/redux-utils.js';
+import { useDispatch, useSelector } from '../utils/redux-utils.js';
 
 const tagFarcasterChannelCopy = {
   DESCRIPTION:
@@ -160,6 +177,246 @@
   );
 }
 
+type UseJoinCommunityParams = {
+  +communityID: ?string,
+  +keyserverOverride: ?KeyserverOverride,
+  +calendarQuery: () => CalendarQuery,
+  +ongoingJoinData: ?OngoingJoinCommunityData,
+  +setOngoingJoinData: SetState<?OngoingJoinCommunityData>,
+  +step: JoinCommunityStep,
+  +setStep: SetState<JoinCommunityStep>,
+  +inviteSecret: string,
+  +setLinkStatus: SetState<LinkStatus>,
+  +threadID?: string,
+};
+function useJoinCommunity(params: UseJoinCommunityParams): () => Promise<void> {
+  const {
+    communityID,
+    keyserverOverride,
+    calendarQuery,
+    ongoingJoinData,
+    setOngoingJoinData,
+    step,
+    setStep,
+    inviteSecret,
+    setLinkStatus,
+    threadID,
+  } = params;
+
+  const dispatch = useDispatch();
+  const dispatchActionPromise = useDispatchActionPromise();
+  const callJoinThread = useJoinThread();
+
+  let keyserverID = keyserverOverride?.keyserverID;
+  if (!keyserverID && communityID) {
+    keyserverID = extractKeyserverIDFromID(communityID);
+  }
+
+  const isKeyserverKnown = useSelector(state =>
+    keyserverID ? !!state.keyserverStore.keyserverInfos[keyserverID] : false,
+  );
+  const isAuthenticated = useSelector(isLoggedInToKeyserver(keyserverID));
+
+  const keyserverURL = keyserverOverride?.keyserverURL;
+  const isKeyserverURLValid = useIsKeyserverURLValid(keyserverURL);
+
+  const timeoutRef = React.useRef<?TimeoutID>();
+
+  React.useEffect(() => {
+    return () => {
+      if (timeoutRef.current) {
+        clearTimeout(timeoutRef.current);
+      }
+    };
+  }, []);
+
+  const createJoinPromise = React.useCallback(() => {
+    return new Promise<void>((resolve, reject) => {
+      if (
+        !keyserverID ||
+        !communityID ||
+        keyserverID !== extractKeyserverIDFromID(communityID)
+      ) {
+        reject();
+        setLinkStatus('invalid');
+        setOngoingJoinData(null);
+        return;
+      }
+      const timeoutID = setTimeout(() => {
+        reject();
+        setOngoingJoinData(oldData => {
+          if (oldData) {
+            setLinkStatus('timed_out');
+          }
+          return null;
+        });
+      }, permissionsAndAuthRelatedRequestTimeout);
+      timeoutRef.current = timeoutID;
+      const resolveAndClearTimeout = () => {
+        clearTimeout(timeoutID);
+        resolve();
+      };
+      const rejectAndClearTimeout = () => {
+        clearTimeout(timeoutID);
+        reject();
+      };
+      setOngoingJoinData({
+        resolve: resolveAndClearTimeout,
+        reject: rejectAndClearTimeout,
+        communityID,
+        threadID,
+      });
+      setStep('add_keyserver');
+    });
+  }, [
+    communityID,
+    keyserverID,
+    setLinkStatus,
+    setOngoingJoinData,
+    setStep,
+    threadID,
+  ]);
+
+  React.useEffect(() => {
+    void (async () => {
+      if (!ongoingJoinData || step !== 'add_keyserver') {
+        return;
+      }
+      if (isKeyserverKnown) {
+        setStep('auth_to_keyserver');
+        return;
+      }
+
+      const isValid = await isKeyserverURLValid();
+      if (!isValid || !keyserverURL) {
+        setLinkStatus('invalid');
+        ongoingJoinData.reject();
+        setOngoingJoinData(null);
+        return;
+      }
+      dispatch({
+        type: addKeyserverActionType,
+        payload: {
+          keyserverAdminUserID: keyserverID,
+          newKeyserverInfo: defaultKeyserverInfo(keyserverURL),
+        },
+      });
+    })();
+  }, [
+    dispatch,
+    isKeyserverKnown,
+    isKeyserverURLValid,
+    keyserverID,
+    keyserverURL,
+    ongoingJoinData,
+    setLinkStatus,
+    setOngoingJoinData,
+    setStep,
+    step,
+  ]);
+
+  React.useEffect(() => {
+    if (step === 'auth_to_keyserver' && ongoingJoinData && isAuthenticated) {
+      setStep('join_community');
+    }
+  }, [isAuthenticated, ongoingJoinData, setStep, step]);
+
+  React.useEffect(() => {
+    void (async () => {
+      if (!ongoingJoinData || step !== 'join_community') {
+        return;
+      }
+      const communityThreadID = ongoingJoinData.communityID;
+      const query = calendarQuery();
+      const joinThreadPromise = callJoinThread({
+        threadID: communityThreadID,
+        calendarQuery: {
+          startDate: query.startDate,
+          endDate: query.endDate,
+          filters: [
+            ...query.filters,
+            { type: 'threads', threadIDs: [communityThreadID] },
+          ],
+        },
+        inviteLinkSecret: inviteSecret,
+      });
+      void dispatchActionPromise(joinThreadActionTypes, joinThreadPromise);
+
+      try {
+        await joinThreadPromise;
+        setStep('join_thread');
+      } catch (e) {
+        setLinkStatus(status => (status === 'valid' ? 'invalid' : status));
+        ongoingJoinData.reject();
+        setOngoingJoinData(null);
+      }
+    })();
+  }, [
+    calendarQuery,
+    callJoinThread,
+    dispatchActionPromise,
+    inviteSecret,
+    ongoingJoinData,
+    setLinkStatus,
+    setOngoingJoinData,
+    setStep,
+    step,
+  ]);
+
+  React.useEffect(() => {
+    void (async () => {
+      if (!ongoingJoinData || step !== 'join_thread') {
+        return;
+      }
+      if (!threadID) {
+        setStep('finished');
+        ongoingJoinData.resolve();
+        setOngoingJoinData(null);
+        return;
+      }
+
+      const query = calendarQuery();
+      const joinThreadPromise = callJoinThread({
+        threadID,
+        calendarQuery: {
+          startDate: query.startDate,
+          endDate: query.endDate,
+          filters: [
+            ...query.filters,
+            { type: 'threads', threadIDs: [threadID] },
+          ],
+        },
+        inviteLinkSecret: inviteSecret,
+      });
+      void dispatchActionPromise(joinThreadActionTypes, joinThreadPromise);
+
+      try {
+        await joinThreadPromise;
+        setStep('finished');
+        ongoingJoinData.resolve();
+      } catch (e) {
+        setLinkStatus(status => (status === 'valid' ? 'invalid' : status));
+        ongoingJoinData.reject();
+      } finally {
+        setOngoingJoinData(null);
+      }
+    })();
+  }, [
+    calendarQuery,
+    callJoinThread,
+    dispatchActionPromise,
+    inviteSecret,
+    ongoingJoinData,
+    setLinkStatus,
+    setOngoingJoinData,
+    setStep,
+    step,
+    threadID,
+  ]);
+
+  return createJoinPromise;
+}
+
 export {
   tagFarcasterChannelCopy,
   tagFarcasterChannelErrorMessages,
@@ -167,4 +424,5 @@
   useCreateFarcasterChannelTag,
   useRemoveFarcasterChannelTag,
   useCanManageFarcasterChannelTag,
+  useJoinCommunity,
 };
diff --git a/lib/types/community-types.js b/lib/types/community-types.js
--- a/lib/types/community-types.js
+++ b/lib/types/community-types.js
@@ -52,3 +52,18 @@
 export type DeleteFarcasterChannelTagPayload = {
   +commCommunityID: string,
 };
+
+export type OngoingJoinCommunityData = {
+  +resolve: () => mixed,
+  +reject: () => mixed,
+  +communityID: string,
+  +threadID: ?string,
+};
+
+export type JoinCommunityStep =
+  | 'inactive'
+  | 'add_keyserver'
+  | 'auth_to_keyserver'
+  | 'join_community'
+  | 'join_thread'
+  | 'finished';