Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Signal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,7 @@
4C6E6C6924241C00009DE948 /* ConversationViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C6E6C6824241C00009DE948 /* ConversationViewControllerTest.swift */; };
4C751BE523FA0284002A8AF1 /* ContactSupportActionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C751BE423FA0284002A8AF1 /* ContactSupportActionSheet.swift */; };
4C83AC4223C55D9C00D4F2E6 /* SignalBaseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C83AC4123C55D9C00D4F2E6 /* SignalBaseTest.swift */; };
C0D1E5012F0C000000000001 /* ThreadUtilSignalUITest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D1E5002F0C000000000001 /* ThreadUtilSignalUITest.swift */; };
4C8A6DFC22E5499300469AE7 /* MediaZoomAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8A6DFB22E5499300469AE7 /* MediaZoomAnimationController.swift */; };
4C8A6DFE22E54AFA00469AE7 /* MediaInteractiveDismiss.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8A6DFD22E54AFA00469AE7 /* MediaInteractiveDismiss.swift */; };
4C9D347B23679C25006A4307 /* ContactStreamTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9D347923679C13006A4307 /* ContactStreamTest.swift */; };
Expand Down Expand Up @@ -4828,6 +4829,7 @@
4C6F527B20FFE8400097DEEE /* SignalUBSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalUBSan.supp; sourceTree = "<group>"; };
4C751BE423FA0284002A8AF1 /* ContactSupportActionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSupportActionSheet.swift; sourceTree = "<group>"; };
4C83AC4123C55D9C00D4F2E6 /* SignalBaseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalBaseTest.swift; sourceTree = "<group>"; };
C0D1E5002F0C000000000001 /* ThreadUtilSignalUITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadUtilSignalUITest.swift; sourceTree = "<group>"; };
4C8A6DFB22E5499300469AE7 /* MediaZoomAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaZoomAnimationController.swift; sourceTree = "<group>"; };
4C8A6DFD22E54AFA00469AE7 /* MediaInteractiveDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaInteractiveDismiss.swift; sourceTree = "<group>"; };
4C9D347923679C13006A4307 /* ContactStreamTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactStreamTest.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -11645,6 +11647,7 @@
50791B1B2D037A7800D747F8 /* RecipientPickers */,
661278052996BA6700A1D5A1 /* Registration */,
040507132F80639B0078B769 /* RemoteReleaseNotes */,
C0D1E5022F0C000000000001 /* Sending */,
4C3EF8002109184A0007EBF7 /* SSKTests */,
D97046082E81D5B60034C05D /* Storage */,
E75DD3DC2810CD3500E32C36 /* subscriptions */,
Expand All @@ -11668,6 +11671,14 @@
path = contact;
sourceTree = "<group>";
};
C0D1E5022F0C000000000001 /* Sending */ = {
isa = PBXGroup;
children = (
C0D1E5002F0C000000000001 /* ThreadUtilSignalUITest.swift */,
);
path = Sending;
sourceTree = "<group>";
};
B660F69D1C29868000687D6E /* Supporting Files */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -18874,6 +18885,7 @@
4C3EF802210918740007EBF7 /* SSKProtoEnvelopeTest.swift in Sources */,
8803FF6628EF89B50023574A /* StorySharingTests.swift in Sources */,
E75DD3E02810CDBD00E32C36 /* SubscriptionModelsTest.swift in Sources */,
C0D1E5012F0C000000000001 /* ThreadUtilSignalUITest.swift in Sources */,
5033D47329DCB3FF007FEADA /* UrlOpenerTest.swift in Sources */,
45A3579827DAAC6A0051CE8B /* UserProfileTest.swift in Sources */,
5042EAA3287F96FB00C9B19F /* VisibleBadgeResolverTest.swift in Sources */,
Expand Down
6 changes: 6 additions & 0 deletions Signal/ConversationView/ConversationInputToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2001,6 +2001,12 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
return linkPreviewFetchState.linkPreviewDraftIfLoaded
}

func consumeLinkPreviewDraftForSendingTask() -> Task<OWSLinkPreviewDraft?, Never>? {
AssertIsOnMainThread()

return linkPreviewFetchState.consumeLinkPreviewDraftForSendingTask()
}

