diff --git a/clients/ios/DeeplineIOS/Sources/DeeplineAppModel.swift b/clients/ios/DeeplineIOS/Sources/DeeplineAppModel.swift index dffe00c..5b8506b 100644 --- a/clients/ios/DeeplineIOS/Sources/DeeplineAppModel.swift +++ b/clients/ios/DeeplineIOS/Sources/DeeplineAppModel.swift @@ -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) } } @@ -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) } @@ -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) } } @@ -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 + } + + private func replaceGroupMembers(_ newMembers: [GroupMember], for conversationId: String) { + guard (groupMembers[conversationId] ?? []) != newMembers else { return } + groupMembers[conversationId] = newMembers + } + func primaryConversationId() -> String? { chats.first?.id } diff --git a/clients/ios/DeeplineIOS/Sources/DeeplineServerClient.swift b/clients/ios/DeeplineIOS/Sources/DeeplineServerClient.swift index 79016dd..ab331b2 100644 --- a/clients/ios/DeeplineIOS/Sources/DeeplineServerClient.swift +++ b/clients/ios/DeeplineIOS/Sources/DeeplineServerClient.swift @@ -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? diff --git a/clients/ios/DeeplineIOS/Sources/RootView.swift b/clients/ios/DeeplineIOS/Sources/RootView.swift index 9b4632b..a9dbc0c 100644 --- a/clients/ios/DeeplineIOS/Sources/RootView.swift +++ b/clients/ios/DeeplineIOS/Sources/RootView.swift @@ -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) @@ -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)) diff --git a/clients/ios/DeeplineIOSUITests/DeeplineIOSUITests.swift b/clients/ios/DeeplineIOSUITests/DeeplineIOSUITests.swift index 39fafe0..268c598 100644 --- a/clients/ios/DeeplineIOSUITests/DeeplineIOSUITests.swift +++ b/clients/ios/DeeplineIOSUITests/DeeplineIOSUITests.swift @@ -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 + } 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 { @@ -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)) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1fbc541..967eca2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df97d72..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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