Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invites list unread badges #819

Merged
merged 1 commit into from
Apr 21, 2023
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
6 changes: 6 additions & 0 deletions ElementX/Sources/Application/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import SwiftUI
final class AppSettings: ObservableObject {
private enum UserDefaultsKeys: String {
case lastVersionLaunched
case seenInvites
case timelineStyle
case enableAnalytics
case enableInAppNotifications
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ struct HomeScreenViewState: BindableState {
let invitePermalink: URL?

var hasPendingInvitations = false
var hasUnreadPendingInvitations = false

var startChatFlowEnabled: Bool {
ServiceLocator.shared.settings.startChatFlowEnabled
Expand Down
14 changes: 11 additions & 3 deletions ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions ElementX/Sources/Screens/Invites/InvitesModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct InvitesViewStateBindings {
struct InvitesRoomDetails {
let roomDetails: RoomSummaryDetails
var inviter: RoomMemberProxyProtocol?
var isUnread: Bool

var isDirect: Bool {
roomDetails.isDirect
Expand Down
25 changes: 13 additions & 12 deletions ElementX/Sources/Screens/Invites/InvitesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ 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()
}

init(userSession: UserSessionProtocol) {
self.userSession = userSession
previouslySeenInvites = ServiceLocator.shared.settings.seenInvites
super.init(initialViewState: InvitesViewState(), imageProvider: userSession.mediaProvider)
setupSubscriptions()
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
52 changes: 39 additions & 13 deletions ElementX/Sources/Screens/Invites/View/InvitesScreenCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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)")
}
}

Expand All @@ -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 {
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
13 changes: 13 additions & 0 deletions ElementX/Sources/UITests/UITestsAppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions ElementX/Sources/UITests/UITestsScreenIdentifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ enum UITestsScreenIdentifier: String {
case startChat
case startChatWithSearchResults
case invites
case invitesWithBadges
case invitesNoInvites
case inviteUsers
}
Expand Down
7 changes: 6 additions & 1 deletion UITests/Sources/InvitesScreenUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ import ElementX
import XCTest

class InvitesScreenUITests: XCTestCase {
func testMixedInvites() {
func testInvitesWithNoBadges() {
let app = Application.launch(.invites)
app.assertScreenshot(.invites)
}

func testInvitesWithBadges() {
let app = Application.launch(.invitesWithBadges)
app.assertScreenshot(.invitesWithBadges)
}

func testNoInvites() {
let app = Application.launch(.invitesNoInvites)
XCTAssertTrue(app.staticTexts[A11yIdentifiers.invitesScreen.noInvites].exists)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions UnitTests/Sources/InvitesViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class InvitesViewModelTests: XCTestCase {
return
}
setupViewModel(roomSummaries: invites)
context.send(viewAction: .accept(.init(roomDetails: details)))
context.send(viewAction: .accept(.init(roomDetails: details, isUnread: false)))
let action: InvitesViewModelAction? = await viewModel.actions.values.first()
guard case .openRoom(let roomID) = action else {
XCTFail("Wrong view model action")
Expand All @@ -69,7 +69,7 @@ class InvitesViewModelTests: XCTestCase {
return
}
setupViewModel(roomSummaries: invites)
context.send(viewAction: .decline(.init(roomDetails: details)))
context.send(viewAction: .decline(.init(roomDetails: details, isUnread: false)))
XCTAssertNotNil(context.alertInfo)
}

Expand Down
1 change: 1 addition & 0 deletions changelog.d/714.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added unread badges in the invites list.