Skip to content
Merged
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ let package = Package(
)
],
dependencies: [
.package(url: "https://github.com/GetStream/stream-chat-swift.git", revision: "412e8a01c00481c60ae86e8184620023f136e0b7")
.package(url: "https://github.com/GetStream/stream-chat-swift.git", branch: "fix/mention-suggestions-large-channels")
],
targets: [
.target(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ import SwiftUI
channelController.sendKeystrokeEvent()
}
} else {
if composerCommand?.displayInfo?.isInstant == false {
// Mentions use `displayInfo == nil`, so clear any non-instant command
// (not only commands with `isInstant == false`).
if composerCommand?.displayInfo?.isInstant != true {
withAnimation(.easeInOut(duration: 0.2)) {
composerCommand = nil
}
}
selectedRangeLocation = 0
withAnimation(.easeInOut(duration: 0.2)) {
suggestions = [String: Any]()
}
clearComposerSuggestions()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
clearMentions()

if shouldDeleteDraftMessage(oldValue: oldValue) {
Expand Down Expand Up @@ -119,6 +119,7 @@ import SwiftUI
}
if oldValue != nil && composerCommand == nil {
pickerTypeState = .expanded(.none)
clearComposerSuggestions()
}
}
}
Expand Down Expand Up @@ -211,6 +212,8 @@ import SwiftUI
}

private var cancellables = Set<AnyCancellable>()
/// Tracks the in-flight suggestions request so stale results can be discarded.
private var suggestionsCancellable: AnyCancellable?
public lazy var commandsHandler = utils
.commandsConfig
.makeCommandsHandler(
Expand Down Expand Up @@ -1040,16 +1043,38 @@ import SwiftUI
}