private func updateInputLinkPreview() {
AssertIsOnMainThread()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ extension ConversationViewController: ConversationInputToolbarDelegate {
// If its cleared, "change" it to nothing (clear it).
quotedReplyEdit: inputToolbar.quotedReplyDraft == nil ? .change(()) : .keep,
linkPreviewDraft: inputToolbar.linkPreviewDraft,
linkPreviewDraftForSending: inputToolbar.consumeLinkPreviewDraftForSendingTask(),
editTarget: editTarget,
persistenceCompletionHandler: {
AssertIsOnMainThread()
Expand All @@ -148,6 +149,7 @@ extension ConversationViewController: ConversationInputToolbarDelegate {
thread: self.thread,
quotedReplyDraft: inputToolbar.quotedReplyDraft,
linkPreviewDraft: inputToolbar.linkPreviewDraft,
linkPreviewDraftForSending: inputToolbar.consumeLinkPreviewDraftForSendingTask(),
persistenceCompletionHandler: {
AssertIsOnMainThread()
self.loadCoordinator.enqueueReload()
Expand Down
147 changes: 147 additions & 0 deletions Signal/test/Sending/ThreadUtilSignalUITest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient
import XCTest

@testable import SignalServiceKit
@testable import SignalUI

final class ThreadUtilSignalUITest: SignalBaseTest {

private var thread: TSContactThread!

@MainActor
override func setUp() {
super.setUp()

ThreadUtil.enqueueSendQueue = SerialTaskQueue()
ThreadUtil.sendFinalizationQueue = SerialTaskQueue()

write { tx in
(DependenciesBridge.shared.registrationStateChangeManager as! RegistrationStateChangeManagerImpl)
.registerForTests(localIdentifiers: .forUnitTests, tx: tx)
SSKPreferences.setAreIntentDonationsEnabled(false, transaction: tx)

self.thread = TSContactThread.getOrCreateThread(
withContactAddress: SignalServiceAddress(Aci.randomForTesting()),
transaction: tx,
)
}
}

@MainActor
override func tearDown() {
ThreadUtil.enqueueSendQueue = SerialTaskQueue()
ThreadUtil.sendFinalizationQueue = SerialTaskQueue()

super.tearDown()
}

func testPendingLinkPreviewDoesNotBlockOutgoingRowPersistence() async throws {
let previewContinuation = AtomicValue<CheckedContinuation<OWSLinkPreviewDraft?, Never>?>(nil, lock: .init())
let previewTask = Task<OWSLinkPreviewDraft?, Never> {
await withCheckedContinuation { continuation in
previewContinuation.set(continuation)
}
}
let previewTaskIsWaiting = await waitUntil { previewContinuation.get() != nil }
XCTAssertTrue(previewTaskIsWaiting)

let firstMessageDidPersist = AtomicValue(false, lock: .init())
ThreadUtil.enqueueMessage(
body: MessageBody(text: "https://signal.org", ranges: .empty),
thread: thread,
linkPreviewDraftForSending: previewTask,
persistenceCompletionHandler: { firstMessageDidPersist.set(true) },
)

let firstMessagePersisted = await waitUntil(firstMessageDidPersist.get)
XCTAssertTrue(
firstMessagePersisted,
"The outgoing row should be visible before the pending preview resolves.",
)
if !firstMessagePersisted {
return
}

var outgoingMessages = self.outgoingMessages()
XCTAssertEqual(outgoingMessages.count, 1)
XCTAssertNil(outgoingMessages.first?.linkPreview)

let secondMessageDidPersist = AtomicValue(false, lock: .init())
ThreadUtil.enqueueMessage(
body: MessageBody(text: "second message", ranges: .empty),
thread: thread,
persistenceCompletionHandler: { secondMessageDidPersist.set(true) },
)

let secondMessagePersisted = await waitUntil(secondMessageDidPersist.get)
XCTAssertTrue(
secondMessagePersisted,
"A pending preview finalization should not block later outgoing rows from appearing.",
)
if !secondMessagePersisted {
return
}
outgoingMessages = self.outgoingMessages()
XCTAssertEqual(outgoingMessages.count, 2)
XCTAssertFalse(
self.messageSenderJobMessageIds().contains(outgoingMessages[0].uniqueId),
"The pending-preview message should hold an in-memory ordering barrier until finalization, not a runnable durable sender job.",
)

previewContinuation.swap(nil)?.resume(returning: OWSLinkPreviewDraft(
url: try XCTUnwrap(URL(string: "https://signal.org")),
title: "Signal",
isForwarded: false,
))

try await ThreadUtil.enqueueSendQueue.enqueue(operation: {}).value
try await ThreadUtil.sendFinalizationQueue.enqueue(operation: {}).value

outgoingMessages = self.outgoingMessages()
XCTAssertEqual(outgoingMessages.count, 2)
XCTAssertEqual(outgoingMessages.first?.linkPreview?.urlString, "https://signal.org")
XCTAssertEqual(outgoingMessages.first?.linkPreview?.title, "Signal")
}

private func outgoingMessages() -> [TSOutgoingMessage] {
read { tx in
try! InteractionFinder(threadUniqueId: thread.uniqueId)
.fetchAllInteractions(rowIdFilter: .newest, limit: Int.max, tx: tx)
.compactMap { $0 as? TSOutgoingMessage }
.sorted { $0.timestamp < $1.timestamp }
}
}

private func messageSenderJobMessageIds() -> [String] {
read { tx in
MessageSenderJobRecord.anyFetchAll(transaction: tx)
.sorted { ($0.id ?? 0) < ($1.id ?? 0) }
.compactMap { jobRecord -> String? in
guard case .persisted(let messageId, _) = jobRecord.messageType else {
return nil
}
return messageId
}
}
}

private func waitUntil(
timeout: TimeInterval = 1,
_ predicate: @escaping () -> Bool,
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if predicate() {
return true
}
try? await Task.sleep(nanoseconds: 10 * NSEC_PER_MSEC)
}

return predicate()
}
}
Loading