Эх сурвалжийг харах

Align typing indicator to new concept

# Send start/stop typing
Send "Typing" every 10 sec when there was a change

Send stop typing:
- when input is deleted
- when there was no input during the 10s timer
- when on leaving room

# Receive start/stop typing
Clear typing for participant after 15s if no start typing-message was received.
Use userId instead sessionId to manage typing participants. This ensures participants are not shown multiple times when using multiple devices with the same user (multisession). To get the userId via websocket, SignalingMessageReceiver and WebSocketInstance had to be modified to pass the CallWebSocketMessage in case the signalingMessage.type is related to typing. Not sure if this is the best solution but didn't find any other way.

Typing is not handled when the userId is of the own user (this could happen when using multiple devices)

In case userId is null (which happens for guests), their sessionId is used as key for the typingParticipants map.

# Other
Disable setting for typing indicator when no HPB is used + Avoid crash in chat when no HPB is used.

Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Marcel Hibbe 1 жил өмнө
parent
commit
7f51d45e9a

+ 72 - 35
app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt

@@ -159,8 +159,8 @@ import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity
 import com.nextcloud.talk.repositories.reactions.ReactionsRepository
 import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
 import com.nextcloud.talk.signaling.SignalingMessageReceiver
-import com.nextcloud.talk.translate.ui.TranslateActivity
 import com.nextcloud.talk.signaling.SignalingMessageSender
+import com.nextcloud.talk.translate.ui.TranslateActivity
 import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet
 import com.nextcloud.talk.ui.dialog.AttachmentDialog
 import com.nextcloud.talk.ui.dialog.MessageActionsDialog
@@ -319,7 +319,8 @@ class ChatActivity :
     }
 
     var typingTimer: CountDownTimer? = null