private func showTypingSuggestions() {
if let composerCommand {
commandsHandler.showSuggestions(for: composerCommand)
.sink { _ in
log.debug("Finished showing suggestions")
} receiveValue: { [weak self] suggestionInfo in
withAnimation {
self?.suggestions[suggestionInfo.key] = suggestionInfo.value
}
suggestionsCancellable?.cancel()

guard let composerCommand else {
clearComposerSuggestions()
return
}

// Capture the query that started this request so a slower, superseded
// search cannot overwrite newer (or cleared) suggestions while deleting.
let expectedCommandId = composerCommand.id
let expectedTypingText = composerCommand.typingSuggestion.text

suggestionsCancellable = commandsHandler.showSuggestions(for: composerCommand)
.sink { _ in
log.debug("Finished showing suggestions")
} receiveValue: { [weak self] suggestionInfo in
Comment thread
nuno-vieira marked this conversation as resolved.
Outdated
guard let self else { return }
guard self.composerCommand?.id == expectedCommandId,
self.composerCommand?.typingSuggestion.text == expectedTypingText else {
return
}
withAnimation {
self.suggestions[suggestionInfo.key] = suggestionInfo.value
}
.store(in: &cancellables)
}
}

private func clearComposerSuggestions() {
suggestionsCancellable?.cancel()
commandsHandler.clearSuggestions()
withAnimation(.easeInOut(duration: 0.2)) {
suggestions = [String: Any]()
Comment thread
nuno-vieira marked this conversation as resolved.
Outdated
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ import SwiftUI
composerCommand: ComposerCommand,
completion: @escaping @MainActor (Error?) -> Void
)

/// Cancels any pending suggestion work and clears cached search results.
///
/// Called when the active command ends so a slower, superseded search cannot
/// resurface stale suggestions.
func clearSuggestions()
}

/// Default implementations.
Expand All @@ -83,6 +89,10 @@ extension CommandHandler {
public func canBeExecuted(composerCommand: ComposerCommand) -> Bool {
!composerCommand.typingSuggestion.text.isEmpty
}

public func clearSuggestions() {
// optional method.
}
}

/// Model for the composer's commands.
Expand Down Expand Up @@ -255,6 +265,12 @@ public class CommandsHandler: CommandHandler {
return StreamChatError.noSuggestionsAvailable.asFailedPromise()
}

public func clearSuggestions() {
for command in commands {
command.clearSuggestions()
}
}
Comment thread
nuno-vieira marked this conversation as resolved.

public func handleCommand(
for text: Binding<String>,
selectedRangeLocation: Binding<Int>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public final class MentionsCommandHandler: CommandHandler {

private let channelController: ChatChannelController
private let provider: MentionSuggestionsProvider
private var suggestionsTask: Task<Void, Never>?

/// Creates a new mentions command handler.
///
Expand Down Expand Up @@ -123,6 +124,12 @@ public final class MentionsCommandHandler: CommandHandler {
)
}

public func clearSuggestions() {
suggestionsTask?.cancel()
suggestionsTask = nil
Task { await provider.clearResults() }
}
Comment thread
nuno-vieira marked this conversation as resolved.

func mentionText(for suggestion: MentionSuggestion) -> String {
switch suggestion.kind {
case let userSuggestion as MentionSuggestion.User:
Expand All @@ -147,14 +154,16 @@ public final class MentionsCommandHandler: CommandHandler {
mentionRange: NSRange
) -> Future<SuggestionInfo, Error> {
let id = id
suggestionsTask?.cancel()
return Future { [weak self] promise in
guard let self else {
promise(.success(SuggestionInfo(key: id, value: [MentionSuggestion]())))
return
}
nonisolated(unsafe) let unsafePromise = promise
Task { @MainActor in
self.suggestionsTask = Task { @MainActor in
let suggestions = await self.makeSuggestions(for: typingMention)
guard !Task.isCancelled else { return }
unsafePromise(.success(SuggestionInfo(key: id, value: suggestions)))
}
}
Expand Down
4 changes: 2 additions & 2 deletions StreamChatSwiftUI.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1543,8 +1543,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/GetStream/stream-chat-swift.git";
requirement = {
kind = revision;
revision = 412e8a01c00481c60ae86e8184620023f136e0b7;
kind = branch;
branch = "fix/mention-suggestions-large-channels";
};
};
E3A1C01A282BAC66002D1E26 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,54 @@ import XCTest
XCTAssert(viewModel.mentionedUsers.isEmpty)
}

func test_messageComposerVM_clearingTextClearsMentionSuggestionsAndCommand() {
let viewModel = makeComposerViewModel()
viewModel.suggestions = ["mentions": [ChatUser.mock(id: "ios", name: "iOS")]]
viewModel.composerCommand = ComposerCommand(
id: "mentions",
typingSuggestion: TypingSuggestion(text: "i", locationRange: NSRange(location: 1, length: 1)),
displayInfo: nil
)

viewModel.text = ""

XCTAssertNil(viewModel.composerCommand)
XCTAssertTrue(viewModel.suggestions.isEmpty)
}

func test_messageComposerVM_endingMentionClearsSuggestions() {
let viewModel = makeComposerViewModel()
viewModel.selectedRangeLocation = 8
viewModel.text = "hello @i"
XCTAssertEqual(viewModel.composerCommand?.id, "mentions")

viewModel.selectedRangeLocation = 6
viewModel.text = "hello "

XCTAssertNil(viewModel.composerCommand)
XCTAssertTrue(viewModel.suggestions.isEmpty)
}

func test_messageComposerVM_deletingMentionQuery_doesNotShowStaleSuggestionsWhenEmpty() async {
let viewModel = makeComposerViewModel()
viewModel.selectedRangeLocation = 4
viewModel.text = "@iOS"
viewModel.selectedRangeLocation = 3
viewModel.text = "@iO"
viewModel.selectedRangeLocation = 2
viewModel.text = "@i"
viewModel.selectedRangeLocation = 1
viewModel.text = "@"
viewModel.selectedRangeLocation = 0
viewModel.text = ""

// Allow any in-flight / debounced suggestion request to finish.
try? await Task.sleep(nanoseconds: 800_000_000)

XCTAssertNil(viewModel.composerCommand)
XCTAssertTrue(viewModel.suggestions.isEmpty)
}

func test_checkForMentionedUsers_withUserSuggestion() {
// Given
let viewModel = makeComposerViewModel()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,30 @@ import XCTest
XCTAssertTrue(provider.receivedRequests.isEmpty)
}

func test_clearSuggestions_cancelsInFlightAndClearsProvider() async {
// Given
let provider = MockMentionSuggestionsProvider(suggestions: [.here], delayNanoseconds: 500_000_000)
let handler = makeHandler(provider: provider)
let expectation = expectation(description: "suggestions")
expectation.isInverted = true

let cancellable = handler.showSuggestions(for: mentionsCommand(text: "mar")).sink { _ in
} receiveValue: { _ in
expectation.fulfill()
}

// When
handler.clearSuggestions()

// Then
await fulfillment(of: [expectation], timeout: 0.2)
cancellable.cancel()

// Allow the async provider clear to run.
try? await Task.sleep(nanoseconds: 50_000_000)
XCTAssertEqual(provider.clearResultsCallCount, 1)
Comment thread
nuno-vieira marked this conversation as resolved.
}

// MARK: - private

private func makeHandler(provider: MentionSuggestionsProvider? = nil) -> MentionsCommandHandler {
Expand Down Expand Up @@ -328,18 +352,33 @@ private final class Box<Value> {
private final class MockMentionSuggestionsProvider: MentionSuggestionsProvider, @unchecked Sendable {
let suggestions: [MentionSuggestion]
let error: Error?
let delayNanoseconds: UInt64
private(set) var receivedRequests: [MentionSuggestionsRequest] = []
private(set) var clearResultsCallCount = 0

init(suggestions: [MentionSuggestion] = [], error: Error? = nil) {
init(
suggestions: [MentionSuggestion] = [],
error: Error? = nil,
delayNanoseconds: UInt64 = 0
) {
self.suggestions = suggestions
self.error = error
self.delayNanoseconds = delayNanoseconds
}

func mentionSuggestions(for request: MentionSuggestionsRequest) async throws -> [MentionSuggestion] {
receivedRequests.append(request)
if delayNanoseconds > 0 {
try await Task.sleep(nanoseconds: delayNanoseconds)
}
try Task.checkCancellation()
if let error {
throw error
}
return suggestions
}

func clearResults() async {
clearResultsCallCount += 1
}
Comment thread
nuno-vieira marked this conversation as resolved.
}
Loading