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
+4
View File
@@ -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
+12
View File
@@ -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
@@ -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()
+56 -1
View File
@@ -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