diff --git a/ts/components/dialog/AdminLeaveClosedGroupDialog.tsx b/ts/components/dialog/AdminLeaveClosedGroupDialog.tsx
index 36c433593..c42cd999f 100644
--- a/ts/components/dialog/AdminLeaveClosedGroupDialog.tsx
+++ b/ts/components/dialog/AdminLeaveClosedGroupDialog.tsx
@@ -1,51 +1,54 @@
-import React from 'react';
+import React, { useState } from 'react';
+import { useDispatch } from 'react-redux';
 import styled from 'styled-components';
 import { getConversationController } from '../../session/conversations';
-import { leaveClosedGroup } from '../../session/group/closed-group';
 import { adminLeaveClosedGroup } from '../../state/ducks/modalDialog';
+import { SessionWrapperModal } from '../SessionWrapperModal';
 import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
 import { SpacerLG } from '../basic/Text';
-import { SessionWrapperModal } from '../SessionWrapperModal';
-
-type Props = {
-  conversationId: string;
-};
+import { SessionSpinner } from '../basic/SessionSpinner';
 
 const StyledWarning = styled.p`
   max-width: 500px;
   line-height: 1.3333;
 `;
 
-export const AdminLeaveClosedGroupDialog = (props: Props) => {
+export const AdminLeaveClosedGroupDialog = (props: { conversationId: string }) => {
+  const dispatch = useDispatch();
   const convo = getConversationController().get(props.conversationId);
-  const titleText = `${window.i18n('leaveGroup')} ${convo.getRealSessionUsername()}`;
-  const warningAsAdmin = `${window.i18n('leaveGroupConfirmationAdmin')}`;
-  const okText = window.i18n('leaveAndRemoveForEveryone');
-  const cancelText = window.i18n('cancel');
+  const [loading, setLoading] = useState(false);
+  const titleText = `${window.i18n('leaveGroup')} ${convo?.getRealSessionUsername() || ''}`;
 
-  const onClickOK = async () => {
-    await leaveClosedGroup(props.conversationId);
-    closeDialog();
+  const closeDialog = () => {
+    dispatch(adminLeaveClosedGroup(null));
   };
 
-  const closeDialog = () => {
-    window.inboxStore?.dispatch(adminLeaveClosedGroup(null));
+  const onClickOK = async () => {
+    if (loading) {
+      return;
+    }
+    setLoading(true);
+    // we know want to delete a closed group right after we've left it, so we can call the deleteContact which takes care of it all
+    await getConversationController().deleteContact(props.conversationId, false);
+    setLoading(false);
+    closeDialog();
   };
 
   return (
     <SessionWrapperModal title={titleText} onClose={closeDialog}>
       <SpacerLG />
-      <StyledWarning>{warningAsAdmin}</StyledWarning>
+      <StyledWarning>{window.i18n('leaveGroupConfirmationAdmin')}</StyledWarning>
+      <SessionSpinner loading={loading} />
 
       <div className="session-modal__button-group">
         <SessionButton
-          text={okText}
+          text={window.i18n('leaveAndRemoveForEveryone')}
           buttonColor={SessionButtonColor.Danger}
           buttonType={SessionButtonType.Simple}
           onClick={onClickOK}
         />
         <SessionButton
-          text={cancelText}
+          text={window.i18n('cancel')}
           buttonType={SessionButtonType.Simple}
           onClick={closeDialog}
         />
diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts
index 77ca33c4d..a2f7ec463 100644
--- a/ts/interactions/conversationInteractions.ts
+++ b/ts/interactions/conversationInteractions.ts
@@ -7,13 +7,16 @@ import { CallManager, SyncUtils, ToastUtils, UserUtils } from '../session/utils'
 import { SessionButtonColor } from '../components/basic/SessionButton';
 import { getCallMediaPermissionsSettings } from '../components/settings/SessionSettings';
 import { Data } from '../data/data';
+import { SettingsKey } from '../data/settings-key';
 import { uploadFileToFsWithOnionV4 } from '../session/apis/file_server_api/FileServerApi';
+import { OpenGroupUtils } from '../session/apis/open_group_api/utils';
 import { getConversationController } from '../session/conversations';
 import { getSodiumRenderer } from '../session/crypto';
 import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager';
-import { ConfigurationSync } from '../session/utils/job_runners/jobs/ConfigurationSyncJob';
 import { perfEnd, perfStart } from '../session/utils/Performance';
 import { fromHexToArray, toHex } from '../session/utils/String';
+import { ConfigurationSync } from '../session/utils/job_runners/jobs/ConfigurationSyncJob';
+import { SessionUtilContact } from '../session/utils/libsession/libsession_utils_contacts';
 import { forceSyncConfigurationNowIfNeeded } from '../session/utils/sync/syncUtils';
 import {
   conversationReset,
@@ -32,17 +35,13 @@ import {
   updateRemoveModeratorsModal,
 } from '../state/ducks/modalDialog';
 import { MIME } from '../types';
-import { urlToBlob } from '../types/attachments/VisualAttachment';
-import { processNewAttachment } from '../types/MessageAttachment';
 import { IMAGE_JPEG } from '../types/MIME';
+import { processNewAttachment } from '../types/MessageAttachment';
+import { urlToBlob } from '../types/attachments/VisualAttachment';
 import { BlockedNumberController } from '../util/blockedNumberController';
 import { encryptProfile } from '../util/crypto/profileEncrypter';
-import { Storage, setLastProfileUpdateTimestamp } from '../util/storage';
-import { OpenGroupUtils } from '../session/apis/open_group_api/utils';
-import { leaveClosedGroup } from '../session/group/closed-group';
-import { SessionUtilContact } from '../session/utils/libsession/libsession_utils_contacts';
-import { SettingsKey } from '../data/settings-key';
 import { ReleasedFeatures } from '../util/releaseFeature';
+import { Storage, setLastProfileUpdateTimestamp } from '../util/storage';
 import { UserGroupsWrapperActions } from '../webworker/workers/browser/libsession_worker_interface';
 
 export async function copyPublicKeyByConvoId(convoId: string) {
@@ -250,19 +249,19 @@ export function showLeaveGroupByConvoId(conversationId: string) {
         title,
         message,
         onClickOk: async () => {
-          await leaveClosedGroup(conversation.id);
+          await getConversationController().deleteContact(conversation.id, false);
           onClickClose();
         },
         onClickClose,
       })
     );
-  } else {
-    window.inboxStore?.dispatch(
-      adminLeaveClosedGroup({
-        conversationId,
-      })
-    );
+    return;
   }
+  window.inboxStore?.dispatch(
+    adminLeaveClosedGroup({
+      conversationId,
+    })
+  );
 }
 export function showInviteContactByConvoId(conversationId: string) {
   window.inboxStore?.dispatch(updateInviteContactModal({ conversationId }));
diff --git a/ts/models/message.ts b/ts/models/message.ts
index 69386ec1b..842d64db8 100644
--- a/ts/models/message.ts
+++ b/ts/models/message.ts
@@ -447,8 +447,10 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
       return 'read';
     }
     const sent = this.get('sent');
+    // control messages we've sent, synced from the network appear to just have the sent_at field set.
+    const sentAt = this.get('sent_at');
     const sentTo = this.get('sent_to') || [];
-    if (sent || sentTo.length > 0) {
+    if (sent || sentTo.length > 0 || sentAt) {
       return 'sent';
     }
 
diff --git a/ts/node/migration/sessionMigrations.ts b/ts/node/migration/sessionMigrations.ts
index 9d3bd53b2..abc67ceaa 100644
--- a/ts/node/migration/sessionMigrations.ts
+++ b/ts/node/migration/sessionMigrations.ts
@@ -1431,7 +1431,7 @@ function getBlockedNumbersDuringMigration(db: BetterSqlite3.Database) {
   try {
     const blockedItem = sqlNode.getItemById('blocked', db);
     if (!blockedItem) {
-      throw new Error('no blocked contacts at all');
+      return [];
     }
     const foundBlocked = blockedItem?.value;
     console.info('foundBlockedNumbers during migration', foundBlocked);
@@ -1497,6 +1497,15 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
         WHERE type = 'private' AND (active_at IS NULL OR active_at = 0 );`
     ).run({});
 
+    // create the table which is going to handle the wrappers, without any content in this migration.
+    db.exec(`CREATE TABLE ${CONFIG_DUMP_TABLE}(
+          variant TEXT NOT NULL,
+          publicKey TEXT NOT NULL,
+          data BLOB,
+          PRIMARY KEY (publicKey, variant)
+          );
+          `);
+
     /**
      * Remove the `publicChat` prefix from the communities, instead keep the full url+room in it, with the corresponding http or https prefix.
      * This is easier to handle with the libsession wrappers
@@ -1531,7 +1540,7 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
         newId,
         oldId: convoDetails.oldConvoId,
       });
-      // do the same for messages and where else?
+      // do the same for messages
 
       db.prepare(
         `UPDATE ${MESSAGES_TABLE} SET
@@ -1543,19 +1552,11 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
       db.prepare(
         `UPDATE ${OPEN_GROUP_ROOMS_V2_TABLE} SET
           conversationId = $newId,
-          json = json_set(json, '$.conversationId', $newId);`
-      ).run({ newId });
+          json = json_set(json, '$.conversationId', $newId)
+          WHERE conversationId = $oldConvoId;`
+      ).run({ newId, oldConvoId: convoDetails.oldConvoId });
     });
 
-    // create the table which is going to handle the wrappers, without any content in this migration.
-    db.exec(`CREATE TABLE ${CONFIG_DUMP_TABLE}(
-      variant TEXT NOT NULL,
-      publicKey TEXT NOT NULL,
-      data BLOB,
-      PRIMARY KEY (publicKey, variant)
-      );
-      `);
-
     writeSessionSchemaVersion(targetVersion, db);
   })();
 
@@ -1712,7 +1713,6 @@ function updateToSessionSchemaVersion31(currentVersion: number, db: BetterSqlite
 
         communitiesToWriteInWrapper.forEach(community => {
           try {
-            console.info('Writing community: ', JSON.stringify(community));
             insertCommunityIntoWrapper(
               community,
               userGroupsConfigWrapper,
@@ -1810,7 +1810,7 @@ function updateToSessionSchemaVersion31(currentVersion: number, db: BetterSqlite
 
     // still, we update the schema version
     writeSessionSchemaVersion(targetVersion, db);
-  });
+  })();
 }
 
 export function printTableColumns(table: string, db: BetterSqlite3.Database) {
diff --git a/ts/receiver/closedGroups.ts b/ts/receiver/closedGroups.ts
index 8ef8b222f..981f7553a 100644
--- a/ts/receiver/closedGroups.ts
+++ b/ts/receiver/closedGroups.ts
@@ -342,6 +342,8 @@ export async function markGroupAsLeftOrKicked(
   groupConvo: ConversationModel,
   isKicked: boolean
 ) {
+  getSwarmPollingInstance().removePubkey(groupPublicKey);
+
   await innerRemoveAllClosedGroupEncryptionKeyPairs(groupPublicKey);
 
   if (isKicked) {
@@ -349,7 +351,6 @@ export async function markGroupAsLeftOrKicked(
   } else {
     groupConvo.set('left', true);
   }
-  getSwarmPollingInstance().removePubkey(groupPublicKey);
 }
 
 /**
@@ -547,13 +548,9 @@ async function performIfValid(
   } else if (groupUpdate.type === Type.MEMBER_LEFT) {
     await handleClosedGroupMemberLeft(envelope, convo);
   } else if (groupUpdate.type === Type.ENCRYPTION_KEY_PAIR_REQUEST) {
-    window?.log?.warn(
-      'Received ENCRYPTION_KEY_PAIR_REQUEST message but it is not enabled for now.'
-    );
     await removeFromCache(envelope);
-
-    // if you add a case here, remember to add it where performIfValid is called too.
   }
+  // if you add a case here, remember to add it where performIfValid is called too.
 
   return true;
 }
diff --git a/ts/receiver/configMessage.ts b/ts/receiver/configMessage.ts
index 9f4ca2c14..84be29005 100644
--- a/ts/receiver/configMessage.ts
+++ b/ts/receiver/configMessage.ts
@@ -329,7 +329,15 @@ async function handleLegacyGroupUpdate(latestEnvelopeTimestamp: number) {
   for (let index = 0; index < legacyGroupsToLeaveInDB.length; index++) {
     const toLeave = legacyGroupsToLeaveInDB[index];
     console.warn('leaving legacy group from configuration sync message with convoId ', toLeave.id);
-    await getConversationController().deleteContact(toLeave.id, true);
+    const toLeaveFromDb = getConversationController().get(toLeave.id);
+
+    // if we were kicked from that group, leave it as is until the user manually deletes it
+    // otherwise, completely remove the conversation
+    if (!toLeaveFromDb?.get('isKickedFromGroup')) {
+      window.log.debug(`we were kicked from ${toLeave.id} so we keep it until manually deleted`);
+
+      await getConversationController().deleteContact(toLeave.id, true);
+    }
   }
 
   for (let index = 0; index < legacyGroupsToJoinInDB.length; index++) {
@@ -379,10 +387,6 @@ async function handleLegacyGroupUpdate(latestEnvelopeTimestamp: number) {
 
     let changes = await legacyGroupConvo.setPriorityFromWrapper(fromWrapper.priority, false);
 
-    if (legacyGroupConvo.get('priority') !== fromWrapper.priority) {
-      legacyGroupConvo.set({ priority: fromWrapper.priority });
-      changes = true;
-    }
     const existingTimestampMs = legacyGroupConvo.get('lastJoinedTimestamp');
     if (Math.floor(existingTimestampMs / 1000) !== fromWrapper.joinedAtSeconds) {
       legacyGroupConvo.set({ lastJoinedTimestamp: fromWrapper.joinedAtSeconds * 1000 });
@@ -400,29 +404,31 @@ async function handleLegacyGroupUpdate(latestEnvelopeTimestamp: number) {
       );
       changes = true;
     }
-    // start polling for this new group
-    getSwarmPollingInstance().addGroupId(PubKey.cast(fromWrapper.pubkeyHex));
+    // start polling for this group if we haven't left it yet. The wrapper does not store this info for legacy group so we check from the DB entry instead
+    if (!legacyGroupConvo.get('isKickedFromGroup') && !legacyGroupConvo.get('left')) {
+      getSwarmPollingInstance().addGroupId(PubKey.cast(fromWrapper.pubkeyHex));
 
-    // trigger decrypting of all this group messages we did not decrypt successfully yet.
-    await queueAllCachedFromSource(fromWrapper.pubkeyHex);
+      // save the encryption keypair if needed
+      if (!isEmpty(fromWrapper.encPubkey) && !isEmpty(fromWrapper.encSeckey)) {
+        try {
+          const inWrapperKeypair: HexKeyPair = {
+            publicHex: toHex(fromWrapper.encPubkey),
+            privateHex: toHex(fromWrapper.encSeckey),
+          };
+
+          await addKeyPairToCacheAndDBIfNeeded(fromWrapper.pubkeyHex, inWrapperKeypair);
+        } catch (e) {
+          window.log.warn('failed to save keypair for legacugroup', fromWrapper.pubkeyHex);
+        }
+      }
+    }
 
     if (changes) {
       await legacyGroupConvo.commit();
     }
 
-    // save the encryption keypair if needed
-    if (!isEmpty(fromWrapper.encPubkey) && !isEmpty(fromWrapper.encSeckey)) {
-      try {
-        const inWrapperKeypair: HexKeyPair = {
-          publicHex: toHex(fromWrapper.encPubkey),
-          privateHex: toHex(fromWrapper.encSeckey),
-        };
-
-        await addKeyPairToCacheAndDBIfNeeded(fromWrapper.pubkeyHex, inWrapperKeypair);
-      } catch (e) {
-        window.log.warn('failed to save keypair for legacugroup', fromWrapper.pubkeyHex);
-      }
-    }
+    // trigger decrypting of all this group messages we did not decrypt successfully yet.
+    await queueAllCachedFromSource(fromWrapper.pubkeyHex);
   }
 }
 
@@ -458,9 +464,9 @@ async function applyConvoVolatileUpdateFromWrapper(
   }
 
   try {
-    window.log.debug(
-      `applyConvoVolatileUpdateFromWrapper: ${convoId}: forcedUnread:${forcedUnread}, lastReadMessage:${lastReadMessageTimestamp}`
-    );
+    // window.log.debug(
+    //   `applyConvoVolatileUpdateFromWrapper: ${convoId}: forcedUnread:${forcedUnread}, lastReadMessage:${lastReadMessageTimestamp}`
+    // );
     // this should mark all the messages sent before fromWrapper.lastRead as read and update the unreadCount
     await foundConvo.markReadFromConfigMessage(lastReadMessageTimestamp);
     // this commits to the DB, if needed
@@ -639,11 +645,13 @@ async function handleConfigMessagesViaLibSession(
   }
 
   window?.log?.debug(
-    `Handling our sharedConfig message via libsession_util ${configMessages.map(m => ({
-      variant: LibSessionUtil.kindToVariant(m.message.kind),
-      hash: m.messageHash,
-      seqno: (m.message.seqno as Long).toNumber(),
-    }))}`
+    `Handling our sharedConfig message via libsession_util ${JSON.stringify(
+      configMessages.map(m => ({
+        variant: LibSessionUtil.kindToVariant(m.message.kind),
+        hash: m.messageHash,
+        seqno: (m.message.seqno as Long).toNumber(),
+      }))
+    )}`
   );
 
   const incomingMergeResult = await mergeConfigsWithIncomingUpdates(configMessages);
diff --git a/ts/receiver/receiver.ts b/ts/receiver/receiver.ts
index 58c5d737a..3fe517954 100644
--- a/ts/receiver/receiver.ts
+++ b/ts/receiver/receiver.ts
@@ -1,20 +1,20 @@
+import { v4 as uuidv4 } from 'uuid';
 import { EnvelopePlus } from './types';
 export { downloadAttachment } from './attachments';
-import { v4 as uuidv4 } from 'uuid';
 
 import { addToCache, getAllFromCache, getAllFromCacheForSource, removeFromCache } from './cache';
 
 // innerHandleSwarmContentMessage is only needed because of code duplication in handleDecryptedEnvelope...
-import { handleSwarmContentMessage, innerHandleSwarmContentMessage } from './contentMessage';
 import _ from 'lodash';
+import { handleSwarmContentMessage, innerHandleSwarmContentMessage } from './contentMessage';
 
-import { getEnvelopeId } from './common';
-import { StringUtils, UserUtils } from '../session/utils';
-import { SignalService } from '../protobuf';
 import { Data } from '../data/data';
-import { createTaskWithTimeout } from '../session/utils/TaskWithTimeout';
+import { SignalService } from '../protobuf';
+import { StringUtils, UserUtils } from '../session/utils';
 import { perfEnd, perfStart } from '../session/utils/Performance';
+import { createTaskWithTimeout } from '../session/utils/TaskWithTimeout';
 import { UnprocessedParameter } from '../types/sqlSharedTypes';
+import { getEnvelopeId } from './common';
 
 const incomingMessagePromises: Array<Promise<any>> = [];
 
@@ -147,9 +147,11 @@ export function handleRequest(
  */
 export async function queueAllCached() {
   const items = await getAllFromCache();
-  items.forEach(item => {
-    void queueCached(item);
-  });
+
+  await items.reduce(async (promise, item) => {
+    await promise;
+    await queueCached(item);
+  }, Promise.resolve());
 }
 
 export async function queueAllCachedFromSource(source: string) {
diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts
index 73c6c44df..d2035e719 100644
--- a/ts/session/apis/snode_api/swarmPolling.ts
+++ b/ts/session/apis/snode_api/swarmPolling.ts
@@ -23,7 +23,10 @@ import { SnodeAPIRetrieve } from './retrieveRequest';
 import { RetrieveMessageItem, RetrieveMessagesResultsBatched } from './types';
 import { ReleasedFeatures } from '../../../util/releaseFeature';
 import { LibSessionUtil } from '../../utils/libsession/libsession_utils';
-import { GenericWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface';
+import {
+  GenericWrapperActions,
+  UserGroupsWrapperActions,
+} from '../../../webworker/workers/browser/libsession_worker_interface';
 
 export function extractWebSocketContent(
   message: string,
@@ -171,13 +174,13 @@ export class SwarmPolling {
           ?.idForLogging() || group.pubkey.key;
 
       if (diff >= convoPollingTimeout) {
-        window?.log?.info(
+        window?.log?.debug(
           `Polling for ${loggingId}; timeout: ${convoPollingTimeout}; diff: ${diff} `
         );
 
         return this.pollOnceForKey(group.pubkey, true, [SnodeNamespaces.ClosedGroupMessage]);
       }
-      window?.log?.info(
+      window?.log?.debug(
         `Not polling for ${loggingId}; timeout: ${convoPollingTimeout} ; diff: ${diff}`
       );
 
@@ -186,7 +189,7 @@ export class SwarmPolling {
     try {
       await Promise.all(concat([directPromise], groupPromises));
     } catch (e) {
-      window?.log?.info('pollForAllKeys exception: ', e);
+      window?.log?.warn('pollForAllKeys exception: ', e);
       throw e;
     } finally {
       setTimeout(this.pollForAllKeys.bind(this), SWARM_POLLING_TIMEOUT.ACTIVE);
@@ -270,7 +273,7 @@ export class SwarmPolling {
       );
     }
     if (allNamespacesWithoutUserConfigIfNeeded.length) {
-      window.log.info(
+      window.log.debug(
         `received allNamespacesWithoutUserConfigIfNeeded: ${allNamespacesWithoutUserConfigIfNeeded.length}`
       );
     }
@@ -280,7 +283,7 @@ export class SwarmPolling {
 
     // if all snodes returned an error (null), no need to update the lastPolledTimestamp
     if (isGroup) {
-      window?.log?.info(
+      window?.log?.debug(
         `Polled for group(${ed25519Str(pubkey.key)}):, got ${messages.length} messages back.`
       );
       let lastPolledTimestamp = Date.now();
@@ -306,15 +309,28 @@ export class SwarmPolling {
     const newMessages = await this.handleSeenMessages(messages);
     perfEnd(`handleSeenMessages-${polledPubkey}`, 'handleSeenMessages');
 
-    // trigger the handling of all the other messages, not shared config related
-    newMessages.forEach(m => {
-      const content = extractWebSocketContent(m.data, m.hash);
-      if (!content) {
-        return;
-      }
+    // don't handle incoming messages from group swarms when using the userconfig and the group is not one of the tracked group
+    const isUserConfigReleaseLive = await ReleasedFeatures.checkIsUserConfigFeatureReleased();
+    if (
+      isUserConfigReleaseLive &&
+      isGroup &&
+      polledPubkey.startsWith('05') &&
+      !(await UserGroupsWrapperActions.getLegacyGroup(polledPubkey)) // just check if a legacy group with that name exists
+    ) {
+      // that pubkey is not tracked in the wrapper anymore. Just discard those messages and make sure we are not polling
+      // TODOLATER we might need to do something like this for the new closed groups once released
+      await getSwarmPollingInstance().removePubkey(polledPubkey);
+    } else {
+      // trigger the handling of all the other messages, not shared config related
+      newMessages.forEach(m => {
+        const content = extractWebSocketContent(m.data, m.hash);
+        if (!content) {
+          return;
+        }
 
-      Receiver.handleRequest(content.body, isGroup ? polledPubkey : null, content.messageHash);
-    });
+        Receiver.handleRequest(content.body, isGroup ? polledPubkey : null, content.messageHash);
+      });
+    }
   }
 
   private async handleSharedConfigMessages(userConfigMessagesMerged: Array<RetrieveMessageItem>) {
diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts
index cc56ba2b8..1d37bb9b2 100644
--- a/ts/session/conversations/ConversationController.ts
+++ b/ts/session/conversations/ConversationController.ts
@@ -12,12 +12,18 @@ import { deleteAllMessagesByConvoIdNoConfirmation } from '../../interactions/con
 import { CONVERSATION_PRIORITIES, ConversationTypeEnum } from '../../models/conversationAttributes';
 import { assertUnreachable } from '../../types/sqlSharedTypes';
 import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface';
-import { leaveClosedGroup } from '../group/closed-group';
 import { ConfigurationSync } from '../utils/job_runners/jobs/ConfigurationSyncJob';
 import { LibSessionUtil } from '../utils/libsession/libsession_utils';
 import { SessionUtilContact } from '../utils/libsession/libsession_utils_contacts';
 import { SessionUtilConvoInfoVolatile } from '../utils/libsession/libsession_utils_convo_info_volatile';
 import { SessionUtilUserGroups } from '../utils/libsession/libsession_utils_user_groups';
+import { GetNetworkTime } from '../apis/snode_api/getNetworkTime';
+import { getMessageQueue } from '..';
+import { markGroupAsLeftOrKicked } from '../../receiver/closedGroups';
+import { getSwarmPollingInstance } from '../apis/snode_api';
+import { SnodeNamespaces } from '../apis/snode_api/namespaces';
+import { ClosedGroupMemberLeftMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupMemberLeftMessage';
+import { UserUtils } from '../utils';
 
 let instance: ConversationController | null;
 
@@ -262,6 +268,7 @@ export class ConversationController {
         if (roomInfos) {
           getOpenGroupManager().removeRoomFromPolledRooms(roomInfos);
         }
+        await this.cleanUpGroupConversation(conversation.id);
 
         // remove the roomInfos locally for this open group room including the pubkey
         try {
@@ -272,34 +279,13 @@ export class ConversationController {
         break;
       case 'LegacyGroup':
         window.log.info(`deleteContact ClosedGroup case: ${conversation.id}`);
-        await leaveClosedGroup(conversation.id);
-        await SessionUtilUserGroups.removeLegacyGroupFromWrapper(conversation.id);
-        await SessionUtilConvoInfoVolatile.removeLegacyGroupFromWrapper(conversation.id);
-
+        await leaveClosedGroup(conversation.id, fromSyncMessage); // this removes the data from the group and convo volatile info
+        await this.cleanUpGroupConversation(conversation.id);
         break;
       default:
         assertUnreachable(convoType, `deleteContact: convoType ${convoType} not handled`);
     }
 
-    if (conversation.isGroup()) {
-      window.log.info(`deleteContact isGroup, removing convo from DB: ${id}`);
-      // not a private conversation, so not a contact for the ContactWrapper
-      await Data.removeConversation(id);
-
-      window.log.info(`deleteContact isGroup, convo removed from DB: ${id}`);
-      this.conversations.remove(conversation);
-
-      window?.inboxStore?.dispatch(
-        conversationActions.conversationChanged({
-          id: conversation.id,
-          data: conversation.getConversationModelProps(),
-        })
-      );
-      window.inboxStore?.dispatch(conversationActions.conversationRemoved(conversation.id));
-
-      window.log.info(`deleteContact NOT private, convo removed from store: ${id}`);
-    }
-
     if (!fromSyncMessage) {
       await ConfigurationSync.queueNewJobIfNeeded();
     }
@@ -400,4 +386,110 @@ export class ConversationController {
     }
     this.conversations.reset([]);
   }
+
+  private async cleanUpGroupConversation(id: string) {
+    window.log.info(`deleteContact isGroup, removing convo from DB: ${id}`);
+    // not a private conversation, so not a contact for the ContactWrapper
+    await Data.removeConversation(id);
+
+    window.log.info(`deleteContact isGroup, convo removed from DB: ${id}`);
+    const conversation = this.conversations.get(id);
+
+    if (conversation) {
+      this.conversations.remove(conversation);
+
+      window?.inboxStore?.dispatch(
+        conversationActions.conversationChanged({
+          id: id,
+          data: conversation.getConversationModelProps(),
+        })
+      );
+    }
+    window.inboxStore?.dispatch(conversationActions.conversationRemoved(id));
+
+    window.log.info(`deleteContact NOT private, convo removed from store: ${id}`);
+  }
+}
+
+/**
+ * You most likely don't want to call this function directly, but instead use the deleteContact() from the ConversationController as it will take care of more cleaningup.
+ *
+ * Note: `fromSyncMessage` is used to know if we need to send a leave group message to the group first.
+ * So if the user made the action on this device, fromSyncMessage should be false, but if it happened from a linked device polled update, set this to true.
+ */
+async function leaveClosedGroup(groupId: string, fromSyncMessage: boolean) {
+  const convo = getConversationController().get(groupId);
+
+  if (!convo || !convo.isClosedGroup()) {
+    window?.log?.error('Cannot leave non-existing group');
+    return;
+  }
+
+  const ourNumber = UserUtils.getOurPubKeyStrFromCache();
+  const isCurrentUserAdmin = convo.get('groupAdmins')?.includes(ourNumber);
+
+  let members: Array<string> = [];
+  let admins: Array<string> = [];
+
+  // if we are the admin, the group must be destroyed for every members
+  if (isCurrentUserAdmin) {
+    window?.log?.info('Admin left a closed group. We need to destroy it');
+    convo.set({ left: true });
+    members = [];
+    admins = [];
+  } else {
+    // otherwise, just the exclude ourself from the members and trigger an update with this
+    convo.set({ left: true });
+    members = (convo.get('members') || []).filter((m: string) => m !== ourNumber);
+    admins = convo.get('groupAdmins') || [];
+  }
+  convo.set({ members });
+  await convo.updateGroupAdmins(admins, false);
+  await convo.commit();
+
+  const source = UserUtils.getOurPubKeyStrFromCache();
+  const networkTimestamp = GetNetworkTime.getNowWithNetworkOffset();
+
+  const dbMessage = await convo.addSingleOutgoingMessage({
+    group_update: { left: [source] },
+    sent_at: networkTimestamp,
+    expireTimer: 0,
+  });
+
+  getSwarmPollingInstance().removePubkey(groupId);
+
+  if (!fromSyncMessage) {
+    // Send the update to the group
+    const ourLeavingMessage = new ClosedGroupMemberLeftMessage({
+      timestamp: networkTimestamp,
+      groupId,
+      identifier: dbMessage.id as string,
+    });
+
+    window?.log?.info(`We are leaving the group ${groupId}. Sending our leaving message.`);
+    // sent the message to the group and once done, remove everything related to this group
+    const wasSent = await getMessageQueue().sendToPubKeyNonDurably({
+      message: ourLeavingMessage,
+      namespace: SnodeNamespaces.ClosedGroupMessage,
+      pubkey: PubKey.cast(groupId),
+    });
+    window?.log?.info(
+      `Leaving message sent ${groupId}. Removing everything related to this group.`
+    );
+    if (wasSent) {
+      await cleanUpFullyLeftLegacyGroup(groupId);
+    }
+  } else {
+    await cleanUpFullyLeftLegacyGroup(groupId);
+  }
+}
+
+async function cleanUpFullyLeftLegacyGroup(groupId: string) {
+  const convo = getConversationController().get(groupId);
+
+  await UserGroupsWrapperActions.eraseLegacyGroup(groupId);
+  await SessionUtilConvoInfoVolatile.removeLegacyGroupFromWrapper(groupId);
+  if (convo) {
+    await markGroupAsLeftOrKicked(groupId, convo, false);
+  }
 }
diff --git a/ts/session/conversations/createClosedGroup.ts b/ts/session/conversations/createClosedGroup.ts
index f95184578..e2fe7f308 100644
--- a/ts/session/conversations/createClosedGroup.ts
+++ b/ts/session/conversations/createClosedGroup.ts
@@ -8,11 +8,7 @@ import { openConversationWithMessages } from '../../state/ducks/conversations';
 import { updateConfirmModal } from '../../state/ducks/modalDialog';
 import { getSwarmPollingInstance } from '../apis/snode_api';
 import { SnodeNamespaces } from '../apis/snode_api/namespaces';
-import {
-  generateClosedGroupPublicKey,
-  generateCurve25519KeyPairWithoutPrefix,
-  generateGroupV3Keypair,
-} from '../crypto';
+import { generateClosedGroupPublicKey, generateCurve25519KeyPairWithoutPrefix } from '../crypto';
 import {
   ClosedGroupNewMessage,
   ClosedGroupNewMessageParams,
@@ -31,13 +27,14 @@ export async function createClosedGroup(groupName: string, members: Array<string
 
   const us = UserUtils.getOurPubKeyStrFromCache();
 
-  const identityKeyPair = await generateGroupV3Keypair();
-  if (!identityKeyPair) {
-    throw new Error('Could not create identity keypair for new closed group v3');
-  }
+  // const identityKeyPair = await generateGroupV3Keypair();
+  // if (!identityKeyPair) {
+  //   throw new Error('Could not create identity keypair for new closed group v3');
+  // }
 
   // a v3 pubkey starts with 03 and an old one starts with 05
-  const groupPublicKey = isV3 ? identityKeyPair.pubkey : await generateClosedGroupPublicKey();
+  const groupPublicKey = await generateClosedGroupPublicKey();
+  // const groupPublicKey = isV3 ? identityKeyPair.pubkey : await generateClosedGroupPublicKey();
 
   // the first encryption keypair is generated the same for all versions of closed group
   const encryptionKeyPair = await generateCurve25519KeyPairWithoutPrefix();
diff --git a/ts/session/crypto/MessageEncrypter.ts b/ts/session/crypto/MessageEncrypter.ts
index 14ed8db17..7f570a239 100644
--- a/ts/session/crypto/MessageEncrypter.ts
+++ b/ts/session/crypto/MessageEncrypter.ts
@@ -43,6 +43,7 @@ export async function encrypt(
       window?.log?.warn("Couldn't get key pair for closed group during encryption");
       throw new Error("Couldn't get key pair for closed group");
     }
+
     const hexPubFromECKeyPair = PubKey.cast(hexEncryptionKeyPair.publicHex);
 
     const cipherTextClosedGroup = await MessageEncrypter.encryptUsingSessionProtocol(
diff --git a/ts/session/group/closed-group.ts b/ts/session/group/closed-group.ts
index 153807e96..06559baec 100644
--- a/ts/session/group/closed-group.ts
+++ b/ts/session/group/closed-group.ts
@@ -2,34 +2,30 @@ import { PubKey } from '../types';
 
 import _ from 'lodash';
 
-import { fromHexToArray, toHex } from '../utils/String';
-import { BlockedNumberController } from '../../util/blockedNumberController';
-import { getConversationController } from '../conversations';
-import { Data } from '../../data/data';
 import { v4 as uuidv4 } from 'uuid';
-import { SignalService } from '../../protobuf';
-import { generateCurve25519KeyPairWithoutPrefix } from '../crypto';
-import { encryptUsingSessionProtocol } from '../crypto/MessageEncrypter';
-import { ECKeyPair } from '../../receiver/keypairs';
-import { UserUtils } from '../utils';
-import { ClosedGroupMemberLeftMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupMemberLeftMessage';
+import { getMessageQueue } from '..';
+import { Data } from '../../data/data';
 import { ConversationModel } from '../../models/conversation';
+import { ConversationAttributes, ConversationTypeEnum } from '../../models/conversationAttributes';
 import { MessageModel } from '../../models/message';
+import { SignalService } from '../../protobuf';
 import {
   addKeyPairToCacheAndDBIfNeeded,
   distributingClosedGroupEncryptionKeyPairs,
-  markGroupAsLeftOrKicked,
 } from '../../receiver/closedGroups';
-import { getMessageQueue } from '..';
+import { ECKeyPair } from '../../receiver/keypairs';
+import { BlockedNumberController } from '../../util/blockedNumberController';
+import { SnodeNamespaces } from '../apis/snode_api/namespaces';
+import { getConversationController } from '../conversations';
+import { generateCurve25519KeyPairWithoutPrefix } from '../crypto';
+import { encryptUsingSessionProtocol } from '../crypto/MessageEncrypter';
 import { ClosedGroupAddedMembersMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupAddedMembersMessage';
 import { ClosedGroupEncryptionPairMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupEncryptionPairMessage';
 import { ClosedGroupNameChangeMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupNameChangeMessage';
 import { ClosedGroupNewMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupNewMessage';
 import { ClosedGroupRemovedMembersMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupRemovedMembersMessage';
-import { getSwarmPollingInstance } from '../apis/snode_api';
-import { ConversationAttributes, ConversationTypeEnum } from '../../models/conversationAttributes';
-import { GetNetworkTime } from '../apis/snode_api/getNetworkTime';
-import { SnodeNamespaces } from '../apis/snode_api/namespaces';
+import { UserUtils } from '../utils';
+import { fromHexToArray, toHex } from '../utils/String';
 
 export type GroupInfo = {
   id: string;
@@ -258,66 +254,6 @@ export async function updateOrCreateClosedGroup(details: GroupInfo) {
   );
 }
 
-export async function leaveClosedGroup(groupId: string) {
-  const convo = getConversationController().get(groupId);
-
-  if (!convo || !convo.isClosedGroup()) {
-    window?.log?.error('Cannot leave non-existing group');
-    return;
-  }
-
-  const ourNumber = UserUtils.getOurPubKeyFromCache();
-  const isCurrentUserAdmin = convo.get('groupAdmins')?.includes(ourNumber.key);
-
-  let members: Array<string> = [];
-  let admins: Array<string> = [];
-
-  // if we are the admin, the group must be destroyed for every members
-  if (isCurrentUserAdmin) {
-    window?.log?.info('Admin left a closed group. We need to destroy it');
-    convo.set({ left: true });
-    members = [];
-    admins = [];
-  } else {
-    // otherwise, just the exclude ourself from the members and trigger an update with this
-    convo.set({ left: true });
-    members = (convo.get('members') || []).filter((m: string) => m !== ourNumber.key);
-    admins = convo.get('groupAdmins') || [];
-  }
-  convo.set({ members });
-  await convo.updateGroupAdmins(admins, false);
-  await convo.commit();
-
-  const source = UserUtils.getOurPubKeyStrFromCache();
-  const networkTimestamp = GetNetworkTime.getNowWithNetworkOffset();
-
-  const dbMessage = await convo.addSingleOutgoingMessage({
-    group_update: { left: [source] },
-    sent_at: networkTimestamp,
-    expireTimer: 0,
-  });
-  // Send the update to the group
-  const ourLeavingMessage = new ClosedGroupMemberLeftMessage({
-    timestamp: networkTimestamp,
-    groupId,
-    identifier: dbMessage.id as string,
-  });
-
-  window?.log?.info(`We are leaving the group ${groupId}. Sending our leaving message.`);
-  // sent the message to the group and once done, remove everything related to this group
-  getSwarmPollingInstance().removePubkey(groupId);
-  await getMessageQueue().sendToGroup({
-    message: ourLeavingMessage,
-    namespace: SnodeNamespaces.ClosedGroupMessage,
-    sentCb: async () => {
-      window?.log?.info(
-        `Leaving message sent ${groupId}. Removing everything related to this group.`
-      );
-      await markGroupAsLeftOrKicked(groupId, convo, false);
-    },
-  });
-}
-
 async function sendNewName(convo: ConversationModel, name: string, messageId: string) {
   if (name.length === 0) {
     window?.log?.warn('No name given for group update. Skipping');
diff --git a/ts/session/sending/MessageQueue.ts b/ts/session/sending/MessageQueue.ts
index 68c93254b..99eaf4f36 100644
--- a/ts/session/sending/MessageQueue.ts
+++ b/ts/session/sending/MessageQueue.ts
@@ -231,7 +231,11 @@ export class MessageQueue {
     pubkey,
   }: {
     pubkey: PubKey;
-    message: ClosedGroupNewMessage | CallMessage | SharedConfigMessage;
+    message:
+      | ClosedGroupNewMessage
+      | CallMessage
+      | SharedConfigMessage
+      | ClosedGroupMemberLeftMessage;
     namespace: SnodeNamespaces;
   }): Promise<boolean | number> {
     let rawMessage;
diff --git a/ts/session/utils/libsession/libsession_utils_user_groups.ts b/ts/session/utils/libsession/libsession_utils_user_groups.ts
index 65aea766b..ea7aa3d57 100644
--- a/ts/session/utils/libsession/libsession_utils_user_groups.ts
+++ b/ts/session/utils/libsession/libsession_utils_user_groups.ts
@@ -10,6 +10,7 @@ import {
 import { UserGroupsWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface';
 import { OpenGroupUtils } from '../../apis/open_group_api/utils';
 import { getConversationController } from '../../conversations';
+import { isEmpty } from 'lodash';
 
 /**
  * The key of this map is the convoId as stored in the database.
@@ -129,9 +130,18 @@ async function insertGroupsFromDBIntoWrapperAndRefresh(convoId: string): Promise
 
       try {
         window.log.debug(`inserting into usergroup wrapper "${foundConvo.id}"... }`);
+
         // this does the create or the update of the matching existing legacy group
 
-        await UserGroupsWrapperActions.setLegacyGroup(wrapperLegacyGroup);
+        if (
+          !isEmpty(wrapperLegacyGroup.name) &&
+          !isEmpty(wrapperLegacyGroup.encPubkey) &&
+          !isEmpty(wrapperLegacyGroup.encSeckey)
+        ) {
+          console.warn('inserting into user wrapper', wrapperLegacyGroup);
+          await UserGroupsWrapperActions.setLegacyGroup(wrapperLegacyGroup);
+        }
+
         await refreshCachedUserGroup(convoId);
       } catch (e) {
         window.log.warn(`UserGroupsWrapperActions.set of ${convoId} failed with ${e.message}`);
diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts
index b94781b67..8d25b4173 100644
--- a/ts/state/ducks/conversations.ts
+++ b/ts/state/ducks/conversations.ts
@@ -377,7 +377,7 @@ export const fetchTopMessagesForConversation = createAsyncThunk(
     const mostRecentMessage = await Data.getLastMessageInConversation(conversationKey);
 
     if (!oldestMessage || oldestMessage.id === oldTopMessageId) {
-      window.log.debug('fetchTopMessagesForConversation: we are already at the top');
+      // window.log.debug('fetchTopMessagesForConversation: we are already at the top');
       return null;
     }
     const messagesProps = await getMessages({
@@ -414,7 +414,7 @@ export const fetchBottomMessagesForConversation = createAsyncThunk(
     const mostRecentMessage = await Data.getLastMessageInConversation(conversationKey);
 
     if (!mostRecentMessage || mostRecentMessage.id === oldBottomMessageId) {
-      window.log.debug('fetchBottomMessagesForConversation: we are already at the bottom');
+      // window.log.debug('fetchBottomMessagesForConversation: we are already at the bottom');
       return null;
     }
     const messagesProps = await getMessages({
diff --git a/ts/util/privacy.ts b/ts/util/privacy.ts
index 9085d5fe8..2ab8a5e36 100644
--- a/ts/util/privacy.ts
+++ b/ts/util/privacy.ts
@@ -2,7 +2,7 @@
 
 // tslint:disable-next-line: no-submodule-imports
 import { compose } from 'lodash/fp';
-import { escapeRegExp, isRegExp, isString } from 'lodash';
+import { escapeRegExp, isNil, isRegExp, isString } from 'lodash';
 import { getAppRootPath } from '../node/getRootPath';
 
 const APP_ROOT_PATH = getAppRootPath();
@@ -98,9 +98,16 @@ const removeNewlines = (text: string) => text.replace(/\r?\n|\r/g, '');
 //      redactSensitivePaths :: String -> String
 const redactSensitivePaths = redactPath(APP_ROOT_PATH);
 
-const isDev = (process.env.NODE_APP_INSTANCE || '').startsWith('devprod');
+function shouldNotRedactLogs() {
+  // if the env variable `SESSION_NO_REDACT` is set, trust it as a boolean
+  if (!isNil(process.env.SESSION_NO_REDACT)) {
+    return process.env.SESSION_NO_REDACT;
+  }
+  // otherwise we don't want to redact logs when running on the devprod env
+  return (process.env.NODE_APP_INSTANCE || '').startsWith('devprod');
+}
 
 //      redactAll :: String -> String
-export const redactAll = !isDev
+export const redactAll = !shouldNotRedactLogs()
   ? compose(redactSensitivePaths, redactGroupIds, redactSessionID, redactSnodeIP, redactServerUrl)
   : (text: string) => text;