TypingIndicatorView.swift 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. //
  2. // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
  3. // SPDX-License-Identifier: GPL-3.0-or-later
  4. //
  5. import Foundation
  6. import Combine
  7. import SwiftyAttributes
  8. @objcMembers class TypingIndicatorView: UIView, SLKVisibleViewProtocol {
  9. private class TypingUser {
  10. var userIdentifier: String
  11. var displayName: String
  12. var lastUpdate: TimeInterval
  13. init(userIdentifier: String, displayName: String) {
  14. self.userIdentifier = userIdentifier
  15. self.displayName = displayName
  16. self.lastUpdate = Date().timeIntervalSinceReferenceDate
  17. }
  18. public func updateTimestamp() {
  19. self.lastUpdate = Date().timeIntervalSinceReferenceDate
  20. }
  21. }
  22. dynamic var isVisible: Bool = false
  23. private var typingUsers: [TypingUser] = []
  24. private var previousUpdateTimestamp: TimeInterval = .zero
  25. private var updateTimer: Timer?
  26. private var removeTimer: Timer?
  27. @IBOutlet var contentView: UIView!
  28. @IBOutlet weak var typingLabel: UILabel!
  29. override init(frame: CGRect) {
  30. super.init(frame: frame)
  31. commonInit()
  32. }
  33. required init?(coder aDecoder: NSCoder) {
  34. super.init(coder: aDecoder)
  35. commonInit()
  36. }
  37. func commonInit() {
  38. Bundle.main.loadNibNamed("TypingIndicatorView", owner: self, options: nil)
  39. addSubview(contentView)
  40. contentView.frame = frame
  41. contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
  42. contentView.backgroundColor = .clear
  43. typingLabel.text = ""
  44. removeTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] _ in
  45. self?.checkInactiveTypingUsers()
  46. })
  47. }
  48. deinit {
  49. self.removeTimer?.invalidate()
  50. }
  51. private func getUsersTypingString() -> NSAttributedString {
  52. // Array keep the order of the elements, no need to sort here manually
  53. if self.typingUsers.count == 1 {
  54. // Alice
  55. return self.typingUsers[0].displayName.withTextColor(.secondaryLabel)
  56. } else {
  57. let separator = ", ".withTextColor(.tertiaryLabel)
  58. let separatorSpace = NSAttributedString(string: " ")
  59. let separatorLast = NSLocalizedString("and", comment: "Alice and Bob").withTextColor(.tertiaryLabel)
  60. if self.typingUsers.count == 2 {
  61. // Alice and Bob
  62. let user1 = self.typingUsers[0].displayName.withTextColor(.secondaryLabel)
  63. let user2 = self.typingUsers[1].displayName.withTextColor(.secondaryLabel)
  64. return user1 + separatorSpace + separatorLast + separatorSpace + user2
  65. } else if self.typingUsers.count == 3 {
  66. // Alice, Bob and Charlie
  67. let user1 = self.typingUsers[0].displayName.withTextColor(.secondaryLabel)
  68. let user2 = self.typingUsers[1].displayName.withTextColor(.secondaryLabel)
  69. let user3 = self.typingUsers[2].displayName.withTextColor(.secondaryLabel)
  70. return user1 + separator + user2 + separatorSpace + separatorLast + separatorSpace + user3
  71. } else {
  72. // Alice, Bob, Charlie
  73. let user1 = self.typingUsers[0].displayName.withTextColor(.secondaryLabel)
  74. let user2 = self.typingUsers[1].displayName.withTextColor(.secondaryLabel)
  75. let user3 = self.typingUsers[2].displayName.withTextColor(.secondaryLabel)
  76. return user1 + separator + user2 + separator + user3
  77. }
  78. }
  79. }
  80. private func updateTypingIndicator() {
  81. if self.typingUsers.isEmpty {
  82. // Just hide the label to have a nice animation. Otherwise we would animate an empty label/space
  83. self.isVisible = false
  84. } else {
  85. let attributedSpace = NSAttributedString(string: " ")
  86. var localizedSuffix: NSAttributedString
  87. if self.typingUsers.count == 1 {
  88. localizedSuffix = NSLocalizedString("is typing…", comment: "Alice is typing…").withTextColor(.tertiaryLabel)
  89. } else if self.typingUsers.count == 2 || self.typingUsers.count == 3 {
  90. localizedSuffix = NSLocalizedString("are typing…", comment: "Alice and Bob are typing…").withTextColor(.tertiaryLabel)
  91. } else if self.typingUsers.count == 4 {
  92. localizedSuffix = NSLocalizedString("and 1 other is typing…", comment: "Alice, Bob, Charlie and 1 other is typing…").withTextColor(.tertiaryLabel)
  93. } else {
  94. let localizedString = NSLocalizedString("and %ld others are typing…", comment: "Alice, Bob, Charlie and 3 others are typing…")
  95. let formattedString = String(format: localizedString, self.typingUsers.count - 3)
  96. localizedSuffix = formattedString.withTextColor(.tertiaryLabel)
  97. }
  98. UIView.transition(with: self.typingLabel,
  99. duration: 0.2,
  100. options: .transitionCrossDissolve,
  101. animations: {
  102. let newTypingText = self.getUsersTypingString() + attributedSpace + localizedSuffix
  103. self.typingLabel.attributedText = newTypingText.withFont(.preferredFont(forTextStyle: .body))
  104. }, completion: nil)
  105. self.isVisible = true
  106. }
  107. self.previousUpdateTimestamp = Date().timeIntervalSinceReferenceDate
  108. }
  109. private func updateTypingIndicatorDebounced() {
  110. // There's already an update planned, no need to do that again
  111. if updateTimer != nil {
  112. return
  113. }
  114. let currentUpdateTimestamp: TimeInterval = Date().timeIntervalSinceReferenceDate
  115. // Update the typing indicator at max. every second
  116. let timestampDiff = currentUpdateTimestamp - previousUpdateTimestamp
  117. if timestampDiff < 1.0 {
  118. self.updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0 - timestampDiff, repeats: false, block: { _ in
  119. self.updateTypingIndicator()
  120. self.updateTimer = nil
  121. })
  122. } else {
  123. self.updateTypingIndicator()
  124. }
  125. }
  126. func checkInactiveTypingUsers() {
  127. let currentUpdateTimestamp: TimeInterval = Date().timeIntervalSinceReferenceDate
  128. var usersToRemove: [TypingUser] = []
  129. for typingUser in typingUsers {
  130. let timestampDiff = currentUpdateTimestamp - typingUser.lastUpdate
  131. if timestampDiff >= 15 {
  132. // We did not receive an update for that user in the last 15 seconds -> remove it
  133. usersToRemove.append(typingUser)
  134. }
  135. }
  136. // Remove the users. Do that after iterating typingUsers to not alter the collection while iterating
  137. for typingUser in usersToRemove {
  138. self.removeTyping(userIdentifier: typingUser.userIdentifier)
  139. }
  140. }
  141. func addTyping(userIdentifier: String, displayName: String) {
  142. let existingEntry = self.typingUsers.first(where: { $0.userIdentifier == userIdentifier })
  143. if existingEntry == nil {
  144. let newEntry = TypingUser(userIdentifier: userIdentifier, displayName: displayName)
  145. self.typingUsers.append(newEntry)
  146. } else {
  147. // We received another startedTyping message, so we want to restart the remove timer
  148. existingEntry?.updateTimestamp()
  149. }
  150. self.updateTypingIndicatorDebounced()
  151. }
  152. func removeTyping(userIdentifier: String) {
  153. let existingIndex = self.typingUsers.firstIndex(where: { $0.userIdentifier == userIdentifier })
  154. if let existingIndex = existingIndex {
  155. self.typingUsers.remove(at: existingIndex)
  156. }
  157. self.updateTypingIndicatorDebounced()
  158. }
  159. }