diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 02285668c..e57ca35fe 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; CD95B3714F806AC9CF9A557B /* ComposerToolbarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerToolbarViewModel.swift; sourceTree = ""; }; CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = ""; }; + CE47A97726F0675DEE387BF9 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; CEE0E6043EFCF6FD2A341861 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = ""; }; CEE20623EB4A9B88FB29F2BA /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/SAS.strings; sourceTree = ""; }; CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; path = UnitTests.xctestplan; sourceTree = ""; }; @@ -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 */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 388db3425..b20534817 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -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"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict index 5127325c1..990c5adf7 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict @@ -242,6 +242,22 @@ %1$@, %2$@ and %3$d others + screen_room_typing_many_members_second_component_ios + + NSStringLocalizedFormatKey + %#@COUNT@ + COUNT + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d other + other + %d others + + screen_room_typing_notification NSStringLocalizedFormatKey diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 9e9c4f14e..fdabc82bb 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -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 diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 14969512f..f8eed30f8 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -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 diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 530567b6b..e7d2babc3 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -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)) diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 22448ef74..2eaf2ffa7 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -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! + var sendTypingNotificationIsTypingClosure: ((Bool) async -> Result)? + + @discardableResult + func sendTypingNotification(isTyping: Bool) async -> Result { + sendTypingNotificationIsTypingCallsCount += 1 + sendTypingNotificationIsTypingReceivedIsTyping = isTyping + sendTypingNotificationIsTypingReceivedInvocations.append(isTyping) + if let sendTypingNotificationIsTypingClosure = sendTypingNotificationIsTypingClosure { + return await sendTypingNotificationIsTypingClosure(isTyping) + } else { + return sendTypingNotificationIsTypingReturnValue + } + } //MARK: - canUserJoinCall var canUserJoinCallUserIDCallsCount = 0 diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index e8889ecd2..b8bbc9312 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -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 diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift index 182ce766d..0a4a0ed43 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift @@ -40,6 +40,8 @@ enum ComposerToolbarViewModelAction { case composerFocusedChanged(isFocused: Bool) case voiceMessage(ComposerToolbarVoiceMessageAction) + + case contentChanged(isEmpty: Bool) } enum ComposerToolbarViewAction { diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift index f68f92dda..a12d0981a 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift @@ -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 diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index b67ea398c..20e8b931e 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -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): diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index be343d133..6d489f50e 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -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 diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index aeebb44d1..933feacfa 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -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 diff --git a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineItemStatusView.swift b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineItemStatusView.swift index d6e2305b2..5b1e4ce00 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineItemStatusView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineItemStatusView.swift @@ -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 diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift index c98383228..7cd818559 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift @@ -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() + 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 } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TypingIndicatorView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TypingIndicatorView.swift new file mode 100644 index 000000000..b1853a21c --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TypingIndicatorView.swift @@ -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) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift index 56db4163b..1d5d356be 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift @@ -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) { diff --git a/ElementX/Sources/Screens/Settings/AvancedOptionsScreen/AdvancedSettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/AvancedOptionsScreen/AdvancedSettingsScreenModels.swift index 7bed31358..ada958a13 100644 --- a/ElementX/Sources/Screens/Settings/AvancedOptionsScreen/AdvancedSettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/AvancedOptionsScreen/AdvancedSettingsScreenModels.swift @@ -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 { } diff --git a/ElementX/Sources/Screens/Settings/AvancedOptionsScreen/View/AdvancedSettingsScreen.swift b/ElementX/Sources/Screens/Settings/AvancedOptionsScreen/View/AdvancedSettingsScreen.swift index 79ebe7eed..436793f39 100644 --- a/ElementX/Sources/Screens/Settings/AvancedOptionsScreen/View/AdvancedSettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/AvancedOptionsScreen/View/AdvancedSettingsScreen.swift @@ -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() diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index e93ecd517..43147cd33 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -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() var actions: AnyPublisher { @@ -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 { + 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 { @@ -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) + } +} diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 255814613..a23dfa697 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -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 + /// https://spec.matrix.org/v1.9/client-server-api/#typing-notifications + @discardableResult func sendTypingNotification(isTyping: Bool) async -> Result + // MARK: - Element Call func canUserJoinCall(userID: String) async -> Result diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index fad74117f..ad162359f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -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: diff --git a/UnitTests/__Snapshots__/PreviewTests/test_advancedSettingsScreen.1.png b/UnitTests/__Snapshots__/PreviewTests/test_advancedSettingsScreen.1.png index 5937c15d5..092db0fa2 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_advancedSettingsScreen.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_advancedSettingsScreen.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca4fa009a4a584047cdec1c4c58abd2d0ae6dc7feefa1ed4133174e3d1beba89 -size 144629 +oid sha256:c374de7541c3218ed8988adc086265a0c1e4cf7fe73461068ef79356c0bfaae0 +size 138506 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_roomScreen.1.png b/UnitTests/__Snapshots__/PreviewTests/test_roomScreen.1.png index f7685cd87..2d73b9b5f 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_roomScreen.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_roomScreen.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d43b19525a8c71d8348ac2afa212380968ae85d267eb1be696388b75dc476d9b -size 311802 +oid sha256:d28ae52c85e15607b2279f5bd3d595ff0d42d93fcbbdabac608836302a7ea075 +size 311650 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_typingIndicatorView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_typingIndicatorView.1.png new file mode 100644 index 000000000..95b66f321 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_typingIndicatorView.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c871f517b2ef9986bed47791ef01bc549878b676c856301f1efd2d9f092ef0ac +size 101509 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_uITimelineView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_uITimelineView.1.png index f7685cd87..2d73b9b5f 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_uITimelineView.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_uITimelineView.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d43b19525a8c71d8348ac2afa212380968ae85d267eb1be696388b75dc476d9b -size 311802 +oid sha256:d28ae52c85e15607b2279f5bd3d595ff0d42d93fcbbdabac608836302a7ea075 +size 311650