Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7180fb1
admin promotion view + flow start
David-Henner May 27, 2026
cf0a7d1
add alerts
David-Henner May 27, 2026
1b20d9e
localization
David-Henner May 27, 2026
6d1fac5
update view model
David-Henner May 27, 2026
afdf348
update action controller
David-Henner May 27, 2026
932b498
add developer flag
David-Henner Jun 3, 2026
3be09e6
add error string
David-Henner Jun 3, 2026
23b8a0b
update flow
David-Henner Jun 3, 2026
356e135
add tests
David-Henner Jun 3, 2026
268285d
simplify view states
David-Henner Jun 3, 2026
5dd9280
UI tests
David-Henner Jun 8, 2026
3ae2d98
format
David-Henner Jun 8, 2026
6ea3843
update locator
David-Henner Jun 8, 2026
27275c5
accessibility
David-Henner Jun 8, 2026
ba2871f
cleanup
David-Henner Jun 8, 2026
6fb2119
accessibility
David-Henner Jun 9, 2026
d23db51
fix threading issue
David-Henner Jun 9, 2026
fd77607
code review
David-Henner Jun 10, 2026
c5cc7ea
add testiny ID
David-Henner Jun 10, 2026
2ba720d
Update wire-ios/Wire-iOS/Sources/UserInterface/GroupDetails/AdminSele…
David-Henner Jun 15, 2026
0fde319
code review
David-Henner Jun 15, 2026
39cf438
fix localized strings
David-Henner Jun 15, 2026
d89e970
add e2e tests
David-Henner Jun 15, 2026
1ff5255
fix typo
David-Henner Jun 16, 2026
aac8850
code review
David-Henner Jun 22, 2026
af4b278
fix warnings
David-Henner Jun 22, 2026
efbd465
tweak tests timeout
David-Henner Jun 22, 2026
3cb8b94
update locators
David-Henner Jun 22, 2026
3cc0e2a
fix accessibility issues
David-Henner Jun 22, 2026
ec0239b
accessibility
David-Henner Jun 22, 2026
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
13 changes: 12 additions & 1 deletion WireUI/Sources/WireLocators/Locators.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,9 @@ public enum Locators {
case addParticipantsButton
case moreOptionsButton
case userCellName
case adminCell
case memberCell
case close

}

