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: "e734b326e5c6959efa1107bcc63df939dd6a3beb")
.package(url: "https://github.com/GetStream/stream-chat-swift.git", revision: "8263bddc2f9395fb1c4a275c86db711ceda954ed")
],
targets: [
.target(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,20 @@
channelController.sendKeystrokeEvent()
}
} else {
if composerCommand?.displayInfo?.isInstant == false {
// Mentions use `displayInfo == nil`, so clear any non-instant command
// (not only commands with `isInstant == false`).
// When a command is cleared here, `composerCommand` didSet clears suggestions;
// otherwise clear them explicitly (instant command kept, or no command).
let clearingCommand = composerCommand != nil
&& composerCommand?.displayInfo?.isInstant != true
if clearingCommand {
withAnimation(.easeInOut(duration: 0.2)) {
composerCommand = nil
}
} else {
clearComposerSuggestions()
}
selectedRangeLocation = 0
withAnimation(.easeInOut(duration: 0.2)) {
suggestions = [String: Any]()
}
clearMentions()

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

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 +1048,39 @@
}

private func showTypingSuggestions() {
if let composerCommand {
commandsHandler.showSuggestions(for: composerCommand)
.sink { _ in
log.debug("Finished showing suggestions")
} receiveValue: { [weak self] suggestionInfo in
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(
receiveCompletion: { _ in },

Check failure on line 1065 in Sources/StreamChatSwiftUI/ChatComposer/MessageComposerViewModel.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this closure is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-swiftui&issues=AZ_9Iz3gnox61m2X2V2w&open=AZ_9Iz3gnox61m2X2V2w&pullRequest=1568
receiveValue: { [weak self] suggestionInfo in
guard let self else { return }
guard self.composerCommand?.id == expectedCommandId,
self.composerCommand?.typingSuggestion.text == expectedTypingText else {
return
}
withAnimation {
self?.suggestions[suggestionInfo.key] = suggestionInfo.value
self.suggestions[suggestionInfo.key] = suggestionInfo.value
}
}
.store(in: &cancellables)
)
}

private func clearComposerSuggestions() {
suggestionsCancellable?.cancel()
commandsHandler.clearSuggestions()
withAnimation(.easeInOut(duration: 0.2)) {
suggestions.removeAll()
}
}

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
2 changes: 1 addition & 1 deletion StreamChatSwiftUI.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1544,7 +1544,7 @@
repositoryURL = "https://github.com/GetStream/stream-chat-swift.git";
requirement = {
kind = revision;
revision = e734b326e5c6959efa1107bcc63df939dd6a3beb;
revision = 8263bddc2f9395fb1c4a275c86db711ceda954ed;
};
};
E3A1C01A282BAC66002D1E26 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,63 @@ 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 requestStarted = expectation(description: "delayed mention request started")
let requestFinished = expectation(description: "delayed mention request finished")
let provider = DelayedMentionSuggestionsProvider(
suggestions: [.user(.mock(id: "ios", name: "iOS"))],
delayNanoseconds: 100_000_000,
onStarted: { requestStarted.fulfill() },
onFinished: { requestFinished.fulfill() }
)
let viewModel = MessageComposerViewModel(
channelController: makeChannelController(),
messageController: nil
)
viewModel.utils = Utils(
commandsConfig: MentionsProviderCommandsConfig(provider: provider)
)

viewModel.selectedRangeLocation = 4
viewModel.text = "@iOS"
await fulfillment(of: [requestStarted], timeout: defaultTimeout)

viewModel.selectedRangeLocation = 0
viewModel.text = ""
await fulfillment(of: [requestFinished], timeout: defaultTimeout)

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

func test_checkForMentionedUsers_withUserSuggestion() {
// Given
let viewModel = makeComposerViewModel()
Expand Down Expand Up @@ -2791,3 +2848,52 @@ enum MessageComposerTestUtils {
)
}
}

private final class MentionsProviderCommandsConfig: CommandsConfig {
let mentionsSymbol = "@"
let instantCommandsSymbol = "/"
private let provider: MentionSuggestionsProvider

init(provider: MentionSuggestionsProvider) {
self.provider = provider
}

func makeCommandsHandler(with channelController: ChatChannelController) -> CommandsHandler {
let mentionsCommandHandler = MentionsCommandHandler(
channelController: channelController,
commandSymbol: mentionsSymbol,
provider: provider
)
return DefaultCommandsConfig.makeCommandsHandler(
mentionsCommandHandler: mentionsCommandHandler,
channelController: channelController
)
}
}

private final class DelayedMentionSuggestionsProvider: MentionSuggestionsProvider, @unchecked Sendable {
private let suggestions: [MentionSuggestion]
private let delayNanoseconds: UInt64
private let onStarted: @Sendable () -> Void
private let onFinished: @Sendable () -> Void

init(
suggestions: [MentionSuggestion],
delayNanoseconds: UInt64,
onStarted: @escaping @Sendable () -> Void,
onFinished: @escaping @Sendable () -> Void
) {
self.suggestions = suggestions
self.delayNanoseconds = delayNanoseconds
self.onStarted = onStarted
self.onFinished = onFinished
}

func mentionSuggestions(for request: MentionSuggestionsRequest) async throws -> [MentionSuggestion] {
onStarted()
defer { onFinished() }
try await Task.sleep(nanoseconds: delayNanoseconds)
try Task.checkCancellation()
return suggestions
}
}
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