diff --git a/Ring/Ring/Bridging/ConversationsAdapter.mm b/Ring/Ring/Bridging/ConversationsAdapter.mm index a36f3359d6e93b1db9cc4bd50a147acc1d0fafbe..510de991217af1c9a8f9f07324eb592630ae717e 100644 --- a/Ring/Ring/Bridging/ConversationsAdapter.mm +++ b/Ring/Ring/Bridging/ConversationsAdapter.mm @@ -88,9 +88,14 @@ static id <MessagesAdapterDelegate> _messagesDelegate; confHandlers.insert(exportable_callback<ConfigurationSignal::ComposingStatusChanged>([&](const std::string& account_id, const std::string& convId, const std::string& from, int status) { if (ConversationsAdapter.messagesDelegate) { - NSString* fromPeer = [NSString stringWithUTF8String:from.c_str()]; - NSString* toAccount = [NSString stringWithUTF8String:account_id.c_str()]; - [ConversationsAdapter.messagesDelegate detectingMessageTyping:fromPeer for:toAccount status:status]; + NSString* fromPeer = [NSString stringWithUTF8String:from.c_str()]; + NSString* toAccount = [NSString stringWithUTF8String:account_id.c_str()]; + NSString* conversationId = [NSString stringWithUTF8String:convId.c_str()]; + + [ConversationsAdapter.messagesDelegate composingStatusChangedWithAccountId:toAccount + conversationId:conversationId + from:fromPeer + status:status]; } })); @@ -225,11 +230,11 @@ static id <MessagesAdapterDelegate> _messagesDelegate; [Utils dictionnaryToMap:content], flag); } -- (void)setComposingMessageTo:(NSString*)peer +- (void)setComposingMessageTo:(NSString*)conversationUri fromAccount:(NSString*)accountID isComposing:(BOOL)isComposing { setIsComposing(std::string([accountID UTF8String]), - std::string([peer UTF8String]), + std::string([conversationUri UTF8String]), isComposing); } diff --git a/Ring/Ring/Constants/Generated/Strings.swift b/Ring/Ring/Constants/Generated/Strings.swift index 8f869d71f027bb5b5f796ac06df255a5cd3c1559..cd29b0af871757dacd2066e7af726ebd46f12b97 100644 --- a/Ring/Ring/Constants/Generated/Strings.swift +++ b/Ring/Ring/Constants/Generated/Strings.swift @@ -287,6 +287,8 @@ internal enum L10n { internal static let changePassword = L10n.tr("Localizable", "accountPage.changePassword", fallback: "Change password") /// Incorrect password internal static let changePasswordError = L10n.tr("Localizable", "accountPage.changePasswordError", fallback: "Incorrect password") + /// Chats + internal static let chats = L10n.tr("Localizable", "accountPage.chats", fallback: "Chats") /// Connectivity and configurations internal static let connectivityAndConfiguration = L10n.tr("Localizable", "accountPage.connectivityAndConfiguration", fallback: "Connectivity and configurations") /// Connectivity @@ -451,6 +453,8 @@ internal enum L10n { internal static let turnServer = L10n.tr("Localizable", "accountPage.turnServer", fallback: "TURN address") /// TURN username internal static let turnUsername = L10n.tr("Localizable", "accountPage.turnUsername", fallback: "TURN username") + /// Typing indicator + internal static let typingIndicator = L10n.tr("Localizable", "accountPage.typingIndicator", fallback: "Typing indicator") /// Unblock internal static let unblockContact = L10n.tr("Localizable", "accountPage.unblockContact", fallback: "Unblock") /// Unlink @@ -660,6 +664,22 @@ internal enum L10n { } /// You have accepted the conversation invitation. internal static let synchronizationTitle = L10n.tr("Localizable", "conversation.synchronizationTitle", fallback: "You have accepted the conversation invitation.") + /// %@ is typing + internal static func typingIndicatorOneUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "conversation.typingIndicatorOneUser", String(describing: p1), fallback: "%@ is typing") + } + /// %1@, %2@ and %3@ others are typing + internal static func typingIndicatorOthersUsers(_ p1: Any, _ p2: Any, _ p3: Any) -> String { + return L10n.tr("Localizable", "conversation.typingIndicatorOthersUsers", String(describing: p1), String(describing: p2), String(describing: p3), fallback: "%1@, %2@ and %3@ others are typing") + } + /// %1@, %2@ and %3@ other are typing + internal static func typingIndicatorOtherUsers(_ p1: Any, _ p2: Any, _ p3: Any) -> String { + return L10n.tr("Localizable", "conversation.typingIndicatorOtherUsers", String(describing: p1), String(describing: p2), String(describing: p3), fallback: "%1@, %2@ and %3@ other are typing") + } + /// %1@ and %2@ are typing + internal static func typingIndicatorTwoUsers(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "conversation.typingIndicatorTwoUsers", String(describing: p1), String(describing: p2), fallback: "%1@ and %2@ are typing") + } /// You internal static let yourself = L10n.tr("Localizable", "conversation.yourself", fallback: "You") } diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift index 6bdb01559cca2c54c9a933c0f87bcc0c20cadfbc..fd1ca49fb5a066539a49496f0242f4caf9d15f53 100644 --- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift +++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift @@ -166,6 +166,8 @@ class ConversationViewController: UIViewController, self.recordVideo() case .sendFile: self.importDocument() + case .registerTypingIndicator(let typingStatus): + self.viewModel.setIsComposingMsg(isComposing: typingStatus) } }) .disposed(by: self.disposeBag) diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift index 99842441309ca0ff4de6a2c350ed6ecca2c1582c..adc316ac1d47f5ae3691a9514d9ecb419c72f649 100644 --- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift +++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift @@ -369,6 +369,17 @@ class ConversationViewModel: Stateable, ViewModel, ObservableObject, Identifiabl self.conversationsService.editSwarmMessage(conversationId: conversation.id, accountId: conversation.accountId, message: content, parentId: messageId) } + func setIsComposingMsg(isComposing: Bool) { + if let conversationx = conversation { + guard let uri = conversationx.getConversationURI() else { return } + conversationsService.setIsComposingMsg( + to: uri, + from: conversation.accountId, + isComposing: isComposing + ) + } + } + func sendMessage(withContent content: String, parentId: String = "", contactURI: String? = nil, conversationModel: ConversationModel? = nil) { let conversation = conversationModel ?? self.conversation guard let conversation = conversation else { return } diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagePanelVM.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagePanelVM.swift index ad42356be25fb796838954f3352ce9d5d1d02462..1eb01e05ca169a3d7914aedb009b81998a3b5c1b 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagePanelVM.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagePanelVM.swift @@ -125,6 +125,14 @@ class MessagePanelVM: ObservableObject, MessageAppearanceProtocol { isEdit = false } + func handleTyping(message: String) { + if !message.isEmpty { + messagePanelState.onNext(MessagePanelState.registerTypingIndicator(typingStatus: true)) + } else { + messagePanelState.onNext(MessagePanelState.registerTypingIndicator(typingStatus: false)) + } + } + func updateUsername(name: String, jamiId: String) { guard let message = messageToReply, !name.isEmpty else { return } if message.message.authorId == jamiId { diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift index cf88288291782c6efe6b76ebe2c88d582f5fb17f..e5db09e91129566f8f354261a41a20135080ba2e 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift @@ -38,6 +38,7 @@ enum MessageInfo: State { enum MessagePanelState: State { case sendMessage(content: String, parentId: String) case editMessage(content: String, messageId: String) + case registerTypingIndicator(typingStatus: Bool) case sendPhoto case openGalery case shareLocation @@ -63,6 +64,8 @@ enum MessagePanelState: State { return L10n.Alerts.uploadFile case .sendPhoto: return "send photo" + case .registerTypingIndicator: + return "typing indicator" } } @@ -147,6 +150,7 @@ class MessagesListVM: ObservableObject { var presenceService: PresenceService var transferHelper: TransferHelper var messagePanel: MessagePanelVM + var currentlyTypingUsers: Set<String> = [] // state private let contextStateSubject = PublishSubject<State>() @@ -211,6 +215,8 @@ class MessagesListVM: ObservableObject { self.subscribeReactions() } + @Published var typingIndicatorText = "" + init (injectionBag: InjectionBag, transferHelper: TransferHelper) { self.requestsService = injectionBag.requestsService self.conversation = ConversationModel() @@ -231,6 +237,7 @@ class MessagesListVM: ObservableObject { self.subscribeReplyTarget() self.subscribeMessagesActions() self.subscribeContextMenu() + self.subscribeToTypingStatus() } func subscribeScreenTapped(screenTapped: Observable<Bool>) { @@ -800,6 +807,54 @@ class MessagesListVM: ObservableObject { .disposed(by: self.disposeBag) } + func subscribeToTypingStatus() { + conversationService.typingStatusStream + .filter { [weak self] status in + guard let self = self else { return false } + return status.conversationId == self.conversation.id + } + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] status in + guard let self = self else { return } + + var typerName = status.from + + if let nameObservable = self.names.get(key: status.from) as? BehaviorRelay<String> { + typerName = nameObservable.value + } + + if status.status == 1 { + self.currentlyTypingUsers.insert(typerName) + } else { + self.currentlyTypingUsers.remove(typerName) + } + + self.updateTypingIndicatorText() + }) + .disposed(by: disposeBag) + } + + private func updateTypingIndicatorText() { + let users = Array(currentlyTypingUsers) + + switch users.count { + case 0: + typingIndicatorText = "" + case 1: + typingIndicatorText = L10n.Conversation.typingIndicatorOneUser(users[0]) + case 2: + typingIndicatorText = L10n.Conversation.typingIndicatorTwoUsers(users[0], users[1]) + default: + let othersTypingCount = users.count - 2 + + if othersTypingCount == 1 { + typingIndicatorText = L10n.Conversation.typingIndicatorOtherUsers(users[0], users[1], othersTypingCount) + } else { + typingIndicatorText = L10n.Conversation.typingIndicatorOthersUsers(users[0], users[1], othersTypingCount) + } + } + } + private func updateColorPreference() { guard let color = UIColor(hexString: self.conversation.preferences.color) else { return } DispatchQueue.main.async { [weak self] in diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift index cc7c96cebe7a65d7c1718145f3c643c1eb84bb5b..e3f005e8953b94213e4f31daaae2475c819dc6a8 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagePanelView.swift @@ -190,6 +190,9 @@ struct MessagePanelView: View { .cornerRadius(18) .accessibilityHidden(true) } + .onChange(of: text) { _ in + model.handleTyping(message: text) + } Spacer() .frame(width: 10) Button(action: { diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift index 93a373a6c14d8de8bf243d91f84801ddb1033f01..bf076f97b082ac9756f7cdc086a08d862f7e200b 100644 --- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift +++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift @@ -65,6 +65,9 @@ struct MessagesListView: View { @SwiftUI.State private var reactionsForMessage: ReactionsContainerModel? @SwiftUI.State private var showReactionsView = false + @SwiftUI.State private var dotCount = 0 + private let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() + var body: some View { ZStack { ZStack(alignment: .top) { @@ -170,6 +173,24 @@ struct MessagesListView: View { // scroll to the bottom Text("") .id("lastMessage") + if !model.typingIndicatorText.isEmpty { + HStack { + Text("\(model.typingIndicatorText)\(String(repeating: ".", count: dotCount))") + .font(.footnote) + .foregroundColor(Color(UIColor.secondaryLabel)) + Spacer() + } + .flipped() + .padding(.horizontal) + .padding(.vertical, 5) + .accessibilityElement() + .accessibilityLabel("\(model.typingIndicatorText)") + .id(model.typingIndicatorText) + .transition(.opacity) + .onReceive(timer) { _ in + dotCount = (dotCount + 1) % 4 + } + } // messages ForEach(model.messagesModels) { message in createMessageRowView(for: message) diff --git a/Ring/Ring/Resources/en.lproj/Localizable.strings b/Ring/Ring/Resources/en.lproj/Localizable.strings index 92dc69f4ef1183da06601f6eb0f7451744e101b8..6644c7df71744afc0a626e57eb69b8b6d05f05d2 100644 --- a/Ring/Ring/Resources/en.lproj/Localizable.strings +++ b/Ring/Ring/Resources/en.lproj/Localizable.strings @@ -161,6 +161,11 @@ "conversation.edited" = "Edited"; "conversation.deletedMessage" = "%@ deleted a message"; "conversation.contactBlocked" = "Contact blocked"; +"conversation.typingIndicatorOneUser" = "%@ is typing"; +"conversation.typingIndicatorTwoUsers" = "%1@ and %2@ are typing"; +"conversation.typingIndicatorOtherUsers" = "%1@, %2@ and %3@ other are typing"; +"conversation.typingIndicatorOthersUsers" = "%1@, %2@ and %3@ others are typing"; + // Invitations "invitations.noInvitations" = "No invitations"; @@ -400,6 +405,8 @@ "accountPage.bootstrap" = "Bootstrap"; "accountPage.dhtConfiguration" = "OpenDHT configuration"; "accountPage.nameServer" = "Name server"; +"accountPage.chats" = "Chats"; +"accountPage.typingIndicator" = "Typing indicator"; // Backup Account "backupAccount.explanation" = "This Jami account exists only on this device. The account will be lost if this device is lost or if the application is uninstalled. It is recommended to make a backup of this account."; diff --git a/Ring/Ring/Services/ConversationsManager.swift b/Ring/Ring/Services/ConversationsManager.swift index 4c96b7ff6bccec3b2c8454207a851f05b7380246..9d77b1bdade3579aebde247b0b8ca895345de6bf 100644 --- a/Ring/Ring/Services/ConversationsManager.swift +++ b/Ring/Ring/Services/ConversationsManager.swift @@ -515,10 +515,6 @@ class ConversationsManager { in: conversationId) } - func detectingMessageTyping(_ from: String, for accountId: String, status: Int) { - conversationService.detectingMessageTyping(from, for: accountId, status: status) - } - func conversationProfileUpdated(conversationId: String, accountId: String, profile: [String: String]) { conversationService.conversationProfileUpdated(conversationId: conversationId, accountId: accountId, profile: profile) } @@ -613,6 +609,10 @@ extension ConversationsManager: MessagesAdapterDelegate { self.conversationService.reactionAdded(conversationId: conversationId, accountId: accountId, messageId: messageId, reaction: reaction) } + func composingStatusChanged(accountId: String, conversationId: String, from: String, status: Int) { + self.conversationService.composingStatusChanged(accountId: accountId, conversationId: conversationId, from: from, status: status) + } + func reactionRemoved(conversationId: String, accountId: String, messageId: String, reactionId: String) { self.conversationService.reactionRemoved(conversationId: conversationId, accountId: accountId, messageId: messageId, reactionId: reactionId) } diff --git a/Ring/Ring/Services/ConversationsService.swift b/Ring/Ring/Services/ConversationsService.swift index 6105eccec11be792ac531f5970d60cf629570e52..0bf55f961238001e0018ac02a26dca3a8886b8fc 100644 --- a/Ring/Ring/Services/ConversationsService.swift +++ b/Ring/Ring/Services/ConversationsService.swift @@ -486,6 +486,28 @@ class ConversationsService { conversation.reactionRemoved(messageId: messageId, reactionId: reactionId) } + struct TypingStatus { + let from: String + let status: Int + let conversationId: String + } + + func composingStatusChanged(accountId: String, conversationId: String, from: String, status: Int) { + guard let conversation = self.getConversationForId(conversationId: conversationId, accountId: accountId) else { + return + } + + let typingStatus = TypingStatus(from: from, status: status, conversationId: conversationId) + + typingStatusSubject.onNext(typingStatus) + } + + let typingStatusSubject = ReplaySubject<TypingStatus>.create(bufferSize: 1) + + var typingStatusStream: Observable<TypingStatus> { + return typingStatusSubject.asObservable() + } + func messageUpdated(conversationId: String, accountId: String, message: SwarmMessageWrap, localJamiId: String) { guard let conversation = self.getConversationForId(conversationId: conversationId, accountId: accountId) else { return } conversation.messageUpdated(swarmMessage: message, localJamiId: localJamiId) @@ -954,17 +976,8 @@ class ConversationsService { // MARK: typing indicator - func setIsComposingMsg(to peer: String, from account: String, isComposing: Bool) { - conversationsAdapter.setComposingMessageTo(peer, fromAccount: account, isComposing: isComposing) - } - - func detectingMessageTyping(_ from: String, for accountId: String, status: Int) { - let serviceEventType: ServiceEventType = .messageTypingIndicator - var serviceEvent = ServiceEvent(withEventType: serviceEventType) - serviceEvent.addEventInput(.peerUri, value: from) - serviceEvent.addEventInput(.accountId, value: accountId) - serviceEvent.addEventInput(.state, value: status) - self.responseStream.onNext(serviceEvent) + func setIsComposingMsg(to conversationUri: String, from accountId: String, isComposing: Bool) { + conversationsAdapter.setComposingMessageTo(conversationUri, fromAccount: accountId, isComposing: isComposing) } } diff --git a/Ring/Ring/Services/MessagesAdapterDelegate.swift b/Ring/Ring/Services/MessagesAdapterDelegate.swift index 1d6f399e81a750003ef7b8b61559b4ede4bc8db0..f3361761254957949003640949924ea3fe55b0c4 100644 --- a/Ring/Ring/Services/MessagesAdapterDelegate.swift +++ b/Ring/Ring/Services/MessagesAdapterDelegate.swift @@ -26,8 +26,6 @@ to receiverAccountId: String) func messageStatusChanged(_ status: MessageStatus, for messageId: String, from accountId: String, to jamiId: String, in conversationId: String) - func detectingMessageTyping(_ from: String, for accountId: String, status: Int) - func conversationLoaded(conversationId: String, accountId: String, messages: [SwarmMessageWrap], requestId: Int) func messageLoaded(conversationId: String, accountId: String, messages: [[String: String]]) func newInteraction(conversationId: String, accountId: String, message: SwarmMessageWrap) @@ -38,6 +36,7 @@ func conversationProfileUpdated(conversationId: String, accountId: String, profile: [String: String]) func conversationPreferencesUpdated(conversationId: String, accountId: String, preferences: [String: String]) func reactionAdded(conversationId: String, accountId: String, messageId: String, reaction: [String: String]) + func composingStatusChanged(accountId: String, conversationId: String, from: String, status: Int) func reactionRemoved(conversationId: String, accountId: String, messageId: String, reactionId: String) func messageUpdated(conversationId: String, accountId: String, message: SwarmMessageWrap) } diff --git a/Ring/Ring/Services/ServiceEvent.swift b/Ring/Ring/Services/ServiceEvent.swift index 5a65a816eb80fe49d063645d25e79e6c3360321a..a220896389a5cc125263634cdb8e2b96f341539a 100644 --- a/Ring/Ring/Services/ServiceEvent.swift +++ b/Ring/Ring/Services/ServiceEvent.swift @@ -49,7 +49,6 @@ enum ServiceEventType { case callProviderPreviewPendingCall case audioActivated case newOutgoingMessage - case messageTypingIndicator case migrationEnded case lastDisplayedMessageUpdated case presenseSubscribed