public enum ConversationDetailsActions: AutoPrefixedEnum {
Expand All @@ -217,6 +218,16 @@ public enum Locators {
case leaveConversation
}

public enum LastAdminLeaveAlert: AutoPrefixedEnum {
case promoteNewAdmin
case deleteGroup
}

public enum AdminSelectionPage: AutoPrefixedEnum {
case promoteButton
case userCell
}

public enum UserProfilePage: AutoPrefixedEnum {

case name
Expand Down
4 changes: 4 additions & 0 deletions wire-ios-utilities/Source/DeveloperFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public enum DeveloperFlag: String, CaseIterable {
case forceDatabaseLoadingFailure
case ignoreIncomingEvents
case newRegistration
case preventAdminlessGroups
case showCreateMLSGroupToggle
case showUnreadConversationsFilter
case skipMLSMessagesDecryption
Expand Down Expand Up @@ -80,6 +81,9 @@ public enum DeveloperFlag: String, CaseIterable {
case .newRegistration:
"Turn on to use the new registration flow"

case .preventAdminlessGroups:
"Turn on to prevent last admins from leaving groups without promoting someone else"

case .showUnreadConversationsFilter:
"Turn on to show the new conversation filter options"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//
// Wire
// Copyright (C) 2026 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Testing
@testable import Wire

@Suite @MainActor
struct AdminSelectionViewModelTests {

// MARK: - filteredCandidates

@Test(
"filteredCandidates returns expected count",
arguments: [
("", 3),
("alice", 1),
("ALICE", 1),
("bob", 1),
("@bob", 1),
("zzz_no_match", 0),
("@al", 1)
] as [(String, Int)]
)
func filteredCandidates_count(query: String, expectedCount: Int) {
let sut = Scaffolding.makeViewModel()
sut.searchQuery = query
#expect(sut.filteredCandidates.count == expectedCount)
}

@Test(
"filteredCandidates returns correct first candidate",
arguments: [
("alice", "Alice"),
("ALICE", "Alice"),
("bob", "Bob"),
("@bob", "Bob"),
("@al", "Alice")
] as [(String, String)]
)
func filteredCandidates_firstCandidate(query: String, expectedName: String) {
let sut = Scaffolding.makeViewModel()
sut.searchQuery = query
#expect(sut.filteredCandidates.first?.name == expectedName)
}

// MARK: - canPromote

@Test("canPromote is false when no user is selected")
func canPromote_isFalse_initially() {
let sut = Scaffolding.makeViewModel()
#expect(sut.canPromote == false)
}

@Test("canPromote is true when a user is selected")
func canPromote_isTrue_whenUserSelected() {
let sut = Scaffolding.makeViewModel()
sut.selectedUser = Scaffolding.candidates.first
#expect(sut.canPromote)
}

@Test("canPromote is false after deselecting a user")
func canPromote_isFalse_afterDeselectingUser() {
let sut = Scaffolding.makeViewModel()
sut.selectedUser = Scaffolding.candidates.first
sut.selectedUser = nil
#expect(sut.canPromote == false)
}

@Test("canPromote is false while promotion is in progress")
func canPromote_isFalse_whileInProgress() {
let sut = Scaffolding.makeViewModel()
sut.selectedUser = Scaffolding.candidates.first
sut.promotionState = .inProgress
#expect(sut.canPromote == false)
}

// MARK: - promote

@Test("promote calls onPromote with the selected user")
func promote_callsOnPromoteWithCorrectUser() async {
var invokedUser: UserType?
let sut = Scaffolding.makeViewModel(onPromote: { user in invokedUser = user })
let user = Scaffolding.candidates[0]
await sut.promote(user: user)
#expect(invokedUser?.remoteIdentifier == user.remoteIdentifier)
}

@Test("promote sets state to succeeded when onPromote succeeds")
func promote_setsStateToSucceeded_onSuccess() async {
let sut = Scaffolding.makeViewModel()
await sut.promote(user: Scaffolding.candidates[0])
#expect(sut.promotionState == .succeeded)
}

@Test("promote sets state to failed when onPromote throws")
func promote_setsStateToFailed_onFailure() async {
enum TestError: Error { case failed }
let sut = Scaffolding.makeViewModel(onPromote: { _ in throw TestError.failed })
await sut.promote(user: Scaffolding.candidates[0])
#expect(sut.promotionState == .failed)
}
}

// MARK: - Scaffolding

private extension AdminSelectionViewModelTests {

enum Scaffolding {

static let candidates: [UserType] = {
let alice = MockUserType.createUser(name: "Alice")
alice.handle = "alice"

let bob = MockUserType.createUser(name: "Bob")
bob.handle = "bob"

let carol = MockUserType.createUser(name: "Carol")
carol.handle = "carol"

return [alice, bob, carol]
}()

@MainActor
static func makeViewModel(
onPromote: @escaping @MainActor (UserType) async throws -> Void = { _ in }

Check failure on line 140 in wire-ios/Wire-iOS UnitTests/UserInterface/GroupDetails/AdminSelectionViewModelTests.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=wireapp_wire-ios&issues=AZ6sA6SLyAo-BqSG10LS&open=AZ6sA6SLyAo-BqSG10LS&pullRequest=4829
) -> AdminSelectionViewModel {
AdminSelectionViewModel(
candidates: candidates,
userSession: UserSessionMock(),
onPromote: onPromote
)
}
}
}
42 changes: 42 additions & 0 deletions wire-ios/Wire-iOS/Generated/Strings+Generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ internal enum L10n {
internal static let description = L10n.tr("Accessibility", "addParticipantsConversationSettings.closeButton.description", fallback: "Close add participants option")
}
}
internal enum AdminSelection {
internal enum CandidateRow {
/// Double tap to select as new admin
internal static let hint = L10n.tr("Accessibility", "adminSelection.candidateRow.hint", fallback: "Double tap to select as new admin")
}
internal enum DeleteGroupButton {
/// Deletes the group permanently
internal static let hint = L10n.tr("Accessibility", "adminSelection.deleteGroupButton.hint", fallback: "Deletes the group permanently")
}
internal enum SearchBar {
internal enum ClearButton {
/// Clear search
internal static let description = L10n.tr("Accessibility", "adminSelection.searchBar.clearButton.description", fallback: "Clear search")
}
}
}
internal enum AdvancedSettings {
internal enum BackButton {
/// Go back to Advanced
Expand Down Expand Up @@ -1249,6 +1265,16 @@ internal enum L10n {
}
}
}
internal enum AdminSelection {
/// After you promoted a new admin, you will leave the group.
internal static let infoBanner = L10n.tr("Localizable", "admin_selection.info_banner", fallback: "After you promoted a new admin, you will leave the group.")
/// Promote
internal static let promote = L10n.tr("Localizable", "admin_selection.promote", fallback: "Promote")
/// Failed to promote user to admin.
internal static let promotionError = L10n.tr("Localizable", "admin_selection.promotion_error", fallback: "Failed to promote user to admin.")
/// New admin
internal static let title = L10n.tr("Localizable", "admin_selection.title", fallback: "New admin")
}
internal enum AppLockModule {
internal enum GoToSettingsButton {
/// Go to Settings
Expand Down Expand Up @@ -4249,6 +4275,22 @@ internal enum L10n {
}
}
}
internal enum LastAdminLeave {
/// Delete group
internal static let deleteGroup = L10n.tr("Localizable", "last_admin_leave.delete_group", fallback: "Delete group")
/// You're the only admin.
/// None of the other participants can be promoted to admin. You can only delete the group.
internal static let noEligibleCandidatesMessage = L10n.tr("Localizable", "last_admin_leave.no_eligible_candidates_message", fallback: "You're the only admin.\nNone of the other participants can be promoted to admin. You can only delete the group.")
/// Promote new admin
internal static let promoteNewAdmin = L10n.tr("Localizable", "last_admin_leave.promote_new_admin", fallback: "Promote new admin")
/// You're the only admin.
/// Promote another participant before leaving, or delete the group if it is no longer needed.
internal static let promoteOrDeleteMessage = L10n.tr("Localizable", "last_admin_leave.promote_or_delete_message", fallback: "You're the only admin.\nPromote another participant before leaving, or delete the group if it is no longer needed.")
/// Leave "%@"?
internal static func title(_ p1: Any) -> String {
return L10n.tr("Localizable", "last_admin_leave.title", String(describing: p1), fallback: "Leave \"%@\"?")
}
}
internal enum LegalHold {
internal enum Deactivated {
/// Future messages will not be recorded.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,9 @@

"webAuth.urlLabel.description" = "Identity Provider URL";
"webAuth.urlLabel.hint" = "Select to open the complete URL.";

// MARK: - Admin Selection

"adminSelection.searchBar.clearButton.description" = "Clear search";
"adminSelection.candidateRow.hint" = "Double tap to select as new admin";
"adminSelection.deleteGroupButton.hint" = "Deletes the group permanently";
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,19 @@
"meta.leave_conversation_button_leave" = "Leave";
"meta.leave_conversation_button_leave_and_delete" = "Leave and clear content";

// Admin selection (promote new admin before leaving)
"admin_selection.title" = "New admin";
"admin_selection.promote" = "Promote";
"admin_selection.info_banner" = "After you promoted a new admin, you will leave the group.";
Comment thread
David-Henner marked this conversation as resolved.
"admin_selection.promotion_error" = "Failed to promote user to admin.";

// Last admin leave flow
"last_admin_leave.title" = "Leave \"%@\"?";
"last_admin_leave.promote_or_delete_message" = "You're the only admin.\nPromote another participant before leaving, or delete the group if it is no longer needed.";
"last_admin_leave.promote_new_admin" = "Promote new admin";
"last_admin_leave.no_eligible_candidates_message" = "You're the only admin.\nNone of the other participants can be promoted to admin. You can only delete the group.";
"last_admin_leave.delete_group" = "Delete group";

// Conversation Degraded (security level lowered)
"meta.degraded.degradation_reason_message.singular" = "%@ started using a new device.";
"meta.degraded.degradation_reason_message.plural" = "%@ started using new devices.";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// Wire
// Copyright (C) 2026 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import SwiftUI
import WireSyncEngine

struct UserImageViewRepresentable: UIViewRepresentable {

let user: UserType
let userSession: UserSession
let size: UserImageView.Size

func makeUIView(context: Context) -> UserImageView {

Check warning on line 28 in wire-ios/Wire-iOS/Sources/UserInterface/Components/Views/UserImageViewRepresentable.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "context" or name it "_".

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ6sA6RKyAo-BqSG10LQ&open=AZ6sA6RKyAo-BqSG10LQ&pullRequest=4829
UserImageView(size: size)
}

func updateUIView(_ view: UserImageView, context: Context) {

Check warning on line 32 in wire-ios/Wire-iOS/Sources/UserInterface/Components/Views/UserImageViewRepresentable.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "context" or name it "_".

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ6sA6RKyAo-BqSG10LR&open=AZ6sA6RKyAo-BqSG10LR&pullRequest=4829
view.userSession = userSession
view.user = user
}
}
Loading
Loading