mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
@@ -424,6 +424,7 @@
|
||||
6C34237AFB808E38FC8776B9 /* RoomStateEventStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D55702474F279D910D2D162 /* RoomStateEventStringBuilder.swift */; };
|
||||
6CD61FAF03E8986523C2ABB8 /* StartChatScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3005886F00029F058DB62BE /* StartChatScreenCoordinator.swift */; };
|
||||
6D046D653DA28ADF1E6E59A4 /* BackgroundTaskServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE73D571D4F9C36DD45255A /* BackgroundTaskServiceProtocol.swift */; };
|
||||
6D6E651ACACE27E9C5690818 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE47A97726F0675DEE387BF9 /* TypingIndicatorView.swift */; };
|
||||
6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */; };
|
||||
6E4E401BE97AC241DA7C7716 /* AppLockSetupSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 502F986D57158674172C58E3 /* AppLockSetupSettingsScreenModels.swift */; };
|
||||
6E63704717F17593A475D152 /* RoomNotificationSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA14564EE143F73F7E4D1F79 /* RoomNotificationSettingsScreenModels.swift */; };
|
||||
@@ -1827,6 +1828,7 @@
|
||||
CD700E035C85738EE4B97129 /* PerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceTests.swift; sourceTree = "<group>"; };
|
||||
CD95B3714F806AC9CF9A557B /* ComposerToolbarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerToolbarViewModel.swift; sourceTree = "<group>"; };
|
||||
CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = "<group>"; };
|
||||
CE47A97726F0675DEE387BF9 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = "<group>"; };
|
||||
CEE0E6043EFCF6FD2A341861 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = "<group>"; };
|
||||
CEE20623EB4A9B88FB29F2BA /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/SAS.strings; sourceTree = "<group>"; };
|
||||
CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; path = UnitTests.xctestplan; sourceTree = "<group>"; };
|
||||
@@ -4225,6 +4227,7 @@
|
||||
F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */,
|
||||
F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */,
|
||||
27B8315A340B46F98B9C5AF0 /* TimelineTableViewController.swift */,
|
||||
CE47A97726F0675DEE387BF9 /* TypingIndicatorView.swift */,
|
||||
0EA689E792E679F5E3956F21 /* UITimelineView.swift */,
|
||||
A2AC3C656E960E15B5905E05 /* UnsupportedRoomTimelineView.swift */,
|
||||
1941C8817E6B6971BA4415F5 /* VideoRoomTimelineView.swift */,
|
||||
@@ -5995,6 +5998,7 @@
|
||||
69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */,
|
||||
FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */,
|
||||
2B1E080B32167AE9EFC763A2 /* TimelineTableViewController.swift in Sources */,
|
||||
6D6E651ACACE27E9C5690818 /* TypingIndicatorView.swift in Sources */,
|
||||
36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */,
|
||||
A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */,
|
||||
0EE5EBA18BA1FE10254BB489 /* UIFont+AttributedStringBuilder.m in Sources */,
|
||||
|
||||
@@ -538,6 +538,9 @@
|
||||
"screen_room_retry_send_menu_title" = "Your message failed to send";
|
||||
"screen_room_timeline_add_reaction" = "Add emoji";
|
||||
"screen_room_timeline_less_reactions" = "Show less";
|
||||
"screen_room_typing_many_members_first_component_ios" = "%1$@, %2$@ and ";
|
||||
"screen_room_typing_notification_plural_ios" = " are typing";
|
||||
"screen_room_typing_notification_singular_ios" = " is typing";
|
||||
"screen_room_typing_two_members" = "%1$@ and %2$@";
|
||||
"screen_room_voice_message_tooltip" = "Hold to record";
|
||||
"screen_roomlist_a11y_create_message" = "Create a new conversation or room";
|
||||
|
||||
@@ -242,6 +242,22 @@
|
||||
<string>%1$@, %2$@ and %3$d others</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>screen_room_typing_many_members_second_component_ios</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@COUNT@</string>
|
||||
<key>COUNT</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d other</string>
|
||||
<key>other</key>
|
||||
<string>%d others</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>screen_room_typing_notification</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
||||
@@ -37,7 +37,7 @@ final class AppSettings {
|
||||
case viewSourceEnabled
|
||||
case richTextEditorEnabled
|
||||
case appAppearance
|
||||
case sendReadReceiptsEnabled
|
||||
case sharePresence
|
||||
case hideUnreadMessagesBadge
|
||||
|
||||
case elementCallBaseURL
|
||||
@@ -266,10 +266,10 @@ final class AppSettings {
|
||||
// maptiler api key
|
||||
let mapTilerApiKey = InfoPlistReader.main.mapLibreAPIKey
|
||||
|
||||
// MARK: - Read Receipts
|
||||
// MARK: - Presence
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.sendReadReceiptsEnabled, defaultValue: true, storageType: .userDefaults(store))
|
||||
var sendReadReceiptsEnabled
|
||||
@UserPreference(key: UserDefaultsKeys.sharePresence, defaultValue: true, storageType: .userDefaults(store))
|
||||
var sharePresence
|
||||
|
||||
// MARK: - Feature Flags
|
||||
|
||||
|
||||
@@ -411,7 +411,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
Task {
|
||||
// Mark the room as read on entering but don't send read receipts
|
||||
// as those will be handled by the timeline
|
||||
await roomProxy.markAsRead(sendReadReceipts: false, receiptType: appSettings.sendReadReceiptsEnabled ? .read : .readPrivate)
|
||||
await roomProxy.markAsRead(sendReadReceipts: false, receiptType: appSettings.sharePresence ? .read : .readPrivate)
|
||||
}
|
||||
|
||||
let userID = userSession.clientProxy.userID
|
||||
|
||||
@@ -1316,10 +1316,22 @@ public enum L10n {
|
||||
public static func screenRoomTypingManyMembers(_ p1: Int) -> String {
|
||||
return L10n.tr("Localizable", "screen_room_typing_many_members", p1)
|
||||
}
|
||||
/// %1$@, %2$@ and
|
||||
public static func screenRoomTypingManyMembersFirstComponentIos(_ p1: Any, _ p2: Any) -> String {
|
||||
return L10n.tr("Localizable", "screen_room_typing_many_members_first_component_ios", String(describing: p1), String(describing: p2))
|
||||
}
|
||||
/// Plural format key: "%#@COUNT@"
|
||||
public static func screenRoomTypingManyMembersSecondComponentIos(_ p1: Int) -> String {
|
||||
return L10n.tr("Localizable", "screen_room_typing_many_members_second_component_ios", p1)
|
||||
}
|
||||
/// Plural format key: "%#@COUNT@"
|
||||
public static func screenRoomTypingNotification(_ p1: Int) -> String {
|
||||
return L10n.tr("Localizable", "screen_room_typing_notification", p1)
|
||||
}
|
||||
/// are typing
|
||||
public static var screenRoomTypingNotificationPluralIos: String { return L10n.tr("Localizable", "screen_room_typing_notification_plural_ios") }
|
||||
/// is typing
|
||||
public static var screenRoomTypingNotificationSingularIos: String { return L10n.tr("Localizable", "screen_room_typing_notification_singular_ios") }
|
||||
/// %1$@ and %2$@
|
||||
public static func screenRoomTypingTwoMembers(_ p1: Any, _ p2: Any) -> String {
|
||||
return L10n.tr("Localizable", "screen_room_typing_two_members", String(describing: p1), String(describing: p2))
|
||||
|
||||
@@ -1871,6 +1871,11 @@ class RoomProxyMock: RoomProxyProtocol {
|
||||
set(value) { underlyingMembers = value }
|
||||
}
|
||||
var underlyingMembers: CurrentValuePublisher<[RoomMemberProxyProtocol], Never>!
|
||||
var typingMembers: CurrentValuePublisher<[String], Never> {
|
||||
get { return underlyingTypingMembers }
|
||||
set(value) { underlyingTypingMembers = value }
|
||||
}
|
||||
var underlyingTypingMembers: CurrentValuePublisher<[String], Never>!
|
||||
var joinedMembersCount: Int {
|
||||
get { return underlyingJoinedMembersCount }
|
||||
set(value) { underlyingJoinedMembersCount = value }
|
||||
@@ -2265,6 +2270,28 @@ class RoomProxyMock: RoomProxyProtocol {
|
||||
return markAsReadSendReadReceiptsReceiptTypeReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - sendTypingNotification
|
||||
|
||||
var sendTypingNotificationIsTypingCallsCount = 0
|
||||
var sendTypingNotificationIsTypingCalled: Bool {
|
||||
return sendTypingNotificationIsTypingCallsCount > 0
|
||||
}
|
||||
var sendTypingNotificationIsTypingReceivedIsTyping: Bool?
|
||||
var sendTypingNotificationIsTypingReceivedInvocations: [Bool] = []
|
||||
var sendTypingNotificationIsTypingReturnValue: Result<Void, RoomProxyError>!
|
||||
var sendTypingNotificationIsTypingClosure: ((Bool) async -> Result<Void, RoomProxyError>)?
|
||||
|
||||
@discardableResult
|
||||
func sendTypingNotification(isTyping: Bool) async -> Result<Void, RoomProxyError> {
|
||||
sendTypingNotificationIsTypingCallsCount += 1
|
||||
sendTypingNotificationIsTypingReceivedIsTyping = isTyping
|
||||
sendTypingNotificationIsTypingReceivedInvocations.append(isTyping)
|
||||
if let sendTypingNotificationIsTypingClosure = sendTypingNotificationIsTypingClosure {
|
||||
return await sendTypingNotificationIsTypingClosure(isTyping)
|
||||
} else {
|
||||
return sendTypingNotificationIsTypingReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - canUserJoinCall
|
||||
|
||||
var canUserJoinCallUserIDCallsCount = 0
|
||||
|
||||
@@ -66,6 +66,7 @@ extension RoomProxyMock {
|
||||
ownUserID = configuration.ownUserID
|
||||
|
||||
members = CurrentValueSubject(configuration.members).asCurrentValuePublisher()
|
||||
typingMembers = CurrentValueSubject([]).asCurrentValuePublisher()
|
||||
|
||||
joinedMembersCount = configuration.members.filter { $0.membership == .join }.count
|
||||
activeMembersCount = configuration.members.filter { $0.membership == .join || $0.membership == .invite }.count
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -31,15 +31,24 @@ class RoomProxy: RoomProxyProtocol {
|
||||
|
||||
private var sendMessageBackgroundTask: BackgroundTaskProtocol?
|
||||
|
||||
private(set) var displayName: String?
|
||||
// periphery:ignore - required for instance retention in the rust codebase
|
||||
private var roomInfoObservationToken: TaskHandle?
|
||||
// periphery:ignore - required for instance retention in the rust codebase
|
||||
private var typingNotificationObservationToken: TaskHandle?
|
||||
|
||||
private var subscribedForUpdates = false
|
||||
|
||||
private(set) var displayName: String?
|
||||
|
||||
private let membersSubject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
|
||||
var members: CurrentValuePublisher<[RoomMemberProxyProtocol], Never> {
|
||||
membersSubject.asCurrentValuePublisher()
|
||||
}
|
||||
|
||||
private let typingMembersSubject = CurrentValueSubject<[String], Never>([])
|
||||
var typingMembers: CurrentValuePublisher<[String], Never> {
|
||||
typingMembersSubject.asCurrentValuePublisher()
|
||||
}
|
||||
|
||||
private let actionsSubject = PassthroughSubject<RoomProxyAction, Never>()
|
||||
var actions: AnyPublisher<RoomProxyAction, Never> {
|
||||
@@ -86,6 +95,8 @@ class RoomProxy: RoomProxyProtocol {
|
||||
await timeline.subscribeForUpdates()
|
||||
|
||||
subscribeToRoomStateUpdates()
|
||||
|
||||
subscribeToTypingNotifications()
|
||||
}
|
||||
|
||||
func unsubscribeFromUpdates() {
|
||||
@@ -386,6 +397,18 @@ class RoomProxy: RoomProxyProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func sendTypingNotification(isTyping: Bool) async -> Result<Void, RoomProxyError> {
|
||||
MXLog.info("Sending typing notification isTyping: \(isTyping)")
|
||||
|
||||
do {
|
||||
try await room.typingNotice(isTyping: isTyping)
|
||||
return .success(())
|
||||
} catch {
|
||||
MXLog.error("Failed sending typing notice with error: \(error)")
|
||||
return .failure(.failedSendingTypingNotice)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Element Call
|
||||
|
||||
func canUserJoinCall(userID: String) async -> Result<Bool, RoomProxyError> {
|
||||
@@ -409,6 +432,26 @@ class RoomProxy: RoomProxyProtocol {
|
||||
self?.actionsSubject.send(.stateUpdate)
|
||||
})
|
||||
}
|
||||
|
||||
private func subscribeToTypingNotifications() {
|
||||
Task {
|
||||
typingNotificationObservationToken = await room.subscribeToTypingNotifications(listener: RoomTypingNotificationUpdateListener { [weak self] typingUserIDs in
|
||||
guard let self else { return }
|
||||
|
||||
MXLog.info("Received typing notification update, typingUsers: \(typingUserIDs)")
|
||||
|
||||
let typingMembers = typingUserIDs.compactMap { userID in
|
||||
if let member = self.members.value.filter({ $0.userID == userID }).first {
|
||||
return member.displayName ?? member.userID
|
||||
} else {
|
||||
return userID
|
||||
}
|
||||
}
|
||||
|
||||
typingMembersSubject.send(typingMembers)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class RoomInfoUpdateListener: RoomInfoListener {
|
||||
@@ -422,3 +465,15 @@ private final class RoomInfoUpdateListener: RoomInfoListener {
|
||||
onUpdateClosure()
|
||||
}
|
||||
}
|
||||
|
||||
private final class RoomTypingNotificationUpdateListener: TypingNotificationsListener {
|
||||
private let onUpdateClosure: ([String]) -> Void
|
||||
|
||||
init(_ onUpdateClosure: @escaping ([String]) -> Void) {
|
||||
self.onUpdateClosure = onUpdateClosure
|
||||
}
|
||||
|
||||
func call(typingUserIds: [String]) {
|
||||
onUpdateClosure(typingUserIds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ enum RoomProxyError: Error, Equatable {
|
||||
case failedCheckingPermission
|
||||
case failedMarkingAsRead
|
||||
case failedMarkingAsUnread
|
||||
case failedSendingTypingNotice
|
||||
}
|
||||
|
||||
enum RoomProxyAction {
|
||||
@@ -52,6 +53,7 @@ protocol RoomProxyProtocol {
|
||||
var ownUserID: String { get }
|
||||
|
||||
var name: String? { get }
|
||||
|
||||
var displayName: String? { get }
|
||||
|
||||
var topic: String? { get }
|
||||
@@ -59,6 +61,8 @@ protocol RoomProxyProtocol {
|
||||
var avatarURL: URL? { get }
|
||||
|
||||
var members: CurrentValuePublisher<[RoomMemberProxyProtocol], Never> { get }
|
||||
|
||||
var typingMembers: CurrentValuePublisher<[String], Never> { get }
|
||||
|
||||
var joinedMembersCount: Int { get }
|
||||
|
||||
@@ -108,6 +112,9 @@ protocol RoomProxyProtocol {
|
||||
|
||||
func markAsRead(sendReadReceipts: Bool, receiptType: ReceiptType) async -> Result<Void, RoomProxyError>
|
||||
|
||||
/// https://spec.matrix.org/v1.9/client-server-api/#typing-notifications
|
||||
@discardableResult func sendTypingNotification(isTyping: Bool) async -> Result<Void, RoomProxyError>
|
||||
|
||||
// MARK: - Element Call
|
||||
|
||||
func canUserJoinCall(userID: String) async -> Result<Bool, RoomProxyError>
|
||||
|
||||
@@ -97,7 +97,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
}
|
||||
|
||||
switch await roomProxy.timeline.sendReadReceipt(for: eventID,
|
||||
type: appSettings.sendReadReceiptsEnabled ? .read : .readPrivate) {
|
||||
type: appSettings.sharePresence ? .read : .readPrivate) {
|
||||
case .success:
|
||||
return .success(())
|
||||
case .failure:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ca4fa009a4a584047cdec1c4c58abd2d0ae6dc7feefa1ed4133174e3d1beba89
|
||||
size 144629
|
||||
oid sha256:c374de7541c3218ed8988adc086265a0c1e4cf7fe73461068ef79356c0bfaae0
|
||||
size 138506
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d43b19525a8c71d8348ac2afa212380968ae85d267eb1be696388b75dc476d9b
|
||||
size 311802
|
||||
oid sha256:d28ae52c85e15607b2279f5bd3d595ff0d42d93fcbbdabac608836302a7ea075
|
||||
size 311650
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c871f517b2ef9986bed47791ef01bc549878b676c856301f1efd2d9f092ef0ac
|
||||
size 101509
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d43b19525a8c71d8348ac2afa212380968ae85d267eb1be696388b75dc476d9b
|
||||
size 311802
|
||||
oid sha256:d28ae52c85e15607b2279f5bd3d595ff0d42d93fcbbdabac608836302a7ea075
|
||||
size 311650
|
||||
|
||||
Reference in New Issue
Block a user