mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Add invites badges (#819)
This commit is contained in:
@@ -21,6 +21,7 @@ import SwiftUI
|
||||
final class AppSettings: ObservableObject {
|
||||
private enum UserDefaultsKeys: String {
|
||||
case lastVersionLaunched
|
||||
case seenInvites
|
||||
case timelineStyle
|
||||
case enableAnalytics
|
||||
case enableInAppNotifications
|
||||
@@ -69,6 +70,11 @@ final class AppSettings: ObservableObject {
|
||||
@UserPreference(key: UserDefaultsKeys.lastVersionLaunched, storageType: .userDefaults(store))
|
||||
var lastVersionLaunched: String?
|
||||
|
||||
/// The Set of room identifiers of invites that the user already saw in the invites list.
|
||||
/// This Set is being used to implement badges for unread invites.
|
||||
@UserPreference(key: UserDefaultsKeys.seenInvites, defaultValue: [], storageType: .userDefaults(store))
|
||||
var seenInvites: Set<String>
|
||||
|
||||
/// The default homeserver address used. This is intentionally a string without a scheme
|
||||
/// so that it can be passed to Rust as a ServerName for well-known discovery.
|
||||
let defaultHomeserverAddress = "matrix.org"
|
||||
|
||||
@@ -70,6 +70,7 @@ struct HomeScreenViewState: BindableState {
|
||||
let invitePermalink: URL?
|
||||
|
||||
var hasPendingInvitations = false
|
||||
var hasUnreadPendingInvitations = false
|
||||
|
||||
var startChatFlowEnabled: Bool {
|
||||
ServiceLocator.shared.settings.startChatFlowEnabled
|
||||
|
||||
@@ -134,10 +134,18 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
invitesSummaryProvider.countPublisher
|
||||
.map { $0 > 0 }
|
||||
invitesSummaryProvider.roomListPublisher
|
||||
.combineLatest(ServiceLocator.shared.settings.$seenInvites)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.weakAssign(to: \.state.hasPendingInvitations, on: self)
|
||||
.sink { [weak self] summaries, readInvites in
|
||||
self?.state.hasPendingInvitations = !summaries.isEmpty
|
||||
self?.state.hasUnreadPendingInvitations = summaries.contains(where: {
|
||||
guard let roomId = $0.id else {
|
||||
return false
|
||||
}
|
||||
return !readInvites.contains(roomId)
|
||||
})
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
updateRooms()
|
||||
|
||||
@@ -33,7 +33,7 @@ struct HomeScreen: View {
|
||||
}
|
||||
|
||||
if context.viewState.hasPendingInvitations, ServiceLocator.shared.settings.invitesFlowEnabled {
|
||||
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: true) {
|
||||
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) {
|
||||
context.send(viewAction: .selectInvites)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
|
||||
@@ -30,6 +30,7 @@ struct InvitesViewStateBindings {
|
||||
struct InvitesRoomDetails {
|
||||
let roomDetails: RoomSummaryDetails
|
||||
var inviter: RoomMemberProxyProtocol?
|
||||
var isUnread: Bool
|
||||
|
||||
var isDirect: Bool {
|
||||
roomDetails.isDirect
|
||||
|
||||
@@ -22,6 +22,7 @@ typealias InvitesViewModelType = StateStoreViewModel<InvitesViewState, InvitesVi
|
||||
class InvitesViewModel: InvitesViewModelType, InvitesViewModelProtocol {
|
||||
private var actionsSubject: PassthroughSubject<InvitesViewModelAction, Never> = .init()
|
||||
private let userSession: UserSessionProtocol
|
||||
private let previouslySeenInvites: Set<String>
|
||||
|
||||
var actions: AnyPublisher<InvitesViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
@@ -29,6 +30,7 @@ class InvitesViewModel: InvitesViewModelType, InvitesViewModelProtocol {
|
||||
|
||||
init(userSession: UserSessionProtocol) {
|
||||
self.userSession = userSession
|
||||
previouslySeenInvites = ServiceLocator.shared.settings.seenInvites
|
||||
super.init(initialViewState: InvitesViewState(), imageProvider: userSession.mediaProvider)
|
||||
setupSubscriptions()
|
||||
}
|
||||
@@ -65,7 +67,8 @@ class InvitesViewModel: InvitesViewModelType, InvitesViewModelProtocol {
|
||||
.sink { [weak self] roomSummaries in
|
||||
guard let self else { return }
|
||||
|
||||
let invites = roomSummaries.invites
|
||||
let invites = self.buildInvites(from: roomSummaries)
|
||||
ServiceLocator.shared.settings.seenInvites = Set(invites.map(\.roomDetails.id))
|
||||
self.state.invites = invites
|
||||
|
||||
for invite in invites {
|
||||
@@ -75,6 +78,15 @@ class InvitesViewModel: InvitesViewModelType, InvitesViewModelProtocol {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func buildInvites(from summaries: [RoomSummary]) -> [InvitesRoomDetails] {
|
||||
summaries.compactMap { summary in
|
||||
guard case .filled(let details) = summary else {
|
||||
return nil
|
||||
}
|
||||
return InvitesRoomDetails(roomDetails: details, isUnread: !previouslySeenInvites.contains(details.id))
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchInviter(for roomID: String) {
|
||||
Task {
|
||||
guard let room: RoomProxyProtocol = await self.clientProxy.roomForIdentifier(roomID) else {
|
||||
@@ -154,14 +166,3 @@ class InvitesViewModel: InvitesViewModelType, InvitesViewModelProtocol {
|
||||
message: L10n.errorUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array where Element == RoomSummary {
|
||||
var invites: [InvitesRoomDetails] {
|
||||
compactMap { summary in
|
||||
guard case .filled(let details) = summary else {
|
||||
return nil
|
||||
}
|
||||
return .init(roomDetails: details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ struct InvitesScreenCell: View {
|
||||
let declineAction: () -> Void
|
||||
|
||||
private let verticalInsets = 16.0
|
||||
@ScaledMetric private var badgeSize = 12.0
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
@@ -42,27 +43,26 @@ struct InvitesScreenCell: View {
|
||||
}
|
||||
}
|
||||
.padding(.top, verticalInsets)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var mainContent: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.element.headline)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textPlaceholder)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
textualContent
|
||||
|
||||
if invite.isUnread {
|
||||
badge
|
||||
}
|
||||
}
|
||||
|
||||
inviterView
|
||||
|
||||
buttons
|
||||
.padding(.top, 10)
|
||||
.padding(.trailing, 26)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,22 @@ struct InvitesScreenCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var textualContent: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.element.headline)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textPlaceholder)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var buttons: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button(L10n.actionDecline, action: declineAction)
|
||||
@@ -127,6 +143,12 @@ struct InvitesScreenCell: View {
|
||||
}
|
||||
return attributedString
|
||||
}
|
||||
|
||||
private var badge: some View {
|
||||
Circle()
|
||||
.frame(width: badgeSize, height: badgeSize)
|
||||
.foregroundColor(.element.brand)
|
||||
}
|
||||
}
|
||||
|
||||
struct InvitesScreenCell_Previews: PreviewProvider {
|
||||
@@ -139,6 +161,10 @@ struct InvitesScreenCell_Previews: PreviewProvider {
|
||||
|
||||
InvitesScreenCell(invite: .room(alias: "#footest:somewhere.org"), imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { })
|
||||
.previewDisplayName("Aliased room")
|
||||
|
||||
InvitesScreenCell(invite: .room(alias: "#footest:somewhere.org"), imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { })
|
||||
.dynamicTypeSize(.accessibility1)
|
||||
.previewDisplayName("Aliased room (AX1)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +183,7 @@ private extension InvitesRoomDetails {
|
||||
inviter.displayName = "Jack"
|
||||
inviter.userID = "@jack:somewhere.com"
|
||||
|
||||
return .init(roomDetails: dmRoom, inviter: inviter)
|
||||
return .init(roomDetails: dmRoom, inviter: inviter, isUnread: false)
|
||||
}
|
||||
|
||||
static func room(alias: String?) -> InvitesRoomDetails {
|
||||
@@ -174,6 +200,6 @@ private extension InvitesRoomDetails {
|
||||
inviter.userID = "@jack:somewhere.com"
|
||||
inviter.avatarURL = nil
|
||||
|
||||
return .init(roomDetails: dmRoom, inviter: inviter)
|
||||
return .init(roomDetails: dmRoom, inviter: inviter, isUnread: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
|
||||
self.presentStartChat(animated: animated)
|
||||
case (.startChatScreen, .dismissedStartChatScreen, .roomList):
|
||||
break
|
||||
|
||||
case (.roomList, .showInvitesScreen, .invitesScreen):
|
||||
self.presentInvitesList(animated: animated)
|
||||
case (.invitesScreen, .closedInvitesScreen, .roomList):
|
||||
|
||||
@@ -364,7 +364,20 @@ class MockScreen: Identifiable {
|
||||
mediaProvider: MockMediaProvider()))
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .invitesWithBadges:
|
||||
ServiceLocator.shared.settings.seenInvites = Set([RoomSummary].mockInvites.dropFirst(1).compactMap(\.id))
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let clientProxy = MockClientProxy(userID: "@mock:client.com")
|
||||
clientProxy.roomInviter = RoomMemberProxyMock.mockCharlie
|
||||
let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites))
|
||||
clientProxy.visibleRoomsSummaryProvider = summaryProvider
|
||||
clientProxy.invitesSummaryProvider = summaryProvider
|
||||
|
||||
let coordinator = InvitesCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())))
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .invites:
|
||||
ServiceLocator.shared.settings.seenInvites = Set([RoomSummary].mockInvites.compactMap(\.id))
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let clientProxy = MockClientProxy(userID: "@mock:client.com")
|
||||
clientProxy.roomInviter = RoomMemberProxyMock.mockCharlie
|
||||
|
||||
@@ -52,6 +52,7 @@ enum UITestsScreenIdentifier: String {
|
||||
case startChat
|
||||
case startChatWithSearchResults
|
||||
case invites
|
||||
case invitesWithBadges
|
||||
case invitesNoInvites
|
||||
case inviteUsers
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user