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>
This commit is contained in:
Marcel Hibbe 2023-06-01 13:21:11 +02:00
parent 2833cdf2cb
commit 7f51d45e9a
No known key found for this signature in database
GPG Key ID: C793F8B59F43CE7B
8 changed files with 208 additions and 60 deletions

View File

@ -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 (name != null && !typingParticipants.contains(session)) {
if (name == "") {
name = context.resources?.getString(R.string.nc_guest)!!
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)!!
}
typingParticipants[session] = name
updateTypingIndicator()
runOnUiThread {
val typingParticipant = TypingParticipant(userIdOrGuestSession!!, displayName) {
typingParticipants.remove(userIdOrGuestSession)
updateTypingIndicator()
}
typingParticipants[userIdOrGuestSession] = typingParticipant
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"
}

View File

@ -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
}
}

View File

@ -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 -> {
}
}

View File

@ -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)
}
}
}

View File

@ -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):
// {

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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>