mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
@@ -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
|
||||
|
||||
+80
-27
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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 { }
|
||||
|
||||
+3
-3
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user