diff --git a/.drone.jsonnet b/.drone.jsonnet
index 1d204720a..07634ce4c 100644
--- a/.drone.jsonnet
+++ b/.drone.jsonnet
@@ -7,23 +7,6 @@ local clone_submodules = {
 // cmake options for static deps mirror
 local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https://oxen.rocks/deps ' else '');
 
-// xcpretty
-local install_xcpretty = {
-  name: 'Install XCPretty',
-  commands: [
-    |||
-      if [[ $(command -v brew) != "" ]]; then
-        brew install xcpretty
-      fi
-    |||,
-    |||
-      if [[ $(command -v brew) == "" ]]; then
-        gem install xcpretty
-      fi
-    |||,
-  ]
-};
-
 // Cocoapods
 // 
 // Unfortunately Cocoapods has a dumb restriction which requires you to use UTF-8 for the
@@ -35,81 +18,89 @@ local install_cocoapods = {
 
 
 [
+  // Unit tests
+  {
+    kind: 'pipeline',
+    type: 'exec',
+    name: 'Unit Tests',
+    platform: { os: 'darwin', arch: 'amd64' },
+    steps: [
+      clone_submodules,
+      // install_xcpretty,
+      install_cocoapods,
+      {
+        name: 'Run Unit Tests',
+        commands: [
+          'mkdir build',
+          |||
+            if command -v xcpretty >/dev/null 2>&1; then
+              'xcodebuild test -workspace Session.xcworkspace -scheme Session -destination "platform=iOS Simulator,name=iPhone 14 Pro" | xcpretty'
+            else
+              'xcodebuild test -workspace Session.xcworkspace -scheme Session -destination "platform=iOS Simulator,name=iPhone 14 Pro"'
+            fi
+          |||
+        ],
+      },
+    ],
+  },
+  // Simulator build
+  {
+    kind: 'pipeline',
+    type: 'exec',
+    name: 'Simulator Build',
+    platform: { os: 'darwin', arch: 'amd64' },
+    steps: [
+      clone_submodules,
+      install_cocoapods,
+      {
+        name: 'Build',
+        commands: [
+          'mkdir build',
+          |||
+            if command -v xcpretty >/dev/null 2>&1; then
+              xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration 'App Store Release' -sdk iphonesimulator -derivedDataPath ./build -archivePath ./build/Session_sim.xcarchive -destination 'generic/platform=iOS Simulator' | xcpretty
+            else
+              xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration 'App Store Release' -sdk iphonesimulator -derivedDataPath ./build -archivePath ./build/Session_sim.xcarchive -destination 'generic/platform=iOS Simulator'
+            fi
+          |||
+        ],
+      },
+      {
+        name: 'Upload artifacts',
+        commands: [
+          './Scripts/drone-static-upload.sh'
+        ]
+      },
+    ],
+  },
+  // AppStore build (generate an archive to be signed later)
   {
     kind: 'pipeline',
     type: 'exec',
-    name: 'Test Upload',
+    name: 'AppStore Build',
     platform: { os: 'darwin', arch: 'amd64' },
     steps: [
+      clone_submodules,
+      install_cocoapods,
+      {
+        name: 'Build',
+        commands: [
+          'mkdir build',
+          |||
+            if command -v xcpretty >/dev/null 2>&1; then
+              xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration 'App Store Release' -sdk iphoneos -derivedDataPath ./build -archivePath ./build/Session.xcarchive -destination 'generic/platform=iOS' | xcpretty
+            else
+              xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration 'App Store Release' -sdk iphoneos -derivedDataPath ./build -archivePath ./build/Session.xcarchive -destination 'generic/platform=iOS'
+            fi
+          |||
+        ],
+      },
       {
         name: 'Upload artifacts',
         commands: [
-          './.drone-static-upload.sh'
+          './Scripts/drone-static-upload.sh'
         ]
-      }
-    ]
+      },
+    ],
   },
-//  // Unit tests
-//  {
-//    kind: 'pipeline',
-//    type: 'exec',
-//    name: 'Unit Tests',
-//    platform: { os: 'darwin', arch: 'amd64' },
-//    steps: [
-//      clone_submodules,
-//      // install_xcpretty,
-//      install_cocoapods,
-//      {
-//        name: 'Run Unit Tests',
-//        commands: [
-//          'mkdir build',
-//          'xcodebuild test -workspace Session.xcworkspace -scheme Session -destination "platform=iOS Simulator,name=iPhone 14 Pro"' //  | xcpretty --report html'
-//        ],
-//      },
-//    ],
-//  },
-//  // Simulator build
-//  {
-//    kind: 'pipeline',
-//    type: 'exec',
-//    name: 'Simulator Build',
-//    platform: { os: 'darwin', arch: 'amd64' },
-//    steps: [
-//      clone_submodules,
-//      // install_xcpretty,
-//      install_cocoapods,
-//      {
-//        name: 'Build',
-//        commands: [
-//          'mkdir build',
-//          'xcodebuild -workspace Session.xcworkspace -scheme Session -configuration "App Store Release" -sdk iphonesimulator -derivedDataPath ./build -destination "generic/platform=iOS Simulator"' // | xcpretty'
-//        ],
-//      },
-//      {
-//        name: 'Upload artifacts',
-//        commands: [
-//          './.drone-static-upload.sh'
-//        ]
-//      }
-//    ],
-//  },
-//  // AppStore build (generate an archive to be signed later)
-//  {
-//    kind: 'pipeline',
-//    type: 'exec',
-//    name: 'AppStore Build',
-//    platform: { os: 'darwin', arch: 'amd64' },
-//    steps: [
-//      clone_submodules,
-//      // install_xcpretty,
-//      install_cocoapods,
-//      {
-//        name: 'Build',
-//        commands: [
-//          'mkdir build',
-//          'xcodebuild archive -workspace Session.xcworkspace -scheme Session -archivePath ./build/Session.xcarchive -destination "platform=generic/iOS" | xcpretty'
-//        ],
-//      },
-//    ],
-//  },
 ]
