Skip to content
Merged
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
16 changes: 13 additions & 3 deletions clients/ios/DeeplineIOS/Sources/DeeplineAppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ final class DeeplineAppModel: ObservableObject {
try await self.ensureReachableServerBaseURL()
let envelopes = try await self.client.listMessages(baseURL: self.serverBaseURL, conversationId: conversationId)
let parsed = self.parseMessages(currentUserId: userId, envelopes: envelopes)
self.messages[conversationId] = parsed
self.replaceMessages(parsed, for: conversationId)
self.updateConversationPreview(conversationId: conversationId, preview: parsed.last?.body)
}
}
Expand Down Expand Up @@ -242,7 +242,7 @@ final class DeeplineAppModel: ObservableObject {
}

let newMessage = parseMessage(currentUserId: currentUserId, envelope: envelope)
messages[conversationId] = existingMessages + [newMessage]
replaceMessages(existingMessages + [newMessage], for: conversationId)
updateConversationPreview(conversationId: conversationId, preview: newMessage.body)
}

Expand Down Expand Up @@ -353,7 +353,7 @@ final class DeeplineAppModel: ObservableObject {
func loadGroupMembers(conversationId: String) async {
await performSilentTask { [self] in
let page = try await self.client.listConversationMembers(baseURL: self.serverBaseURL, conversationId: conversationId)
self.groupMembers[conversationId] = page.members
self.replaceGroupMembers(page.members, for: conversationId)
}
}

Expand Down Expand Up @@ -473,6 +473,16 @@ final class DeeplineAppModel: ObservableObject {
messages[conversationId] ?? []
}

private func replaceMessages(_ newMessages: [DeeplineMessage], for conversationId: String) {
guard (messages[conversationId] ?? []) != newMessages else { return }
messages[conversationId] = newMessages
}
Comment on lines +476 to +479

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The replaceMessages helper is a valid workaround for the reported Xcode 15 compiler issues. However, since loadMessages is called frequently via a polling loop (every 3 seconds in ChatRoomView), this method will trigger a UI refresh even if the messages haven't changed. Adding an equality check here can prevent unnecessary view updates and potential flickering.

Suggested change
private func replaceMessages(_ newMessages: [DeeplineMessage], for conversationId: String) {
var updatedMessages = messages
updatedMessages[conversationId] = newMessages
messages = updatedMessages
}
private func replaceMessages(_ newMessages: [DeeplineMessage], for conversationId: String) {
guard messages[conversationId] != newMessages else { return }
var updatedMessages = messages
updatedMessages[conversationId] = newMessages
messages = updatedMessages
}


private func replaceGroupMembers(_ newMembers: [GroupMember], for conversationId: String) {
guard (groupMembers[conversationId] ?? []) != newMembers else { return }
groupMembers[conversationId] = newMembers
}
Comment on lines +476 to +484

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The 'copy-modify-assign' pattern used here is a known workaround for Xcode 15 compiler issues, but it results in a full dictionary copy on every update, which can impact performance as the number of conversations grows. Using direct subscript assignment within these helpers should still resolve the compiler's type inference issues while maintaining O(1) performance. Additionally, adding a comment explaining the workaround and using consistent parameter naming (e.g., newMembers) will improve maintainability.

    private func replaceMessages(_ newMessages: [DeeplineMessage], for conversationId: String) {
        // Workaround for Xcode 15 compiler issues with @Published dictionary updates
        guard messages[conversationId] != newMessages else { return }
        messages[conversationId] = newMessages
    }

    private func replaceGroupMembers(_ newMembers: [GroupMember], for conversationId: String) {
        // Workaround for Xcode 15 compiler issues with @Published dictionary updates
        guard groupMembers[conversationId] != newMembers else { return }
        groupMembers[conversationId] = newMembers
    }


func primaryConversationId() -> String? {
chats.first?.id
}
Expand Down
2 changes: 1 addition & 1 deletion clients/ios/DeeplineIOS/Sources/DeeplineServerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ enum GroupRole: String, Codable {
case MEMBER
}

struct GroupMember: Codable, Identifiable {
struct GroupMember: Codable, Equatable, Identifiable {
let userId: String
let role: GroupRole
let addedByUserId: String?
Expand Down
3 changes: 2 additions & 1 deletion clients/ios/DeeplineIOS/Sources/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,7 @@ private struct ChatInputBar: View {
.foregroundStyle(draft.isEmpty ? DeeplineTheme.onSurfaceVariant(colorScheme) : .white)
}
}
.accessibilityLabel(draft.isEmpty ? "Voice message" : "Send")
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
Expand Down Expand Up @@ -1439,7 +1440,7 @@ private struct MemberRow: View {
)

VStack(alignment: .leading, spacing: 4) {
Text("User \(member.userId.suffix(4))")
Text("User \(String(member.userId.suffix(4)))")
.font(.subheadline.weight(.medium))
.foregroundStyle(DeeplineTheme.onSurface(colorScheme))

Expand Down
30 changes: 23 additions & 7 deletions clients/ios/DeeplineIOSUITests/DeeplineIOSUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,33 @@ final class DeeplineIOSUITests: XCTestCase {
let app = XCUIApplication()
app.launchArguments.append("-resetDeeplineState")
app.launchEnvironment["DEEPLINE_SERVER_URL"] = "http://localhost:9091"
addUIInterruptionMonitor(withDescription: "Notification permission") { alert in
let alertText = ([alert.label] + alert.staticTexts.allElementsBoundByIndex.map(\.label)).joined(separator: " ")
guard alertText.localizedCaseInsensitiveContains("notification") else { return false }
if alert.buttons["Allow"].exists {
alert.buttons["Allow"].tap()
} else if alert.buttons.count > 1 {
alert.buttons.element(boundBy: 1).tap()
} else if alert.buttons.count > 0 {
alert.buttons.element(boundBy: 0).tap()
} else {
return false
}
return true
}
Comment on lines +12 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The addUIInterruptionMonitor implementation is quite broad as it attempts to tap any button labeled "Allow" or fallback to the first/second button available on any alert. While this works for the notification permission prompt, it might inadvertently dismiss other unexpected system or app alerts, potentially masking issues or causing flakiness in CI. Consider verifying the alert's title or message content to ensure only the intended permission prompt is handled.

app.launch()
app.tap()

let setupButton = app.buttons["Set Up Local Identity"]
let setupButton = app.buttons["Get Started"]
XCTAssertTrue(setupButton.waitForExistence(timeout: 10))
setupButton.tap()

let displayNameField = app.textFields["Display name"]
XCTAssertTrue(displayNameField.waitForExistence(timeout: 10))
let displayNameField = app.textFields["Enter your name"]
XCTAssertTrue(displayNameField.waitForExistence(timeout: 15))
displayNameField.tap()
displayNameField.typeText("Codex Tester")

let deviceField = app.textFields["Device label"]
let deviceField = app.textFields["e.g. iPhone 15 Pro"]
XCTAssertTrue(deviceField.waitForExistence(timeout: 10))
deviceField.tap()
if let currentValue = deviceField.value as? String, !currentValue.isEmpty {
Expand All @@ -31,17 +46,18 @@ final class DeeplineIOSUITests: XCTestCase {

app.buttons["Create Identity"].tap()

let composer = app.textFields["Write a private note"]
let composer = app.textFields["Message"]
if !composer.waitForExistence(timeout: 20) {
let localNotes = app.staticTexts["Local Notes"]
XCTAssertTrue(localNotes.waitForExistence(timeout: 20))
localNotes.tap()
}
XCTAssertTrue(composer.waitForExistence(timeout: 10))
XCTAssertTrue(app.buttons["Send"].exists)
composer.tap()
composer.typeText("UITest secure note")
app.buttons["Send"].tap()
let sendButton = app.buttons["Send"]
XCTAssertTrue(sendButton.waitForExistence(timeout: 10))
sendButton.tap()
XCTAssertTrue(app.staticTexts["UITest secure note"].waitForExistence(timeout: 10))
}
}
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[versions]
agp = "8.7.3"
agp = "8.13.2"
androidx-activity-compose = "1.10.1"
firebase-messaging = "24.1.0"
androidx-core-ktx = "1.15.0"
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
Loading