-    val typingParticipants = HashMap<String, String>()
+    var typedWhileTypingTimerIsRunning: Boolean = false
+    val typingParticipants = HashMap<String, TypingParticipant>()
 
     private val localParticipantMessageListener = object : SignalingMessageReceiver.LocalParticipantMessageListener {
         override fun onSwitchTo(token: String?) {
@@ -334,23 +335,38 @@ class ChatActivity :
     }
 
     private val conversationMessageListener = object : SignalingMessageReceiver.ConversationMessageListener {
-        override fun onStartTyping(session: String) {
-            if (!CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)) {
-                var name = webSocketInstance?.getDisplayNameForSession(session)
+        override fun onStartTyping(userId: String?, session: String?) {
+            val userIdOrGuestSession = userId ?: session
+
+            if (isTypingStatusEnabled() && conversationUser?.userId != userIdOrGuestSession) {
+                var displayName = webSocketInstance?.getDisplayNameForSession(session)
+
+                if (displayName != null && !typingParticipants.contains(userIdOrGuestSession)) {
+                    if (displayName == "") {
+                        displayName = context.resources?.getString(R.string.nc_guest)!!
+                    }
+
+                    runOnUiThread {
+                        val typingParticipant = TypingParticipant(userIdOrGuestSession!!, displayName) {
+                            typingParticipants.remove(userIdOrGuestSession)
+                            updateTypingIndicator()
+                        }
 
-                if (name != null && !typingParticipants.contains(session)) {
-                    if (name == "") {
-                        name = context.resources?.getString(R.string.nc_guest)!!
+                        typingParticipants[userIdOrGuestSession] = typingParticipant
+                        updateTypingIndicator()
                     }
-                    typingParticipants[session] = name
-                    updateTypingIndicator()
+                } else if (typingParticipants.contains(userIdOrGuestSession)) {
+                    typingParticipants[userIdOrGuestSession]?.restartTimer()
                 }
             }
         }
 
-        override fun onStopTyping(session: String) {
-            if (!CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)) {
-                typingParticipants.remove(session)
+        override fun onStopTyping(userId: String?, session: String?) {
+            val userIdOrGuestSession = userId ?: session
+
+            if (isTypingStatusEnabled() && conversationUser?.userId != userId) {
+                typingParticipants[userIdOrGuestSession]?.cancelTimer()
+                typingParticipants.remove(userIdOrGuestSession)
                 updateTypingIndicator()
             }
         }
@@ -544,7 +560,7 @@ class ChatActivity :
 
             @Suppress("Detekt.TooGenericExceptionCaught")
             override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
-                sendStartTypingMessage()
+                updateOwnTypingStatus(s)
 
                 if (s.length >= lengthFilter) {
                     binding?.messageInputView?.inputEditText?.error = String.format(
@@ -922,7 +938,11 @@ class ChatActivity :
             return DisplayUtils.ellipsize(text, TYPING_INDICATOR_MAX_NAME_LENGTH)
         }
 
-        val participantNames = ArrayList(typingParticipants.values)
+        val participantNames = ArrayList<String>()
+
+        for (typingParticipant in typingParticipants.values) {
+            participantNames.add(typingParticipant.name)
+        }
 
         val typingString: SpannableStringBuilder
         when (typingParticipants.size) {
@@ -998,42 +1018,51 @@ class ChatActivity :
         }
     }
 
-    fun sendStartTypingMessage() {
-        if (webSocketInstance == null) {
-            return
+    fun updateOwnTypingStatus(typedText: CharSequence) {
+        fun sendStartTypingSignalingMessage() {
+            for ((sessionId, participant) in webSocketInstance?.getUserMap()!!) {
+                val ncSignalingMessage = NCSignalingMessage()
+                ncSignalingMessage.to = sessionId
+                ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE
+                signalingMessageSender!!.send(ncSignalingMessage)
+            }
         }
 
-        if (!CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)) {
-            if (typingTimer == null) {
-                for ((sessionId, participant) in webSocketInstance?.getUserMap()!!) {
-                    val ncSignalingMessage = NCSignalingMessage()
-                    ncSignalingMessage.to = sessionId
-                    ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE
-                    signalingMessageSender!!.send(ncSignalingMessage)
-                }
+        if (isTypingStatusEnabled()) {
+            if (typedText.isEmpty()) {
+                sendStopTypingMessage()
+            } else if (typingTimer == null) {
+                sendStartTypingSignalingMessage()
 
                 typingTimer = object : CountDownTimer(
-                    TYPING_DURATION_BEFORE_SENDING_STOP,
-                    TYPING_DURATION_BEFORE_SENDING_STOP
+                    TYPING_DURATION_TO_SEND_NEXT_TYPING_MESSAGE,
+                    TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE
                 ) {
                     override fun onTick(millisUntilFinished: Long) {
-                        // unused atm
+                        // unused
                     }
 
                     override fun onFinish() {
-                        sendStopTypingMessage()
+                        if (typedWhileTypingTimerIsRunning) {
+                            sendStartTypingSignalingMessage()
+                            cancel()
+                            start()
+                            typedWhileTypingTimerIsRunning = false
+                        } else {
+                            sendStopTypingMessage()
+                        }
                     }
                 }.start()
             } else {
-                typingTimer?.cancel()
-                typingTimer?.start()
+                typedWhileTypingTimerIsRunning = true
             }
         }
     }
 
-    fun sendStopTypingMessage() {
-        if (!CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)) {
+    private fun sendStopTypingMessage() {
+        if (isTypingStatusEnabled()) {
             typingTimer = null
+            typedWhileTypingTimerIsRunning = false
 
             for ((sessionId, participant) in webSocketInstance?.getUserMap()!!) {
                 val ncSignalingMessage = NCSignalingMessage()
@@ -1044,6 +1073,11 @@ class ChatActivity :
         }
     }
 
+    private fun isTypingStatusEnabled(): Boolean {
+        return webSocketInstance != null &&
+            !CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)
+    }
+
     private fun getRoomInfo() {
         logConversationInfos("getRoomInfo")
 
@@ -2347,6 +2381,8 @@ class ChatActivity :
                     Log.d(TAG, "leaveRoom - leaveRoom - got response: $startNanoTime")
                     logConversationInfos("leaveRoom#onNext")
 
+                    sendStopTypingMessage()
+
                     checkingLobbyStatus = false
 
                     if (getRoomInfoTimerHandler != null) {
@@ -3810,7 +3846,8 @@ class ChatActivity :
         private const val COMMA = ", "
         private const val TYPING_INDICATOR_ANIMATION_DURATION = 200L
         private const val TYPING_INDICATOR_MAX_NAME_LENGTH = 14
-        private const val TYPING_DURATION_BEFORE_SENDING_STOP = 4000L
+        private const val TYPING_DURATION_TO_SEND_NEXT_TYPING_MESSAGE = 10000L
+        private const val TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE = 1000L
         private const val TYPING_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping"
         private const val TYPING_STOPPED_SIGNALING_MESSAGE_TYPE = "stoppedTyping"
     }

+ 59 - 0
app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt

@@ -0,0 +1,59 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * Copyright (C) 2021-2022 Marcel Hibbe <dev@mhibbe.de>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.chat
+
+import android.os.CountDownTimer
+
+class TypingParticipant(val userId: String, val name: String, val funToCallWhenTimeIsUp: (userId: String) -> Unit) {
+    var timer: CountDownTimer? = null
+
+    init {
+        startTimer()
+    }
+
+    private fun startTimer() {
+        timer = object : CountDownTimer(
+            TYPING_DURATION_TO_HIDE_TYPING_MESSAGE,
+            TYPING_DURATION_TO_HIDE_TYPING_MESSAGE
+        ) {
+            override fun onTick(millisUntilFinished: Long) {
+                // unused
+            }
+
+            override fun onFinish() {
+                funToCallWhenTimeIsUp(userId)
+            }
+        }.start()
+    }
+
+    fun restartTimer() {
+        timer?.cancel()
+        timer?.start()
+    }
+
+    fun cancelTimer() {
+        timer?.cancel()
+    }
+
+    companion object {
+        private const val TYPING_DURATION_TO_HIDE_TYPING_MESSAGE = 15000L
+    }
+}

+ 25 - 6
app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt

@@ -629,6 +629,7 @@ class SettingsActivity : BaseActivity() {
                     PorterDuff.Mode.SRC_IN
                 )
             }
+
             CapabilitiesUtilNew.isServerAlmostEOL(currentUser!!) -> {
                 binding.serverAgeWarningText.setTextColor(
                     ContextCompat.getColor((context), R.color.nc_darkYellow)
@@ -639,6 +640,7 @@ class SettingsActivity : BaseActivity() {
                     PorterDuff.Mode.SRC_IN
                 )
             }
+
             else -> {
                 binding.serverAgeWarningTextCard.visibility = View.GONE
             }
@@ -664,17 +666,31 @@ class SettingsActivity : BaseActivity() {
             binding.settingsReadPrivacy.visibility = View.GONE
         }
 
-        if (CapabilitiesUtilNew.isTypingStatusAvailable(currentUser!!)) {
-            (binding.settingsTypingStatus.findViewById<View>(R.id.mp_checkable) as Checkable).isChecked =
-                !CapabilitiesUtilNew.isTypingStatusPrivate(currentUser!!)
-        } else {
-            binding.settingsTypingStatus.visibility = View.GONE
-        }
+        setupTypingStatusSetting()
 
         (binding.settingsPhoneBookIntegration.findViewById<View>(R.id.mp_checkable) as Checkable).isChecked =
             appPreferences.isPhoneBookIntegrationEnabled
     }
 
+    private fun setupTypingStatusSetting() {
+        if (currentUser!!.externalSignalingServer?.externalSignalingServer?.isNotEmpty() == true) {
+            binding.settingsTypingStatusOnlyWithHpb.visibility = View.GONE
+
+            if (CapabilitiesUtilNew.isTypingStatusAvailable(currentUser!!)) {
+                (binding.settingsTypingStatus.findViewById<View>(R.id.mp_checkable) as Checkable).isChecked =
+                    !CapabilitiesUtilNew.isTypingStatusPrivate(currentUser!!)
+            } else {
+                binding.settingsTypingStatus.visibility = View.GONE
+            }
+        } else {
+            (binding.settingsTypingStatus.findViewById<View>(R.id.mp_checkable) as Checkable).isChecked = false
+            binding.settingsTypingStatusOnlyWithHpb.visibility = View.VISIBLE
+            binding.settingsTypingStatus.isEnabled = false
+            binding.settingsTypingStatusOnlyWithHpb.alpha = DISABLED_ALPHA
+            binding.settingsTypingStatus.alpha = DISABLED_ALPHA
+        }
+    }
+
     private fun setupScreenLockSetting() {
         val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
         if (keyguardManager.isKeyguardSecure) {
@@ -846,10 +862,13 @@ class SettingsActivity : BaseActivity() {
                 when (newValue) {
                     "HTTP" ->
                         binding.settingsProxyPortEdit.value = "3128"
+
                     "DIRECT" ->
                         binding.settingsProxyPortEdit.value = "8080"
+
                     "SOCKS" ->
                         binding.settingsProxyPortEdit.value = "1080"
+
                     else -> {
                     }
                 }

+ 4 - 4
app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt

@@ -36,15 +36,15 @@ internal class ConversationMessageNotifier {
     }
 
     @Synchronized
-    fun notifyStartTyping(sessionId: String?) {
+    fun notifyStartTyping(userId: String?, sessionId: String?) {
         for (listener in ArrayList(conversationMessageListeners)) {
-            listener.onStartTyping(sessionId)
+            listener.onStartTyping(userId, sessionId)
         }
     }
 
-    fun notifyStopTyping(sessionId: String?) {
+    fun notifyStopTyping(userId: String?, sessionId: String?) {
         for (listener in ArrayList(conversationMessageListeners)) {
-            listener.onStopTyping(sessionId)
+            listener.onStopTyping(userId, sessionId)
         }
     }
 }

+ 23 - 10
app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java

@@ -24,6 +24,7 @@ import com.nextcloud.talk.models.json.participants.Participant;
 import com.nextcloud.talk.models.json.signaling.NCIceCandidate;
 import com.nextcloud.talk.models.json.signaling.NCMessagePayload;
 import com.nextcloud.talk.models.json.signaling.NCSignalingMessage;
+import com.nextcloud.talk.models.json.websocket.CallWebSocketMessage;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -169,8 +170,8 @@ public abstract class SignalingMessageReceiver {
      * Listener for conversation messages.
      */
     public interface ConversationMessageListener {
-        void onStartTyping(String session);
-        void onStopTyping(String session);
+        void onStartTyping(String userId, String session);
+        void onStopTyping(String userId,String session);
     }
 
     /**
@@ -515,6 +516,26 @@ public abstract class SignalingMessageReceiver {
         return participant;
     }
 
+    protected void processCallWebSocketMessage(CallWebSocketMessage callWebSocketMessage) {
+
+        NCSignalingMessage signalingMessage = callWebSocketMessage.getNcSignalingMessage();
+
+        if (callWebSocketMessage.getSenderWebSocketMessage() != null && signalingMessage != null) {
+            String type = signalingMessage.getType();
+
+            String userId = callWebSocketMessage.getSenderWebSocketMessage().getUserid();
+            String sessionId = signalingMessage.getFrom();
+
+            if ("startedTyping".equals(type)) {
+                conversationMessageNotifier.notifyStartTyping(userId, sessionId);
+            }
+
+            if ("stoppedTyping".equals(type)) {
+                conversationMessageNotifier.notifyStopTyping(userId, sessionId);
+            }
+        }
+    }
+
     protected void processSignalingMessage(NCSignalingMessage signalingMessage) {
         // Note that in the internal signaling server message "data" is the String representation of a JSON
         // object, although it is already decoded when used here.
@@ -581,14 +602,6 @@ public abstract class SignalingMessageReceiver {
             return;
         }
 
-        if ("startedTyping".equals(type)) {
-            conversationMessageNotifier.notifyStartTyping(sessionId);
-        }
-
-        if ("stoppedTyping".equals(type)) {
-            conversationMessageNotifier.notifyStopTyping(sessionId);
-        }
-
         if ("reaction".equals(type)) {
             // Message schema (external signaling server):
             // {

+ 13 - 5
app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt

@@ -35,6 +35,7 @@ import com.nextcloud.talk.models.json.signaling.NCSignalingMessage
 import com.nextcloud.talk.models.json.websocket.BaseWebSocketMessage
 import com.nextcloud.talk.models.json.websocket.ByeWebSocketMessage
 import com.nextcloud.talk.models.json.websocket.CallOverallWebSocketMessage
+import com.nextcloud.talk.models.json.websocket.CallWebSocketMessage
 import com.nextcloud.talk.models.json.websocket.ErrorOverallWebSocketMessage
 import com.nextcloud.talk.models.json.websocket.EventOverallWebSocketMessage
 import com.nextcloud.talk.models.json.websocket.HelloResponseOverallWebSocketMessage
@@ -182,15 +183,16 @@ class WebSocketInstance internal constructor(
     private fun processMessage(text: String) {
         val (_, callWebSocketMessage) = LoganSquare.parse(text, CallOverallWebSocketMessage::class.java)
         if (callWebSocketMessage != null) {
-            val ncSignalingMessage = callWebSocketMessage
-                .ncSignalingMessage
+            val ncSignalingMessage = callWebSocketMessage.ncSignalingMessage
+
             if (ncSignalingMessage != null &&
                 TextUtils.isEmpty(ncSignalingMessage.from) &&
                 callWebSocketMessage.senderWebSocketMessage != null
             ) {
                 ncSignalingMessage.from = callWebSocketMessage.senderWebSocketMessage!!.sessionId
             }
-            signalingMessageReceiver.process(ncSignalingMessage)
+
+            signalingMessageReceiver.process(callWebSocketMessage)
         }
     }
 
@@ -453,8 +455,14 @@ class WebSocketInstance internal constructor(
             processEvent(eventMap)
         }
 
-        fun process(message: NCSignalingMessage?) {
-            processSignalingMessage(message)
+        fun process(message: CallWebSocketMessage?) {
+            if (message?.ncSignalingMessage?.type == "startedTyping" ||
+                message?.ncSignalingMessage?.type == "stoppedTyping"
+            ) {
+                processCallWebSocketMessage(message)
+            } else {
+                processSignalingMessage(message?.ncSignalingMessage)
+            }
         }
     }
 

+ 10 - 0
app/src/main/res/layout/activity_settings.xml

@@ -272,6 +272,16 @@
             apc:mp_key="@string/nc_settings_read_privacy_key"
             apc:mp_summary="@string/nc_settings_typing_status_desc"
             apc:mp_title="@string/nc_settings_typing_status_title" />
+
+        <TextView
+            android:id="@+id/settings_typing_status_only_with_hpb"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="@dimen/standard_margin"
+            android:layout_marginEnd="@dimen/standard_margin"
+            android:textColor="@color/disabled_text"
+            android:text="@string/nc_settings_typing_status_hpb_description">
+        </TextView>
     </com.yarolegovich.mp.MaterialPreferenceCategory>
 
     <com.yarolegovich.mp.MaterialPreferenceCategory

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -153,6 +153,8 @@ How to translate with transifex:
     <string name="nc_settings_read_privacy_title">Read status</string>
     <string name="nc_settings_typing_status_desc">Share my typing-status and show the typing-status of others</string>
     <string name="nc_settings_typing_status_title">Typing status</string>
+    <string name="nc_settings_typing_status_hpb_description">Typing status is only available when using a high
+        performance backend (HPB)</string>
 
     <string name="nc_screen_lock_timeout_30">30 seconds</string>
     <string name="nc_screen_lock_timeout_60">1 minute</string>