\ No newline at end of file
diff --git a/LibSession-Util b/LibSession-Util
index e0b994201..d8f07fa92 160000
--- a/LibSession-Util
+++ b/LibSession-Util
@@ -1 +1 @@
-Subproject commit e0b994201a016cc5bf9065526a0ceb4291f60d5a
+Subproject commit d8f07fa92c12c5c2409774e03e03395d7847d1c2
diff --git a/Scripts/LintLocalizableStrings.swift b/Scripts/LintLocalizableStrings.swift
index 12bd626aa..910179348 100755
--- a/Scripts/LintLocalizableStrings.swift
+++ b/Scripts/LintLocalizableStrings.swift
@@ -26,6 +26,7 @@ var pathFiles: [String] = {
     return fileUrls
         .filter {
             ((try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == false) && // No directories
+            !$0.path.contains("build/") &&                                // Exclude files under the build folder (CI)
             !$0.path.contains("Pods/") &&                                 // Exclude files under the pods folder
             !$0.path.contains(".xcassets") &&                             // Exclude asset bundles
             !$0.path.contains(".app/") &&                                 // Exclude files in the app build directories
diff --git a/.drone-static-upload.sh b/Scripts/drone-static-upload.sh
similarity index 83%
rename from .drone-static-upload.sh
rename to Scripts/drone-static-upload.sh
index b1f7c0887..4fd13faa5 100755
--- a/.drone-static-upload.sh
+++ b/Scripts/drone-static-upload.sh
@@ -31,10 +31,20 @@ fi
 mkdir -v "$base"
 
 # Copy over the build products
+prod_path="build/Session.xcarchive"
+sim_path="build/Session_sim.xcarchive/Products/Applications/Session.app"
+
 mkdir build
 echo "Test" > "build/test.txt"
-cp -av build/test.txt "$base"
-# cp -av build/Build/Products/App\ Store\ Release-iphonesimulator/Session.app "$base"
+
+if [ ! -d $prod_path ]; then
+    cp -av $prod_path "$base"
+else if [ ! -d $sim_path ]; then
+    cp -av $sim_path "$base"
+else
+    echo "Expected a file to upload, found none" >&2
+    exit 1
+fi
 
 # tar dat shiz up yo
 archive="$base.tar.xz"
@@ -54,9 +64,7 @@ for p in "${upload_dirs[@]}"; do
     mkdirs="$mkdirs
 -mkdir $dir_tmp"
 done
-if [ -e "$base-debug-symbols.tar.xz" ] ; then
-    put_debug="put $base-debug-symbols.tar.xz $upload_to"
-fi
+
 sftp -i ssh_key -b - -o StrictHostKeyChecking=off drone@oxen.rocks <<SFTP
 $mkdirs
 put $archive $upload_to
diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift
index db534d40b..e2a78ce64 100644
--- a/Session/Conversations/ConversationVC+Interaction.swift
+++ b/Session/Conversations/ConversationVC+Interaction.swift
@@ -453,6 +453,7 @@ extension ConversationVC:
             self?.snInputView.quoteDraftInfo = nil
 
             self?.resetMentions()
+            self?.scrollToBottom(isAnimated: false)
         }
 
         // Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can
@@ -481,64 +482,70 @@ extension ConversationVC:
             quoteModel: quoteModel
         )
         
-        // Actually send the message
-        Storage.shared
-            .writePublisher { [weak self] db in
-                // Update the thread to be visible (if it isn't already)
-                if self?.viewModel.threadData.threadShouldBeVisible == false {
-                    _ = try SessionThread
-                        .filter(id: threadId)
-                        .updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
-                }
-                
-                // Insert the interaction and associated it with the optimistically inserted message so
-                // we can remove it once the database triggers a UI update
-                let insertedInteraction: Interaction = try optimisticData.interaction.inserted(db)
-                self?.viewModel.associate(optimisticMessageId: optimisticData.id, to: insertedInteraction.id)
-                
-                // If there is a LinkPreview and it doesn't match an existing one then add it now
-                if
-                    let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft,
-                    (try? insertedInteraction.linkPreview.isEmpty(db)) == true
-                {
-                    try LinkPreview(
-                        url: linkPreviewDraft.urlString,
-                        title: linkPreviewDraft.title,
-                        attachmentId: try optimisticData.linkPreviewAttachment?.inserted(db).id
-                    ).insert(db)
-                }
-                
-                // If there is a Quote the insert it now
-                if let interactionId: Int64 = insertedInteraction.id, let quoteModel: QuotedReplyModel = quoteModel {
-                    try Quote(
-                        interactionId: interactionId,
-                        authorId: quoteModel.authorId,
-                        timestampMs: quoteModel.timestampMs,
-                        body: quoteModel.body,
-                        attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db)
-                    ).insert(db)
+        DispatchQueue.global(qos:.userInitiated).async {
+            // Generate the quote thumbnail if needed (want this to happen outside of the DBWrite thread as
+            // this can take up to 0.5s
+            let quoteThumbnailAttachment: Attachment? = quoteModel?.attachment?.cloneAsQuoteThumbnail()
+            
+            // Actually send the message
+            Storage.shared
+                .writePublisher { [weak self] db in
+                    // Update the thread to be visible (if it isn't already)
+                    if self?.viewModel.threadData.threadShouldBeVisible == false {
+                        _ = try SessionThread
+                            .filter(id: threadId)
+                            .updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
+                    }
+                    
+                    // Insert the interaction and associated it with the optimistically inserted message so
+                    // we can remove it once the database triggers a UI update
+                    let insertedInteraction: Interaction = try optimisticData.interaction.inserted(db)
+                    self?.viewModel.associate(optimisticMessageId: optimisticData.id, to: insertedInteraction.id)
+                    
+                    // If there is a LinkPreview and it doesn't match an existing one then add it now
+                    if
+                        let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft,
+                        (try? insertedInteraction.linkPreview.isEmpty(db)) == true
+                    {
+                        try LinkPreview(
+                            url: linkPreviewDraft.urlString,
+                            title: linkPreviewDraft.title,
+                            attachmentId: try optimisticData.linkPreviewAttachment?.inserted(db).id
+                        ).insert(db)
+                    }
+                    
+                    // If there is a Quote the insert it now
+                    if let interactionId: Int64 = insertedInteraction.id, let quoteModel: QuotedReplyModel = quoteModel {
+                        try Quote(
+                            interactionId: interactionId,
+                            authorId: quoteModel.authorId,
+                            timestampMs: quoteModel.timestampMs,
+                            body: quoteModel.body,
+                            attachmentId: try quoteThumbnailAttachment?.inserted(db).id
+                        ).insert(db)
+                    }
+                    
+                    // Process any attachments
+                    try Attachment.process(
+                        db,
+                        data: optimisticData.attachmentData,
+                        for: insertedInteraction.id
+                    )
+                    
+                    try MessageSender.send(
+                        db,
+                        interaction: insertedInteraction,
+                        threadId: threadId,
+                        threadVariant: threadVariant
+                    )
                 }
-                
-                // Process any attachments
-                try Attachment.process(
-                    db,
-                    data: optimisticData.attachmentData,
-                    for: insertedInteraction.id
-                )
-                
-                try MessageSender.send(
-                    db,
-                    interaction: insertedInteraction,
-                    threadId: threadId,
-                    threadVariant: threadVariant
+                .subscribe(on: DispatchQueue.global(qos: .userInitiated))
+                .sinkUntilComplete(
+                    receiveCompletion: { [weak self] _ in
+                        self?.handleMessageSent()
+                    }
                 )
-            }
-            .subscribe(on: DispatchQueue.global(qos: .userInitiated))
-            .sinkUntilComplete(
-                receiveCompletion: { [weak self] _ in
-                    self?.handleMessageSent()
-                }
-            )
+        }
     }
 
     func handleMessageSent() {
diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift
index d25342c47..6242fd410 100644
--- a/Session/Conversations/ConversationVC.swift
+++ b/Session/Conversations/ConversationVC.swift
@@ -899,9 +899,34 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
         
         // Store the 'sentMessageBeforeUpdate' state locally
         let didSendMessageBeforeUpdate: Bool = self.viewModel.sentMessageBeforeUpdate
+        let onlyReplacedOptimisticUpdate: Bool = {
+            // Replacing an optimistic update means making a delete and an insert, which will be done
+            // as separate changes at the same positions
+            guard
+                changeset.count > 1 &&
+                changeset[changeset.count - 2].elementDeleted == changeset[changeset.count - 1].elementInserted
+            else { return false }
+            
+            let deletedModels: [MessageViewModel] = changeset[changeset.count - 2]
+                .elementDeleted
+                .map { self.viewModel.interactionData[$0.section].elements[$0.element] }
+            let insertedModels: [MessageViewModel] = changeset[changeset.count - 1]
+                .elementInserted
+                .map { updatedData[$0.section].elements[$0.element] }
+            
+            // Make sure all the deleted models were optimistic updates, the inserted models were not
+            // optimistic updates and they have the same timestamps
+            return (
+                deletedModels.map { $0.id }.asSet() == [MessageViewModel.optimisticUpdateId] &&
+                insertedModels.map { $0.id }.asSet() != [MessageViewModel.optimisticUpdateId] &&
+                deletedModels.map { $0.timestampMs }.asSet() == insertedModels.map { $0.timestampMs }.asSet()
+            )
+        }()
         let wasOnlyUpdates: Bool = (
-            changeset.count == 1 &&
-            changeset[0].elementUpdated.count == changeset[0].changeCount
+            onlyReplacedOptimisticUpdate || (
+                changeset.count == 1 &&
+                changeset[0].elementUpdated.count == changeset[0].changeCount
+            )
         )
         self.viewModel.sentMessageBeforeUpdate = false
         
@@ -912,13 +937,13 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
         guard !didSendMessageBeforeUpdate && !wasOnlyUpdates else {
             self.viewModel.updateInteractionData(updatedData)
             self.tableView.reloadData()
-            self.tableView.layoutIfNeeded()
             
             // If we just sent a message then we want to jump to the bottom of the conversation instantly
             if didSendMessageBeforeUpdate {
                 // We need to dispatch to the next run loop because it seems trying to scroll immediately after
                 // triggering a 'reloadData' doesn't work
                 DispatchQueue.main.async { [weak self] in
+                    self?.tableView.layoutIfNeeded()
                     self?.scrollToBottom(isAnimated: false)
                     
                     // Note: The scroll button alpha won't get set correctly in this case so we forcibly set it to
diff --git a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift
index 1a3f6eef5..57ce17769 100644
--- a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift	
+++ b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift	
@@ -299,7 +299,7 @@ enum GiphyAPI {
                 return HTTPError.generic
             }
             .map { data, _ in
-                Logger.error("search request succeeded")
+                Logger.debug("search request succeeded")
                 
                 guard let imageInfos = self.parseGiphyImages(responseData: data) else {
                     Logger.error("unable to parse trending images")
@@ -347,7 +347,7 @@ enum GiphyAPI {
                 return HTTPError.generic
             }
             .tryMap { data, _ -> [GiphyImageInfo] in
-                Logger.error("search request succeeded")
+                Logger.debug("search request succeeded")
                 
                 guard let imageInfos = self.parseGiphyImages(responseData: data) else {
                     throw HTTPError.invalidResponse
diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift
index ad8f54c77..a9c4ffb8d 100644
--- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift
+++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift
@@ -39,8 +39,7 @@ public enum MessageSendJob: JobExecutor {
         /// already have attachments in a valid state
         if
             details.message is VisibleMessage,
-            (details.message as? VisibleMessage)?.reaction == nil &&
-            details.isSyncMessage == false
+            (details.message as? VisibleMessage)?.reaction == nil
         {
             guard
                 let jobId: Int64 = job.id,
@@ -51,122 +50,111 @@ public enum MessageSendJob: JobExecutor {
                 return
             }
             
-            // If the original interaction no longer exists then don't bother sending the message (ie. the
-            // message was deleted before it even got sent)
-            guard Storage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true else {
-                SNLog("[MessageSendJob] Failing due to missing interaction")
-                failure(job, StorageError.objectNotFound, true)
-                return
-            }
-            
-            // Check if there are any attachments associated to this message, and if so
-            // upload them now
-            //
-            // Note: Normal attachments should be sent in a non-durable way but any
-            // attachments for LinkPreviews and Quotes will be processed through this mechanism
-            let attachmentState: (shouldFail: Bool, shouldDefer: Bool, fileIds: [String])? = Storage.shared.write { db in
-                let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment
-                    .stateInfo(interactionId: interactionId)
-                    .fetchAll(db)
-                let maybeFileIds: [String?] = allAttachmentStateInfo
-                    .sorted { lhs, rhs in lhs.albumIndex < rhs.albumIndex }
-                    .map { Attachment.fileId(for: $0.downloadUrl) }
-                let fileIds: [String] = maybeFileIds.compactMap { $0 }
-                
-                // If there were failed attachments then this job should fail (can't send a
-                // message which has associated attachments if the attachments fail to upload)
-                guard !allAttachmentStateInfo.contains(where: { $0.state == .failedDownload }) else {
-                    return (true, false, fileIds)
-                }
-                
-                // Create jobs for any pending (or failed) attachment jobs and insert them into the
-                // queue before the current job (this will mean the current job will re-run
-                // after these inserted jobs complete)
-                //
-                // Note: If there are any 'downloaded' attachments then they also need to be
-                // uploaded (as a 'downloaded' attachment will be on the current users device
-                // but not on the message recipients device - both LinkPreview and Quote can
-                // have this case)
-                try allAttachmentStateInfo
-                    .filter { attachment -> Bool in
-                        // Non-media quotes won't have thumbnails so so don't try to upload them
-                        guard attachment.downloadUrl != Attachment.nonMediaQuoteFileId else { return false }
-                        
-                        switch attachment.state {
-                            case .uploading, .pendingDownload, .downloading, .failedUpload, .downloaded:
-                                return true
-                                
-                            default: return false
-                        }
-                    }
-                    .filter { stateInfo in
-                        // Don't add a new job if there is one already in the queue
-                        !JobRunner.hasPendingOrRunningJob(
-                            with: .attachmentUpload,
-                            details: AttachmentUploadJob.Details(
-                                messageSendJobId: jobId,
-                                attachmentId: stateInfo.attachmentId
-                            )
-                        )
-                    }
-                    .compactMap { stateInfo -> (jobId: Int64, job: Job)? in
-                        JobRunner
-                            .insert(
-                                db,
-                                job: Job(
-                                    variant: .attachmentUpload,
-                                    behaviour: .runOnce,
-                                    threadId: job.threadId,
-                                    interactionId: interactionId,
-                                    details: AttachmentUploadJob.Details(
-                                        messageSendJobId: jobId,
-                                        attachmentId: stateInfo.attachmentId
-                                    )
-                                ),
-                                before: job
-                            )
+            // Retrieve the current attachment state
+            typealias AttachmentState = (error: Error?, pendingUploadAttachmentIds: [String], preparedFileIds: [String])
+
+            let attachmentState: AttachmentState = Storage.shared
+                .read { db in
+                    // If the original interaction no longer exists then don't bother sending the message (ie. the
+                    // message was deleted before it even got sent)
+                    guard try Interaction.exists(db, id: interactionId) else {
+                        SNLog("[MessageSendJob] Failing due to missing interaction")
+                        return (StorageError.objectNotFound, [], [])
                     }
-                    .forEach { otherJobId, _ in
-                        // Create the dependency between the jobs
-                        try JobDependencies(
-                            jobId: jobId,
-                            dependantId: otherJobId
-                        )
-                        .insert(db)
+
+                    // Get the current state of the attachments
+                    let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment
+                        .stateInfo(interactionId: interactionId)
+                        .fetchAll(db)
+                    let maybeFileIds: [String?] = allAttachmentStateInfo
+                        .sorted { lhs, rhs in lhs.albumIndex < rhs.albumIndex }
+                        .map { Attachment.fileId(for: $0.downloadUrl) }
+                    let fileIds: [String] = maybeFileIds.compactMap { $0 }
+
+                    // If there were failed attachments then this job should fail (can't send a
+                    // message which has associated attachments if the attachments fail to upload)
+                    guard !allAttachmentStateInfo.contains(where: { $0.state == .failedDownload }) else {
+                        SNLog("[MessageSendJob] Failing due to failed attachment upload")
+                        return (AttachmentError.notUploaded, [], fileIds)
                     }
-                
-                // If there were pending or uploading attachments then stop here (we want to
-                // upload them first and then re-run this send job - the 'JobRunner.insert'
-                // method will take care of this)
-                let isMissingFileIds: Bool = (maybeFileIds.count != fileIds.count)
-                let hasPendingUploads: Bool = allAttachmentStateInfo.contains(where: { $0.state != .uploaded })
-                
-                return (
-                    (isMissingFileIds && !hasPendingUploads),
-                    hasPendingUploads,
-                    fileIds
-                )
-            }
-            
-            // Don't send messages with failed attachment uploads
-            //
-            // Note: If we have gotten to this point then any dependant attachment upload
-            // jobs will have permanently failed so this message send should also do so
-            guard attachmentState?.shouldFail == false else {
-                SNLog("[MessageSendJob] Failing due to failed attachment upload")
-                failure(job, AttachmentError.notUploaded, true)
-                return
+
+                    /// Find all attachmentIds for attachments which need to be uploaded
+                    ///
+                    /// **Note:** If there are any 'downloaded' attachments then they also need to be uploaded (as a
+                    /// 'downloaded' attachment will be on the current users device but not on the message recipients
+                    /// device - both `LinkPreview` and `Quote` can have this case)
+                    let pendingUploadAttachmentIds: [String] = allAttachmentStateInfo
+                        .filter { attachment -> Bool in
+                            // Non-media quotes won't have thumbnails so so don't try to upload them
+                            guard attachment.downloadUrl != Attachment.nonMediaQuoteFileId else { return false }
+
+                            switch attachment.state {
+                                case .uploading, .pendingDownload, .downloading, .failedUpload, .downloaded:
+                                    return true
+
+                                default: return false
+                            }
+                        }
+                        .map { $0.attachmentId }
+                    
+                    return (nil, pendingUploadAttachmentIds, fileIds)
+                }
+                .defaulting(to: (MessageSenderError.invalidMessage, [], []))
+
+            /// If we got an error when trying to retrieve the attachment state then this job is actually invalid so it
+            /// should permanently fail
+            guard attachmentState.error == nil else {
+                return failure(job, (attachmentState.error ?? MessageSenderError.invalidMessage), true)
             }
 
-            // Defer the job if we found incomplete uploads
-            guard attachmentState?.shouldDefer == false else {
-                SNLog("[MessageSendJob] Deferring pending attachment uploads")
-                deferred(job)
-                return
+            /// If we have any pending (or failed) attachment uploads then we should create jobs for them and insert them into the
+            /// queue before the current job and defer it (this will mean the current job will re-run after these inserted jobs complete)
+            guard attachmentState.pendingUploadAttachmentIds.isEmpty else {
+                Storage.shared.write { db in
+                    try attachmentState.pendingUploadAttachmentIds
+                        .filter { attachmentId in
+                            // Don't add a new job if there is one already in the queue
+                            !JobRunner.hasPendingOrRunningJob(
+                                with: .attachmentUpload,
+                                details: AttachmentUploadJob.Details(
+                                    messageSendJobId: jobId,
+                                    attachmentId: attachmentId
+                                )
+                            )
+                        }
+                        .compactMap { attachmentId -> (jobId: Int64, job: Job)? in
+                            JobRunner
+                                .insert(
+                                    db,
+                                    job: Job(
+                                        variant: .attachmentUpload,
+                                        behaviour: .runOnce,
+                                        threadId: job.threadId,
+                                        interactionId: interactionId,
+                                        details: AttachmentUploadJob.Details(
+                                            messageSendJobId: jobId,
+                                            attachmentId: attachmentId
+                                        )
+                                    ),
+                                    before: job
+                                )
+                        }
+                        .forEach { otherJobId, _ in
+                            // Create the dependency between the jobs
+                            try JobDependencies(
+                                jobId: jobId,
+                                dependantId: otherJobId
+                            )
+                            .insert(db)
+                        }
+                }
+
+                SNLog("[MessageSendJob] Deferring due to pending attachment uploads")
+                return deferred(job)
             }
-            
+
             // Store the fileIds so they can be sent with the open group message content
-            messageFileIds = (attachmentState?.fileIds ?? [])
+            messageFileIds = attachmentState.preparedFileIds
         }
         
         // Store the sentTimestamp from the message in case it fails due to a clockOutOfSync error
diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift
index 9005bf1b8..2893c8aa1 100644
--- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift	
+++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift	
@@ -606,6 +606,21 @@ public final class MessageSender {
                 )
                 
                 guard expectedAttachmentUploadCount == preparedSendData.totalAttachmentsUploaded else {
+                    // Make sure to actually handle this as a failure (if we don't then the message
+                    // won't go into an error state correctly)
+                    if let message: Message = preparedSendData.message {
+                        dependencies.storage.read { db in
+                            MessageSender.handleFailedMessageSend(
+                                db,
+                                message: message,
+                                with: .attachmentsNotUploaded,
+                                interactionId: preparedSendData.interactionId,
+                                isSyncMessage: (preparedSendData.isSyncMessage == true),
+                                using: dependencies
+                            )
+                        }
+                    }
+                    
                     return Fail(error: MessageSenderError.attachmentsNotUploaded)
                         .eraseToAnyPublisher()
                 }
@@ -992,7 +1007,6 @@ public final class MessageSender {
         isSyncMessage: Bool = false,
         using dependencies: SMKDependencies = SMKDependencies()
     ) -> Error {
-        // TODO: Revert the local database change
         // If the message was a reaction then we don't want to do anything to the original
         // interaciton (which the 'interactionId' is pointing to
         guard (message as? VisibleMessage)?.reaction == nil else { return error }
diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift
index 02b793160..3936baf4f 100644
--- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift	
+++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift	
@@ -14,7 +14,12 @@ public final class CurrentUserPoller: Poller {
 
     // MARK: - Settings
     
-    override var namespaces: [SnodeAPI.Namespace] { CurrentUserPoller.namespaces }
+    override var namespaces: [SnodeAPI.Namespace] {
+        // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
+        guard SessionUtil.userConfigsEnabled else { return [.default] }
+        
+        return CurrentUserPoller.namespaces
+    }
     
     /// After polling a given snode this many times we always switch to a new one.
     ///
diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift
index 562621a36..94a6c2ee4 100644
--- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift	
+++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift	
@@ -71,16 +71,3 @@ public struct QuotedReplyModel {
         )
     }
 }
-
-// MARK: - Convenience
-
-public extension QuotedReplyModel {
-    func generateAttachmentThumbnailIfNeeded(_ db: Database) throws -> String? {
-        guard let sourceAttachment: Attachment = self.attachment else { return nil }
-        
-        return try sourceAttachment
-            .cloneAsQuoteThumbnail()?
-            .inserted(db)
-            .id
-    }
-}
diff --git a/SessionMessagingKit/SessionUtil/SessionUtil.swift b/SessionMessagingKit/SessionUtil/SessionUtil.swift
index 62b77c9fe..d933238a5 100644
--- a/SessionMessagingKit/SessionUtil/SessionUtil.swift
+++ b/SessionMessagingKit/SessionUtil/SessionUtil.swift
@@ -10,9 +10,7 @@ import SessionUtilitiesKit
 
 public extension Features {
     static func useSharedUtilForUserConfig(_ db: Database? = nil) -> Bool {
-        return true
-        // TODO: Need to set this timestamp to the correct date (currently start of 2030)
-//        guard Date().timeIntervalSince1970 < 1893456000 else { return true }
+        guard Date().timeIntervalSince1970 < 1690761600 else { return true }
         guard !SessionUtil.hasCheckedMigrationsCompleted.wrappedValue else {
             return SessionUtil.userConfigsEnabledIgnoringFeatureFlag
         }
diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift
index 67d719d74..57fd9acc8 100644
--- a/SessionMessagingKit/Shared Models/MessageViewModel.swift	
+++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift	
@@ -527,6 +527,7 @@ public extension MessageViewModel {
 public extension MessageViewModel {
     static let genericId: Int64 = -1
     static let typingIndicatorId: Int64 = -2
+    static let optimisticUpdateId: Int64 = -3
     
     /// This init method is only used for system-created cells or empty states
     init(
@@ -634,8 +635,8 @@ public extension MessageViewModel {
         
         // Interaction Info
         
-        self.rowId = -1
-        self.id = -1
+        self.rowId = MessageViewModel.optimisticUpdateId
+        self.id = MessageViewModel.optimisticUpdateId
         self.openGroupServerMessageId = nil
         self.variant = .standardOutgoing
         self.timestampMs = timestampMs