Fixes #2347, #2348, #2349 - Add support for typing notifications

This commit is contained in:
Stefan Ceriu
2024-02-13 15:27:52 +02:00
committed by GitHub
parent 2b2accaca6
commit f2e9c32e8d
26 changed files with 346 additions and 47 deletions
@@ -40,6 +40,8 @@ enum ComposerToolbarViewModelAction {
case composerFocusedChanged(isFocused: Bool)
case voiceMessage(ComposerToolbarVoiceMessageAction)
case contentChanged(isEmpty: Bool)
}
enum ComposerToolbarViewAction {
@@ -68,7 +68,11 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
.store(in: &cancellables)
wysiwygViewModel.$isContentEmpty
.weakAssign(to: \.state.composerEmpty, on: self)
.removeDuplicates()
.sink { [weak self] isEmpty in
self?.state.composerEmpty = isEmpty
self?.actionsSubject.send(.contentChanged(isEmpty: isEmpty))
}
.store(in: &cancellables)
wysiwygViewModel.$actionStates
@@ -181,7 +181,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
return
}
switch await roomProxy.markAsRead(sendReadReceipts: true, receiptType: appSettings.sendReadReceiptsEnabled ? .read : .readPrivate) {
switch await roomProxy.markAsRead(sendReadReceipts: true, receiptType: appSettings.sharePresence ? .read : .readPrivate) {
case .success:
ServiceLocator.shared.analytics.trackInteraction(name: .MobileRoomListRoomContextMenuUnreadToggle)
case .failure(let error):
@@ -118,7 +118,9 @@ struct RoomScreenViewState: BindableState {
var roomTitle = ""
var roomAvatarURL: URL?
var members: [String: RoomMemberState] = [:]
var typingMembers: [String] = []
var showLoading = false
var showReadReceipts = false
var timelineStyle: TimelineStyle
var isEncryptedOneToOneRoom = false
var timelineViewState = TimelineViewState() // check the doc before changing this
@@ -192,6 +192,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
composerFocusedSubject.send(isFocused)
case .voiceMessage(let voiceMessageAction):
processVoiceMessageAction(voiceMessageAction)
case .contentChanged(let isEmpty):
guard appSettings.sharePresence else {
return
}
Task {
await roomProxy.sendTypingNotification(isTyping: !isEmpty)
}
}
}
@@ -306,6 +314,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
appSettings.$timelineStyle
.weakAssign(to: \.state.timelineStyle, on: self)
.store(in: &cancellables)
appSettings.$sharePresence
.weakAssign(to: \.state.showReadReceipts, on: self)
.store(in: &cancellables)
roomProxy.members
.map { members in
@@ -317,6 +329,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
.weakAssign(to: \.state.members, on: self)
.store(in: &cancellables)
roomProxy.typingMembers
.receive(on: DispatchQueue.main)
.filter { [weak self] _ in self?.appSettings.sharePresence ?? false }
.weakAssign(to: \.state.typingMembers, on: self)
.store(in: &cancellables)
roomScreenInteractionHandler.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
@@ -33,7 +33,7 @@ struct TimelineItemStatusView: View {
@ViewBuilder
private var mainContent: some View {
if !timelineItem.properties.orderedReadReceipts.isEmpty {
if context.viewState.showReadReceipts, !timelineItem.properties.orderedReadReceipts.isEmpty {
readReceipts
} else {
deliveryStatusBadge
@@ -32,6 +32,20 @@ class TimelineItemCell: UITableViewCell {
}
}
/// A table view cell that displays member typing notifications. The cell is intended
/// to be configured to display a SwiftUI view and not use any UIKit.
class TimelineTypingIndicatorCell: UITableViewCell {
static let reuseIdentifier = "TimelineTypingIndicatorCell"
}
class TypingMembersObservableObject: ObservableObject {
@Published var members: [String] = []
init(members: [String]) {
self.members = members
}
}
/// A table view controller that displays the timeline of a room.
///
/// This class subclasses `UIViewController` as `UITableViewController` adds some
@@ -65,6 +79,17 @@ class TimelineTableViewController: UIViewController {
}
}
/// Used to hold an observable object that the typing indicator can use
let typingMembers = TypingMembersObservableObject(members: [])
/// Updates the typing members but also updates table view items
func setTypingMembers(_ members: [String]) {
DispatchQueue.main.async {
// Avoid `Publishing changes from within view update warnings`
self.typingMembers.members = members
}
}
@Binding private var isScrolledToBottom: Bool
private var timelineItemsIDs: [String] {
@@ -94,6 +119,7 @@ class TimelineTableViewController: UIViewController {
super.init(nibName: nil, bundle: nil)
tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier)
tableView.register(TimelineTypingIndicatorCell.self, forCellReuseIdentifier: TimelineTypingIndicatorCell.reuseIdentifier)
tableView.separatorStyle = .none
tableView.allowsSelection = false
tableView.keyboardDismissMode = .onDrag
@@ -146,31 +172,51 @@ class TimelineTableViewController: UIViewController {
/// Configures a diffable data source for the timeline's table view.
private func configureDataSource() {
dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, id in
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
guard let self, let cell = cell as? TimelineItemCell else { return cell }
// A local reference to avoid capturing self in the cell configuration.
let coordinator = self.coordinator
let viewState = timelineItemsDictionary[id]
cell.item = viewState
guard let viewState else {
switch id {
case TimelineTypingIndicatorCell.reuseIdentifier:
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineTypingIndicatorCell.reuseIdentifier, for: indexPath)
guard let self else {
return cell
}
cell.contentConfiguration = UIHostingConfiguration {
TypingIndicatorView(typingMembers: self.typingMembers)
}
.margins(.all, 0)
.minSize(height: 0)
.background(Color.clear)
// Flipping the cell can create some issues with cell resizing, so flip the content View
cell.contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
return cell
default:
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
guard let self, let cell = cell as? TimelineItemCell else { return cell }
// A local reference to avoid capturing self in the cell configuration.
let coordinator = self.coordinator
let viewState = timelineItemsDictionary[id]
cell.item = viewState
guard let viewState else {
return cell
}
cell.contentConfiguration = UIHostingConfiguration {
RoomTimelineItemView(viewState: viewState)
.id(id)
.frame(maxWidth: .infinity, alignment: .leading)
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
.environment(\.roomContext, coordinator.context)
}
.margins(.all, self.timelineStyle.rowInsets)
.minSize(height: 1)
.background(Color.clear)
// Flipping the cell can create some issues with cell resizing, so flip the content View
cell.contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
return cell
}
cell.contentConfiguration = UIHostingConfiguration {
RoomTimelineItemView(viewState: viewState)
.id(id)
.frame(maxWidth: .infinity, alignment: .leading)
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
.environment(\.roomContext, coordinator.context)
}
.margins(.all, self.timelineStyle.rowInsets)
.minSize(height: 1)
.background(Color.clear)
// Flipping the cell can create some issues with cell resizing, so flip the content View
cell.contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
return cell
}
// We only animate when there's a new last message, so its safe
@@ -195,15 +241,21 @@ class TimelineTableViewController: UIViewController {
guard let dataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, String>()
snapshot.appendSections([.typingIndicator])
snapshot.appendItems([TimelineTypingIndicatorCell.reuseIdentifier])
snapshot.appendSections([.main])
snapshot.appendItems(timelineItemsIDs)
let currentSnapshot = dataSource.snapshot()
MXLog.verbose("DIFF: \(snapshot.itemIdentifiers.difference(from: currentSnapshot.itemIdentifiers))")
// We only animate when new items come at the end of the timeline, ignoring transitions through empty.
let animated = currentSnapshot.numberOfItems > 0 && snapshot.numberOfItems > 0 &&
snapshot.itemIdentifiers.first != currentSnapshot.itemIdentifiers.first
let animated = currentSnapshot.sectionIdentifiers.contains(.main) &&
snapshot.sectionIdentifiers.contains(.main) &&
currentSnapshot.numberOfItems(inSection: .main) > 0 &&
snapshot.numberOfItems(inSection: .main) > 0 &&
snapshot.itemIdentifiers(inSection: .main).first != currentSnapshot.itemIdentifiers(inSection: .main).first
dataSource.apply(snapshot, animatingDifferences: animated)
}
@@ -293,5 +345,6 @@ extension TimelineTableViewController {
/// The sections of the table view used in the diffable data source.
enum TimelineSection {
case main
case typingIndicator
}
}
@@ -0,0 +1,88 @@
//
// Copyright 2024 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct TypingIndicatorView: View {
@ObservedObject var typingMembers: TypingMembersObservableObject
@State private var didShowTextOnce = false
var body: some View {
content
.font(.compound.bodySM)
.foregroundColor(.compound.textPlaceholder)
.lineLimit(1)
.truncationMode(.middle)
.padding(.horizontal, 4)
.animation(.elementDefault, value: typingMembers.members)
.onChange(of: typingMembers.members) { newValue in
if !newValue.isEmpty {
didShowTextOnce = true
}
}
}
@ViewBuilder
var content: some View {
// Plurals with string arguments aren't generated correctly by so we need to work around that
// https://github.com/SwiftGen/SwiftGen/issues/1089
switch typingMembers.members.count {
case 1:
let firstMember = typingMembers.members[0]
Text(firstMember).bold() +
Text(L10n.screenRoomTypingNotificationSingularIos)
case 2:
let firstMember = typingMembers.members[0]
let lastMember = typingMembers.members[1]
Text(L10n.screenRoomTypingTwoMembers(firstMember, lastMember)).bold() +
Text(L10n.screenRoomTypingNotificationPluralIos)
case 3...:
let firstMember = typingMembers.members[0]
let lastMember = typingMembers.members[1]
Text(L10n.tr("Localizable", "screen_room_typing_many_members_first_component_ios", firstMember, lastMember)).bold() +
Text(L10n.tr("Localizable", "screen_room_typing_many_members_second_component_ios", typingMembers.members.count - 2)).bold() +
Text(L10n.screenRoomTypingNotificationPluralIos)
default:
if didShowTextOnce {
Text(L10n.screenRoomTypingNotificationSingularIos)
.opacity(0)
} else {
// Together with the margins and minSize on the contentConfiguration forces the tableView cell to be small
Rectangle()
.frame(height: 1)
.opacity(0)
}
}
}
}
struct TypingIndicatorView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(alignment: .leading, spacing: 16.0) {
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice"]))
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice", "Bob"]))
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice", "Bob", "Charlie"]))
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice", "Bob", "Charlie", "Dan"]))
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice", "Bob", "Charlie", "Dan", "Frannie"]))
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice with her very long display name", "Bob", "Charlie", "Dan", "Frannie"]))
}
.frame(width: 300)
}
}
@@ -62,6 +62,10 @@ struct UITimelineView: UIViewControllerRepresentable {
if tableViewController.isBackPaginating != context.viewState.timelineViewState.isBackPaginating {
tableViewController.isBackPaginating = context.viewState.timelineViewState.isBackPaginating
}
if tableViewController.typingMembers.members != context.viewState.typingMembers {
tableViewController.setTypingMembers(context.viewState.typingMembers)
}
}
func send(viewAction: RoomScreenViewAction) {
@@ -42,7 +42,7 @@ protocol AdvancedSettingsProtocol: AnyObject {
var viewSourceEnabled: Bool { get set }
var richTextEditorEnabled: Bool { get set }
var appAppearance: AppAppearance { get set }
var sendReadReceiptsEnabled: Bool { get set }
var sharePresence: Bool { get set }
}
extension AppSettings: AdvancedSettingsProtocol { }
@@ -37,9 +37,9 @@ struct AdvancedSettingsScreen: View {
ListRow(label: .plain(title: L10n.actionViewSource),
kind: .toggle($context.viewSourceEnabled))
ListRow(label: .plain(title: L10n.screenAdvancedSettingsSendReadReceipts,
description: L10n.screenAdvancedSettingsSendReadReceiptsDescription),
kind: .toggle($context.sendReadReceiptsEnabled))
ListRow(label: .plain(title: L10n.screenAdvancedSettingsSharePresence,
description: L10n.screenAdvancedSettingsSharePresenceDescription),
kind: .toggle($context.sharePresence))
}
}
.compoundList()