Knocked Preview implementation (#3426)

* JoinRoomScreen ui for knocking

* code improvement

* updated previews

* added knocked state with tests

* send knock request

* Apply suggestions from code review

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>

* pr comments

* update tests

* new API

* knock implementation and cancel knock

* update strings

* added a knocked cell in the home screen

* design update

* updated SDK

* simplified the invite case code

* pr comments

* updated previews

* added message as reason

* updated strings

* fixing tests

---------

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
Mauro
2024-10-24 17:23:06 +02:00
committed by GitHub
parent 7d373c07a3
commit 2511c98090
48 changed files with 492 additions and 120 deletions
@@ -127,10 +127,11 @@ struct HomeScreenViewStateBindings {
}
struct HomeScreenRoom: Identifiable, Equatable {
enum RoomType {
enum RoomType: Equatable {
case placeholder
case room
case invite
case invite(inviterDetails: RoomInviterDetails?)
case knock
}
static let placeholderLastMessage = AttributedString("Hidden last message")
@@ -143,6 +144,13 @@ struct HomeScreenRoom: Identifiable, Equatable {
let type: RoomType
var inviter: RoomInviterDetails? {
if case .invite(let inviter) = type {
return inviter
}
return nil
}
let badges: Badges
struct Badges: Equatable {
let isDotShown: Bool
@@ -164,9 +172,7 @@ struct HomeScreenRoom: Identifiable, Equatable {
let lastMessage: AttributedString?
let avatar: RoomAvatar
let inviter: RoomInviterDetails?
let canonicalAlias: String?
static func placeholder() -> HomeScreenRoom {
@@ -181,7 +187,6 @@ struct HomeScreenRoom: Identifiable, Equatable {
timestamp: "Now",
lastMessage: placeholderLastMessage,
avatar: .room(id: "", name: "", avatarURL: nil),
inviter: nil,
canonicalAlias: nil)
}
}
@@ -192,17 +197,21 @@ extension HomeScreenRoom {
let hasUnreadMessages = hideUnreadMessagesBadge ? false : summary.hasUnreadMessages
let isDotShown = hasUnreadMessages || summary.hasUnreadMentions || summary.hasUnreadNotifications || summary.isMarkedUnread
let isDotShown = hasUnreadMessages || summary.hasUnreadMentions || summary.hasUnreadNotifications || summary.isMarkedUnread || summary.joinRequestType?.isKnock == true
let isMentionShown = summary.hasUnreadMentions && !summary.isMuted
let isMuteShown = summary.isMuted
let isCallShown = summary.hasOngoingCall
let isHighlighted = summary.isMarkedUnread || (!summary.isMuted && (summary.hasUnreadNotifications || summary.hasUnreadMentions))
let isHighlighted = summary.isMarkedUnread || (!summary.isMuted && (summary.hasUnreadNotifications || summary.hasUnreadMentions)) || summary.joinRequestType?.isKnock == true
let inviter = summary.inviter.map(RoomInviterDetails.init)
let type: HomeScreenRoom.RoomType = switch summary.joinRequestType {
case .invite(let inviter): .invite(inviterDetails: inviter.map(RoomInviterDetails.init))
case .knock: .knock
case .none: .room
}
self.init(id: identifier,
roomID: summary.id,
type: summary.isInvite ? .invite : .room,
type: type,
badges: .init(isDotShown: isDotShown,
isMentionShown: isMentionShown,
isMuteShown: isMuteShown,
@@ -214,7 +223,6 @@ extension HomeScreenRoom {
timestamp: summary.lastMessageFormattedTimestamp,
lastMessage: summary.lastMessage,
avatar: summary.avatar,
inviter: inviter,
canonicalAlias: summary.canonicalAlias)
}
}
@@ -69,7 +69,8 @@ struct HomeScreenInviteCell: View {
@ViewBuilder
private var inviterView: some View {
if let inviter = room.inviter, !room.isDirect {
if let inviter = room.inviter,
!room.isDirect {
RoomInviterLabel(inviter: inviter, mediaProvider: context.mediaProvider)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPlaceholder)
@@ -177,8 +178,7 @@ private extension HomeScreenRoom {
let summary = RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "@someone:somewhere.com",
isInvite: false,
inviter: inviter,
joinRequestType: .invite(inviter: inviter),
name: "Some Guy",
isDirect: true,
avatarURL: nil,
@@ -205,8 +205,7 @@ private extension HomeScreenRoom {
let summary = RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "@someone:somewhere.com",
isInvite: false,
inviter: inviter,
joinRequestType: .invite(inviter: inviter),
name: "Awesome Room",
isDirect: false,
avatarURL: avatarURL,
@@ -0,0 +1,200 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import Compound
import SwiftUI
@MainActor
struct HomeScreenKnockedCell: View {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
let room: HomeScreenRoom
let context: HomeScreenViewModel.Context
var body: some View {
HStack(alignment: .top, spacing: 16) {
if dynamicTypeSize < .accessibility3 {
RoomAvatarImage(avatar: room.avatar,
avatarSize: .custom(52),
mediaProvider: context.mediaProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
}
mainContent
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 16)
.padding(.trailing, 16)
.multilineTextAlignment(.leading)
.overlay(alignment: .bottom) {
separator
}
}
.padding(.top, 12)
.padding(.leading, 16)
.onTapGesture {
if let roomID = room.roomID {
context.send(viewAction: .selectRoom(roomIdentifier: roomID))
}
}
}
// MARK: - Private
private var mainContent: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 16) {
textualContent
badge
}
Text(L10n.screenRoomlistKnockEventSentDescription)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPlaceholder)
.padding(.top, room.canonicalAlias == nil ? 0 : 4)
.padding(.trailing, 16)
}
.fixedSize(horizontal: false, vertical: true)
.accessibilityElement(children: .combine)
}
}
@ViewBuilder
private var textualContent: some View {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(.compound.bodyLGSemibold)
.foregroundColor(.compound.textPrimary)
.lineLimit(2)
if let subtitle {
Text(subtitle)
.font(.compound.bodyMD)
.foregroundColor(.compound.textPlaceholder)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var separator: some View {
Rectangle()
.fill(Color.compound.borderDisabled)
.frame(height: 1 / UIScreen.main.scale)
}
private var title: String {
room.name
}
private var subtitle: String? {
room.canonicalAlias
}
private var badge: some View {
Circle()
.scaledFrame(size: 12)
.foregroundColor(.compound.iconAccentTertiary)
}
}
struct HomeScreenKnockedCell_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
ScrollView {
VStack(spacing: 0) {
HomeScreenKnockedCell(room: .dmInvite,
context: viewModel().context)
HomeScreenKnockedCell(room: .dmInvite,
context: viewModel().context)
HomeScreenKnockedCell(room: .roomKnocked(),
context: viewModel().context)
HomeScreenKnockedCell(room: .roomKnocked(),
context: viewModel().context)
HomeScreenKnockedCell(room: .roomKnocked(alias: "#footest:somewhere.org", avatarURL: .picturesDirectory),
context: viewModel().context)
HomeScreenKnockedCell(room: .roomKnocked(alias: "#footest:somewhere.org"),
context: viewModel().context)
.dynamicTypeSize(.accessibility1)
.previewDisplayName("Aliased room (AX1)")
}
}
}
static func viewModel() -> HomeScreenViewModel {
let clientProxy = ClientProxyMock(.init())
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
return HomeScreenViewModel(userSession: userSession,
analyticsService: ServiceLocator.shared.analytics,
appSettings: ServiceLocator.shared.settings,
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
}
@MainActor
private extension HomeScreenRoom {
static var dmInvite: HomeScreenRoom {
let inviter = RoomMemberProxyMock()
inviter.displayName = "Jack"
inviter.userID = "@jack:somewhere.com"
let summary = RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "@someone:somewhere.com",
joinRequestType: .invite(inviter: inviter),
name: "Some Guy",
isDirect: true,
avatarURL: nil,
heroes: [.init(userID: "@someone:somewhere.com")],
lastMessage: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
unreadNotificationsCount: 0,
notificationMode: nil,
canonicalAlias: "#footest:somewhere.org",
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false)
return .init(summary: summary, hideUnreadMessagesBadge: false)
}
static func roomKnocked(alias: String? = nil, avatarURL: URL? = nil) -> HomeScreenRoom {
let inviter = RoomMemberProxyMock()
inviter.displayName = "Luca"
inviter.userID = "@jack:somewhi.nl"
inviter.avatarURL = avatarURL
let summary = RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "@someone:somewhere.com",
joinRequestType: .invite(inviter: inviter),
name: "Awesome Room",
isDirect: false,
avatarURL: avatarURL,
heroes: [.init(userID: "@someone:somewhere.com")],
lastMessage: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
unreadNotificationsCount: 0,
notificationMode: nil,
canonicalAlias: alias,
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false)
return .init(summary: summary, hideUnreadMessagesBadge: false)
}
}
@@ -32,6 +32,8 @@ struct HomeScreenRoomList: View {
.redacted(reason: .placeholder)
case .invite:
HomeScreenInviteCell(room: room, context: context)
case .knock:
HomeScreenKnockedCell(room: room, context: context)
case .room:
let isSelected = context.viewState.selectedRoomID == room.id
@@ -49,7 +49,7 @@ final class JoinRoomScreenCoordinator: CoordinatorProtocol {
switch action {
case .joined:
actionsSubject.send(.joined)
case .cancelled:
case .dismiss:
actionsSubject.send(.cancelled)
}
}
@@ -9,7 +9,7 @@ import Foundation
enum JoinRoomScreenViewModelAction {
case joined
case cancelled
case dismiss
}
enum JoinRoomScreenInteractionMode {
@@ -65,6 +65,7 @@ struct JoinRoomScreenViewStateBindings {
enum JoinRoomScreenAlertType {
case declineInvite
case cancelKnock
}
enum JoinRoomScreenViewAction {
@@ -59,8 +59,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
case .declineInvite:
showDeclineInviteConfirmationAlert()
case .cancelKnock:
// TODO: implement once available
break
showCancelKnockConfirmationAlert()
}
}
@@ -78,15 +77,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
Task { await updateRoomDetails() }
}
// Using only the preview API isn't enough as it's not capable
// of giving us information for non-joined rooms (at least not on synapse)
// See if we known about the room locally and, if so, have that
// take priority over the preview one.
if let room = await clientProxy.roomForIdentifier(roomID) {
self.room = room
await updateRoomDetails()
}
await updateRoom()
switch await clientProxy.roomPreviewForIdentifier(roomID, via: via) {
case .success(let roomPreviewDetails):
@@ -99,6 +90,17 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
}
}
private func updateRoom() async {
// Using only the preview API isn't enough as it's not capable
// of giving us information for non-joined rooms (at least not on synapse)
// See if we known about the room locally and, if so, have that
// take priority over the preview one.
if let room = await clientProxy.roomForIdentifier(roomID) {
self.room = room
await updateRoomDetails()
}
}
private func updateRoomDetails() async {
var roomProxy: RoomProxyProtocol?
var inviter: RoomInviterDetails?
@@ -188,7 +190,8 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
switch await clientProxy.knockRoomAlias(alias,
message: state.bindings.knockMessage.isBlank ? nil : state.bindings.knockMessage) {
case .success:
state.mode = .knocked
// The room should become knocked through the sync
await updateRoom()
case .failure(let error):
MXLog.error("Failed knocking room alias: \(alias) with error: \(error)")
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
@@ -198,7 +201,8 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
via: via,
message: state.bindings.knockMessage.isBlank ? nil : state.bindings.knockMessage) {
case .success:
state.mode = .knocked
// The room should become knocked through the sync
await updateRoom()
case .failure(let error):
MXLog.error("Failed knocking room id: \(roomID) with error: \(error)")
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
@@ -220,6 +224,14 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
secondaryButton: .init(title: L10n.actionDecline, role: .destructive, action: { Task { await self.declineInvite() } }))
}
private func showCancelKnockConfirmationAlert() {
state.bindings.alertInfo = .init(id: .cancelKnock,
title: L10n.screenJoinRoomCancelKnockAlertTitle,
message: L10n.screenJoinRoomCancelKnockAlertDescription,
primaryButton: .init(title: L10n.actionNo, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.screenJoinRoomCancelKnockAlertConfirmation, role: .destructive, action: { Task { await self.cancelKnock() } }))
}
private func declineInvite() async {
defer {
userIndicatorController.retractIndicatorWithId(roomID)
@@ -236,6 +248,29 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
if case .failure = result {
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
} else {
actionsSubject.send(.dismiss)
}
}
private func cancelKnock() async {
defer {
userIndicatorController.retractIndicatorWithId(roomID)
}
userIndicatorController.submitIndicator(UserIndicator(id: roomID, type: .modal, title: L10n.commonLoading, persistent: true))
guard case let .knocked(roomProxy) = room else {
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
return
}
let result = await roomProxy.cancelKnock()
if case .failure = result {
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
} else {
actionsSubject.send(.dismiss)
}
}
@@ -14,7 +14,7 @@ struct JoinRoomScreen: View {
@ObservedObject var context: JoinRoomScreenViewModel.Context
var body: some View {
FullscreenDialog(topPadding: 80, background: .bloom) {
FullscreenDialog(topPadding: context.viewState.mode == .knocked ? 151 : 35, background: .bloom) {
if context.viewState.mode == .loading {
EmptyView()
} else {
@@ -27,13 +27,15 @@ struct JoinRoomScreen: View {
.background()
.backgroundStyle(.compound.bgCanvasDefault)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
}
@ViewBuilder
var mainContent: some View {
if context.viewState.mode == .knocked {
switch context.viewState.mode {
case .knocked:
knockedView
} else {
default:
defaultView
}
}
@@ -54,7 +56,7 @@ struct JoinRoomScreen: View {
if let subtitle = context.viewState.subtitle {
Text(subtitle)
.font(.compound.bodyMD)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
}
@@ -101,7 +103,7 @@ struct JoinRoomScreen: View {
}
}
}
@ViewBuilder
private var knockMessage: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -158,6 +160,17 @@ struct JoinRoomScreen: View {
Button(L10n.actionAccept) { context.send(viewAction: .acceptInvite) }
.buttonStyle(.compound(.primary))
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
if context.viewState.mode == .knocked {
ToolbarItem(placement: .principal) {
RoomHeaderView(roomName: context.viewState.title,
roomAvatar: context.viewState.avatar,
mediaProvider: context.mediaProvider)
}
}
}
}
// MARK: - Previews
@@ -221,10 +234,17 @@ struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview {
if mode == .unknown {
clientProxy.roomPreviewForIdentifierViaReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
} else {
if mode == .knocked {
switch mode {
case .knocked:
clientProxy.roomForIdentifierClosure = { _ in
.knocked(KnockedRoomProxyMock(.init()))
.knocked(KnockedRoomProxyMock(.init(avatarURL: URL.homeDirectory)))
}
case .invited:
clientProxy.roomForIdentifierClosure = { _ in
.invited(InvitedRoomProxyMock(.init(avatarURL: URL.homeDirectory)))
}
default:
break
}
clientProxy.roomPreviewForIdentifierViaReturnValue = .success(.init(roomID: "1",
name: "The Three-Body Problem - 三体",
@@ -1,57 +0,0 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import Compound
import SwiftUI
struct RoomHeaderView: View {
let roomName: String
let roomAvatar: RoomAvatar
let mediaProvider: MediaProviderProtocol?
var body: some View {
HStack(spacing: 12) {
avatarImage
.accessibilityHidden(true)
Text(roomName)
.lineLimit(1)
.font(.compound.bodyLGSemibold)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name)
}
// Take up as much space as possible, with a leading alignment for use in the principal toolbar position.
.frame(idealWidth: .greatestFiniteMagnitude, maxWidth: .infinity, alignment: .leading)
}
private var avatarImage: some View {
RoomAvatarImage(avatar: roomAvatar,
avatarSize: .room(on: .timeline),
mediaProvider: mediaProvider)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.avatar)
}
}
struct RoomHeaderView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
RoomHeaderView(roomName: "Some Room name",
roomAvatar: .room(id: "1",
name: "Some Room Name",
avatarURL: URL.picturesDirectory),
mediaProvider: MediaProviderMock(configuration: .init()))
.previewLayout(.sizeThatFits)
.padding()
RoomHeaderView(roomName: "Some Room name",
roomAvatar: .room(id: "1",
name: "Some Room Name",
avatarURL: nil),
mediaProvider: MediaProviderMock(configuration: .init()))
.previewLayout(.sizeThatFits)
.padding()
}
}