浏览代码

Merge branch 'master' into translate-message-mvvm-impl

Signed-off-by: Julius Linus <69230048+rapterjet2004@users.noreply.github.com>
Julius Linus 2 年之前
父节点
当前提交
528f1fd431

+ 5 - 0
app/src/main/java/com/nextcloud/talk/api/NcApi.java

@@ -453,6 +453,11 @@ public interface NcApi {
                                                     @Url String url,
                                                     @Body RequestBody body);
 
+    @POST
+    Observable<GenericOverall> setTypingStatusPrivacy(@Header("Authorization") String authorization,
+                                                    @Url String url,
+                                                    @Body RequestBody body);
+
     @POST
     Observable<ContactsByNumberOverall> searchContactsByPhoneNumber(@Header("Authorization") String authorization,
                                                                     @Url String url,

+ 180 - 4
app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt

@@ -44,6 +44,7 @@ import android.media.MediaRecorder
 import android.net.Uri
 import android.os.Build
 import android.os.Bundle
+import android.os.CountDownTimer
 import android.os.Handler
 import android.os.Parcelable
 import android.os.SystemClock
@@ -51,6 +52,7 @@ import android.provider.ContactsContract
 import android.provider.MediaStore
 import android.text.Editable
 import android.text.InputFilter
+import android.text.SpannableStringBuilder
 import android.text.TextUtils
 import android.text.TextWatcher
 import android.util.Log
@@ -60,6 +62,7 @@ import android.view.Menu
 import android.view.MenuItem
 import android.view.MotionEvent
 import android.view.View
+import android.view.animation.AccelerateDecelerateInterpolator
 import android.view.animation.AlphaAnimation
 import android.view.animation.Animation
 import android.view.animation.LinearInterpolator
@@ -75,6 +78,7 @@ import androidx.core.content.ContextCompat
 import androidx.core.content.FileProvider
 import androidx.core.content.PermissionChecker
 import androidx.core.graphics.drawable.toBitmap
+import androidx.core.text.bold
 import androidx.core.widget.doAfterTextChanged
 import androidx.emoji2.text.EmojiCompat
 import androidx.emoji2.widget.EmojiTextView
@@ -124,7 +128,7 @@ import com.nextcloud.talk.callbacks.MentionAutocompleteCallback
 import com.nextcloud.talk.conversationinfo.ConversationInfoActivity
 import com.nextcloud.talk.conversationlist.ConversationsListActivity
 import com.nextcloud.talk.data.user.model.User
-import com.nextcloud.talk.databinding.ControllerChatBinding
+import com.nextcloud.talk.databinding.ActivityChatBinding
 import com.nextcloud.talk.events.UserMentionClickEvent
 import com.nextcloud.talk.events.WebSocketCommunicationEvent
 import com.nextcloud.talk.extensions.loadAvatarOrImagePreview
@@ -144,6 +148,7 @@ import com.nextcloud.talk.models.json.conversations.RoomOverall
 import com.nextcloud.talk.models.json.conversations.RoomsOverall
 import com.nextcloud.talk.models.json.generic.GenericOverall
 import com.nextcloud.talk.models.json.mention.Mention
+import com.nextcloud.talk.models.json.signaling.NCSignalingMessage
 import com.nextcloud.talk.polls.ui.PollCreateDialogFragment
 import com.nextcloud.talk.presenters.MentionAutocompletePresenter
 import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity
@@ -151,6 +156,7 @@ 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.ui.bottom.sheet.ProfileBottomSheet
 import com.nextcloud.talk.ui.dialog.AttachmentDialog
 import com.nextcloud.talk.ui.dialog.MessageActionsDialog
@@ -231,7 +237,7 @@ class ChatActivity :
 
     var active = false
 
-    private lateinit var binding: ControllerChatBinding
+    private lateinit var binding: ActivityChatBinding
 
     @Inject
     lateinit var ncApi: NcApi
@@ -278,7 +284,8 @@ class ChatActivity :
     private var conversationVideoMenuItem: MenuItem? = null
     private var conversationSharedItemsItem: MenuItem? = null
 
-    var webSocketInstance: WebSocketInstance? = null
+    private var webSocketInstance: WebSocketInstance? = null
+    private var signalingMessageSender: SignalingMessageSender? = null
 
     var getRoomInfoTimerHandler: Handler? = null
     var pastPreconditionFailed = false
@@ -299,6 +306,9 @@ class ChatActivity :
 
     private var videoURI: Uri? = null
 
+    var typingTimer: CountDownTimer? = null
+    val typingParticipants = HashMap<String, String>()
+
     private val localParticipantMessageListener = object : SignalingMessageReceiver.LocalParticipantMessageListener {
         override fun onSwitchTo(token: String?) {
             if (token != null) {
@@ -311,11 +321,34 @@ class ChatActivity :
         }
     }
 
+    private val conversationMessageListener = object : SignalingMessageReceiver.ConversationMessageListener {
+        override fun onStartTyping(session: String) {
+            if (!CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)) {
+                var name = webSocketInstance?.getDisplayNameForSession(session)
+
+                if (name != null && !typingParticipants.contains(session)) {
+                    if (name == "") {
+                        name = context.resources?.getString(R.string.nc_guest)!!
+                    }
+                    typingParticipants[session] = name
+                    updateTypingIndicator()
+                }
+            }
+        }
+
+        override fun onStopTyping(session: String) {
+            if (!CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)) {
+                typingParticipants.remove(session)
+                updateTypingIndicator()
+            }
+        }
+    }
+
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
 
-        binding = ControllerChatBinding.inflate(layoutInflater)
+        binding = ActivityChatBinding.inflate(layoutInflater)
         setupActionBar()
         setContentView(binding.root)
         setupSystemColors()
@@ -398,6 +431,7 @@ class ChatActivity :
 
         setupWebsocket()
         webSocketInstance?.getSignalingMessageReceiver()?.addListener(localParticipantMessageListener)
+        webSocketInstance?.getSignalingMessageReceiver()?.addListener(conversationMessageListener)
 
         if (conversationUser?.userId != "?" &&
             CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "mention-flag")
@@ -496,6 +530,8 @@ class ChatActivity :
 
             @Suppress("Detekt.TooGenericExceptionCaught")
             override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
+                sendStartTypingMessage()
+
                 if (s.length >= lengthFilter) {
                     binding?.messageInputView?.inputEditText?.error = String.format(
                         Objects.requireNonNull<Resources>(resources).getString(R.string.nc_limit_hit),
@@ -872,6 +908,134 @@ class ChatActivity :
         }
     }
 
+    @Suppress("MagicNumber")
+    private fun updateTypingIndicator() {
+        fun ellipsize(text: String): String {
+            return DisplayUtils.ellipsize(text, TYPING_INDICATOR_MAX_NAME_LENGTH)
+        }
+
+        val participantNames = ArrayList(typingParticipants.values)
+
+        val typingString: SpannableStringBuilder
+        when (typingParticipants.size) {
+            0 -> typingString = SpannableStringBuilder().append(binding.typingIndicator.text)
+
+            // person1 is typing
+            1 -> typingString = SpannableStringBuilder()
+                .bold { append(ellipsize(participantNames[0])) }
+                .append(WHITESPACE + context.resources?.getString(R.string.typing_is_typing))
+
+            // person1 and person2 are typing
+            2 -> typingString = SpannableStringBuilder()
+                .bold { append(ellipsize(participantNames[0])) }
+                .append(WHITESPACE + context.resources?.getString(R.string.nc_common_and) + WHITESPACE)
+                .bold { append(ellipsize(participantNames[1])) }
+                .append(WHITESPACE + context.resources?.getString(R.string.typing_are_typing))
+
+            // person1, person2 and person3 are typing
+            3 -> typingString = SpannableStringBuilder()
+                .bold { append(ellipsize(participantNames[0])) }
+                .append(COMMA)
+                .bold { append(ellipsize(participantNames[1])) }
+                .append(WHITESPACE + context.resources?.getString(R.string.nc_common_and) + WHITESPACE)
+                .bold { append(ellipsize(participantNames[2])) }
+                .append(WHITESPACE + context.resources?.getString(R.string.typing_are_typing))
+
+            // person1, person2, person3 and 1 other is typing
+            4 -> typingString = SpannableStringBuilder()
+                .bold { append(participantNames[0]) }
+                .append(COMMA)
+                .bold { append(participantNames[1]) }
+                .append(COMMA)
+                .bold { append(participantNames[2]) }
+                .append(WHITESPACE + context.resources?.getString(R.string.typing_1_other))
+
+            // person1, person2, person3 and x others are typing
+            else -> {
+                val moreTypersAmount = typingParticipants.size - 3
+                val othersTyping = context.resources?.getString(R.string.typing_x_others)?.let {
+                    String.format(it, moreTypersAmount)
+                }
+                typingString = SpannableStringBuilder()
+                    .bold { append(participantNames[0]) }
+                    .append(COMMA)
+                    .bold { append(participantNames[1]) }
+                    .append(COMMA)
+                    .bold { append(participantNames[2]) }
+                    .append(othersTyping)
+            }
+        }
+
+        runOnUiThread {
+            binding.typingIndicator.text = typingString
+
+            if (participantNames.size > 0) {
+                binding.typingIndicatorWrapper.animate()
+                    .translationY(binding.messageInputView.y - DisplayUtils.convertDpToPixel(18f, context))
+                    .setInterpolator(AccelerateDecelerateInterpolator())
+                    .duration = TYPING_INDICATOR_ANIMATION_DURATION
+            } else {
+                if (binding.typingIndicator.lineCount == 1) {
+                    binding.typingIndicatorWrapper.animate()
+                        .translationY(binding.messageInputView.y)
+                        .setInterpolator(AccelerateDecelerateInterpolator())
+                        .duration = TYPING_INDICATOR_ANIMATION_DURATION
+                } else if (binding.typingIndicator.lineCount == 2) {
+                    binding.typingIndicatorWrapper.animate()
+                        .translationY(binding.messageInputView.y + DisplayUtils.convertDpToPixel(15f, context))
+                        .setInterpolator(AccelerateDecelerateInterpolator())
+                        .duration = TYPING_INDICATOR_ANIMATION_DURATION
+                }
+            }
+        }
+    }
+
+    fun sendStartTypingMessage() {
+        if (webSocketInstance == null) {
+            return
+        }
+
+        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)
+                }
+
+                typingTimer = object : CountDownTimer(
+                    TYPING_DURATION_BEFORE_SENDING_STOP,
+                    TYPING_DURATION_BEFORE_SENDING_STOP
+                ) {
+                    override fun onTick(millisUntilFinished: Long) {
+                        // unused atm
+                    }
+
+                    override fun onFinish() {
+                        sendStopTypingMessage()
+                    }
+                }.start()
+            } else {
+                typingTimer?.cancel()
+                typingTimer?.start()
+            }
+        }
+    }
+
+    fun sendStopTypingMessage() {
+        if (!CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)) {
+            typingTimer = null
+
+            for ((sessionId, participant) in webSocketInstance?.getUserMap()!!) {
+                val ncSignalingMessage = NCSignalingMessage()
+                ncSignalingMessage.to = sessionId
+                ncSignalingMessage.type = TYPING_STOPPED_SIGNALING_MESSAGE_TYPE
+                signalingMessageSender!!.send(ncSignalingMessage)
+            }
+        }
+    }
+
     private fun getRoomInfo() {
         logConversationInfos("getRoomInfo")
 
@@ -1980,6 +2144,7 @@ class ChatActivity :
         eventBus.unregister(this)
 
         webSocketInstance?.getSignalingMessageReceiver()?.removeListener(localParticipantMessageListener)
+        webSocketInstance?.getSignalingMessageReceiver()?.removeListener(conversationMessageListener)
 
         findViewById<View>(R.id.toolbar)?.setOnClickListener(null)
 
@@ -2228,6 +2393,7 @@ class ChatActivity :
             }
 
             binding?.messageInputView?.inputEditText?.setText("")
+            sendStopTypingMessage()
             val replyMessageId: Int? = findViewById<RelativeLayout>(R.id.quotedChatMessageView)?.tag as Int?
             sendMessage(
                 editable,
@@ -2303,6 +2469,8 @@ class ChatActivity :
         if (webSocketInstance == null) {
             Log.d(TAG, "webSocketInstance not set up. This should only happen when not using the HPB")
         }
+
+        signalingMessageSender = webSocketInstance?.signalingMessageSender
     }
 
     fun pullChatMessages(
@@ -3627,5 +3795,13 @@ class ChatActivity :
         private const val LOOKING_INTO_FUTURE_TIMEOUT = 30
         private const val CHUNK_SIZE: Int = 10
         private const val ONE_SECOND_IN_MILLIS = 1000
+
+        private const val WHITESPACE = " "
+        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_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping"
+        private const val TYPING_STOPPED_SIGNALING_MESSAGE_TYPE = "stoppedTyping"
     }
 }

+ 66 - 1
app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt

@@ -56,6 +56,7 @@ import androidx.appcompat.app.AlertDialog
 import androidx.core.content.ContextCompat
 import androidx.core.view.ViewCompat
 import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkInfo
 import androidx.work.WorkManager
 import autodagger.AutoInjector
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -69,6 +70,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.setAppT
 import com.nextcloud.talk.data.user.model.User
 import com.nextcloud.talk.databinding.ActivitySettingsBinding
 import com.nextcloud.talk.jobs.AccountRemovalWorker
+import com.nextcloud.talk.jobs.CapabilitiesWorker
 import com.nextcloud.talk.jobs.ContactAddressBookWorker
 import com.nextcloud.talk.jobs.ContactAddressBookWorker.Companion.checkPermission
 import com.nextcloud.talk.jobs.ContactAddressBookWorker.Companion.deleteAll
@@ -122,6 +124,7 @@ class SettingsActivity : BaseActivity() {
     private var screenLockTimeoutChangeListener: OnPreferenceValueChangedListener<String?>? = null
     private var themeChangeListener: OnPreferenceValueChangedListener<String?>? = null
     private var readPrivacyChangeListener: OnPreferenceValueChangedListener<Boolean>? = null
+    private var typingStatusChangeListener: OnPreferenceValueChangedListener<Boolean>? = null
     private var phoneBookIntegrationChangeListener: OnPreferenceValueChangedListener<Boolean>? = null
     private var profileQueryDisposable: Disposable? = null
     private var dbQueryDisposable: Disposable? = null
@@ -172,6 +175,8 @@ class SettingsActivity : BaseActivity() {
         supportActionBar?.show()
         dispose(null)
 
+        loadCapabilitiesAndUpdateSettings()
+
         binding.settingsVersion.setOnClickListener {
             sendLogs()
         }
@@ -224,6 +229,19 @@ class SettingsActivity : BaseActivity() {
         themeSwitchPreferences()
     }
 
+    private fun loadCapabilitiesAndUpdateSettings() {
+        val capabilitiesWork = OneTimeWorkRequest.Builder(CapabilitiesWorker::class.java).build()
+        WorkManager.getInstance(context).enqueue(capabilitiesWork)
+
+        WorkManager.getInstance(context).getWorkInfoByIdLiveData(capabilitiesWork.id)
+            .observe(this) { workInfo ->
+                if (workInfo?.state == WorkInfo.State.SUCCEEDED) {
+                    getCurrentUser()
+                    setupCheckables()
+                }
+            }
+    }
+
     private fun setupActionBar() {
         setSupportActionBar(binding.settingsToolbar)
         binding.settingsToolbar.setNavigationOnClickListener {
@@ -402,6 +420,11 @@ class SettingsActivity : BaseActivity() {
                 readPrivacyChangeListener = it
             }
         )
+        appPreferences.registerTypingStatusChangeListener(
+            TypingStatusChangeListener().also {
+                typingStatusChangeListener = it
+            }
+        )
     }
 
     fun sendLogs() {
@@ -470,6 +493,7 @@ class SettingsActivity : BaseActivity() {
                 settingsIncognitoKeyboard,
                 settingsPhoneBookIntegration,
                 settingsReadPrivacy,
+                settingsTypingStatus,
                 settingsProxyUseCredentials
             ).forEach(viewThemeUtils.talk::colorSwitchPreference)
         }
@@ -636,13 +660,20 @@ class SettingsActivity : BaseActivity() {
         (binding.settingsIncognitoKeyboard.findViewById<View>(R.id.mp_checkable) as Checkable).isChecked =
             appPreferences.isKeyboardIncognito
 
-        if (CapabilitiesUtilNew.isReadStatusAvailable(userManager.currentUser.blockingGet())) {
+        if (CapabilitiesUtilNew.isReadStatusAvailable(currentUser!!)) {
             (binding.settingsReadPrivacy.findViewById<View>(R.id.mp_checkable) as Checkable).isChecked =
                 !CapabilitiesUtilNew.isReadStatusPrivate(currentUser!!)
         } else {
             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
+        }
+
         (binding.settingsPhoneBookIntegration.findViewById<View>(R.id.mp_checkable) as Checkable).isChecked =
             appPreferences.isPhoneBookIntegrationEnabled
     }
@@ -680,6 +711,7 @@ class SettingsActivity : BaseActivity() {
         appPreferences.unregisterScreenLockTimeoutListener(screenLockTimeoutChangeListener)
         appPreferences.unregisterThemeChangeListener(themeChangeListener)
         appPreferences.unregisterReadPrivacyChangeListener(readPrivacyChangeListener)
+        appPreferences.unregisterTypingStatusChangeListener(typingStatusChangeListener)
         appPreferences.unregisterPhoneBookIntegrationChangeListener(phoneBookIntegrationChangeListener)
 
         super.onDestroy()
@@ -1009,6 +1041,39 @@ class SettingsActivity : BaseActivity() {
         }
     }
 
+    private inner class TypingStatusChangeListener : OnPreferenceValueChangedListener<Boolean> {
+        override fun onChanged(newValue: Boolean) {
+            val booleanValue = if (newValue) "0" else "1"
+            val json = "{\"key\": \"typing_privacy\", \"value\" : $booleanValue}"
+            ncApi.setTypingStatusPrivacy(
+                ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token),
+                ApiUtils.getUrlForUserSettings(currentUser!!.baseUrl),
+                RequestBody.create("application/json".toMediaTypeOrNull(), json)
+            )
+                .subscribeOn(Schedulers.io())
+                .observeOn(AndroidSchedulers.mainThread())
+                .subscribe(object : Observer<GenericOverall> {
+                    override fun onSubscribe(d: Disposable) {
+                        // unused atm
+                    }
+
+                    override fun onNext(genericOverall: GenericOverall) {
+                        // unused atm
+                    }
+
+                    override fun onError(e: Throwable) {
+                        appPreferences.setTypingStatus(!newValue)
+                        (binding.settingsTypingStatus.findViewById<View>(R.id.mp_checkable) as Checkable).isChecked =
+                            !newValue
+                    }
+
+                    override fun onComplete() {
+                        // unused atm
+                    }
+                })
+        }
+    }
+
     companion object {
         private const val TAG = "SettingsController"
         private const val DURATION: Long = 2500

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

@@ -0,0 +1,50 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * Copyright (C) 2023 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.signaling
+
+import com.nextcloud.talk.signaling.SignalingMessageReceiver.ConversationMessageListener
+
+internal class ConversationMessageNotifier {
+    private val conversationMessageListeners: MutableSet<ConversationMessageListener> = LinkedHashSet()
+
+    @Synchronized
+    fun addListener(listener: ConversationMessageListener?) {
+        requireNotNull(listener) { "conversationMessageListener can not be null" }
+        conversationMessageListeners.add(listener)
+    }
+
+    @Synchronized
+    fun removeListener(listener: ConversationMessageListener) {
+        conversationMessageListeners.remove(listener)
+    }
+
+    @Synchronized
+    fun notifyStartTyping(sessionId: String?) {
+        for (listener in ArrayList(conversationMessageListeners)) {
+            listener.onStartTyping(sessionId)
+        }
+    }
+
+    fun notifyStopTyping(sessionId: String?) {
+        for (listener in ArrayList(conversationMessageListeners)) {
+            listener.onStopTyping(sessionId)
+        }
+    }
+}

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

@@ -50,6 +50,18 @@ import java.util.Map;
  */
 public abstract class SignalingMessageReceiver {
 
+    private final ParticipantListMessageNotifier participantListMessageNotifier = new ParticipantListMessageNotifier();
+
+    private final LocalParticipantMessageNotifier localParticipantMessageNotifier = new LocalParticipantMessageNotifier();
+
+    private final CallParticipantMessageNotifier callParticipantMessageNotifier = new CallParticipantMessageNotifier();
+
+    private final ConversationMessageNotifier conversationMessageNotifier = new ConversationMessageNotifier();
+
+    private final OfferMessageNotifier offerMessageNotifier = new OfferMessageNotifier();
+
+    private final WebRtcMessageNotifier webRtcMessageNotifier = new WebRtcMessageNotifier();
+
     /**
      * Listener for participant list messages.
      *
@@ -153,6 +165,14 @@ public abstract class SignalingMessageReceiver {
         void onUnshareScreen();
     }
 
+    /**
+     * Listener for conversation messages.
+     */
+    public interface ConversationMessageListener {
+        void onStartTyping(String session);
+        void onStopTyping(String session);
+    }
+
     /**
      * Listener for WebRTC offers.
      *
@@ -179,16 +199,6 @@ public abstract class SignalingMessageReceiver {
         void onEndOfCandidates();
     }
 
-    private final ParticipantListMessageNotifier participantListMessageNotifier = new ParticipantListMessageNotifier();
-
-    private final LocalParticipantMessageNotifier localParticipantMessageNotifier = new LocalParticipantMessageNotifier();
-
-    private final CallParticipantMessageNotifier callParticipantMessageNotifier = new CallParticipantMessageNotifier();
-
-    private final OfferMessageNotifier offerMessageNotifier = new OfferMessageNotifier();
-
-    private final WebRtcMessageNotifier webRtcMessageNotifier = new WebRtcMessageNotifier();
-
     /**
      * Adds a listener for participant list messages.
      *
@@ -236,6 +246,14 @@ public abstract class SignalingMessageReceiver {
         callParticipantMessageNotifier.removeListener(listener);
     }
 
+    public void addListener(ConversationMessageListener listener) {
+        conversationMessageNotifier.addListener(listener);
+    }
+
+    public void removeListener(ConversationMessageListener listener) {
+        conversationMessageNotifier.removeListener(listener);
+    }
+
     /**
      * Adds a listener for all offer messages.
      *
@@ -563,6 +581,14 @@ 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):
             // {

+ 7 - 0
app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java

@@ -553,4 +553,11 @@ public class DisplayUtils {
         DateFormat df = DateFormat.getDateTimeInstance();
         return df.format(date);
     }
+
+    public static String ellipsize(String text, int maxLength) {
+        if (text.length() > maxLength) {
+            return text.substring(0, maxLength - 1) + "…";
+        }
+        return text;
+    }
 }

+ 17 - 0
app/src/main/java/com/nextcloud/talk/utils/database/user/CapabilitiesUtilNew.kt

@@ -98,7 +98,24 @@ object CapabilitiesUtilNew {
                 return (map["read-privacy"]!!.toString()).toInt() == 1
             }
         }
+        return false
+    }
+
+    fun isTypingStatusAvailable(user: User): Boolean {
+        if (user.capabilities?.spreedCapability?.config?.containsKey("chat") == true) {
+            val map = user.capabilities!!.spreedCapability!!.config!!["chat"]
+            return map != null && map.containsKey("typing-privacy")
+        }
+        return false
+    }
 
+    fun isTypingStatusPrivate(user: User): Boolean {
+        if (user.capabilities?.spreedCapability?.config?.containsKey("chat") == true) {
+            val map = user.capabilities!!.spreedCapability!!.config!!["chat"]
+            if (map?.containsKey("typing-privacy") == true) {
+                return (map["typing-privacy"]!!.toString()).toInt() == 1
+            }
+        }
         return false
     }
 

+ 12 - 0
app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java

@@ -37,6 +37,7 @@ import net.orange_box.storebox.annotations.option.SaveOption;
 import net.orange_box.storebox.enums.SaveMode;
 import net.orange_box.storebox.listeners.OnPreferenceValueChangedListener;
 
+
 @SaveOption(SaveMode.APPLY)
 public interface AppPreferences {
 
@@ -312,6 +313,9 @@ public interface AppPreferences {
 
     @KeyByResource(R.string.nc_settings_read_privacy_key)
     void setReadPrivacy(boolean value);
+
+    @KeyByString("typing_status")
+    void setTypingStatus(boolean value);
     
     @KeyByResource(R.string.nc_settings_read_privacy_key)
     @RegisterChangeListenerMethod
@@ -321,6 +325,14 @@ public interface AppPreferences {
     @UnregisterChangeListenerMethod
     void unregisterReadPrivacyChangeListener(OnPreferenceValueChangedListener<Boolean> listener);
 
+    @KeyByString("typing_status")
+    @RegisterChangeListenerMethod
+    void registerTypingStatusChangeListener(OnPreferenceValueChangedListener<Boolean> listener);
+
+    @KeyByString("typing_status")
+    @UnregisterChangeListenerMethod
+    void unregisterTypingStatusChangeListener(OnPreferenceValueChangedListener<Boolean> listener);
+
     @KeyByResource(R.string.nc_file_browser_sort_by_key)
     void setSorting(String value);
 

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

@@ -206,6 +206,8 @@ class WebSocketInstance internal constructor(
                             processRoomMessageMessage(eventOverallWebSocketMessage)
                         } else if ("join" == eventOverallWebSocketMessage.eventMap!!["type"]) {
                             processRoomJoinMessage(eventOverallWebSocketMessage)
+                        } else if ("leave" == eventOverallWebSocketMessage.eventMap!!["type"]) {
+                            processRoomLeaveMessage(eventOverallWebSocketMessage)
                         }
                         signalingMessageReceiver.process(eventOverallWebSocketMessage.eventMap)
                     }
@@ -271,6 +273,17 @@ class WebSocketInstance internal constructor(
         }
     }
 
+    private fun processRoomLeaveMessage(eventOverallWebSocketMessage: EventOverallWebSocketMessage) {
+        val leaveEventList = eventOverallWebSocketMessage.eventMap?.get("leave") as List<String>?
+        for (i in leaveEventList!!.indices) {
+            usersHashMap.remove(leaveEventList[i])
+        }
+    }
+
+    fun getUserMap(): HashMap<String?, Participant> {
+        return usersHashMap
+    }
+
     @Throws(IOException::class)
     private fun processJoinedRoomMessage(text: String) {
         val (_, roomWebSocketMessage) = LoganSquare.parse(text, JoinedRoomOverallWebSocketMessage::class.java)

+ 38 - 11
app/src/main/res/layout/controller_chat.xml → app/src/main/res/layout/activity_chat.xml

@@ -30,7 +30,8 @@
     android:layout_height="match_parent"
     android:animateLayoutChanges="true"
     android:background="@color/bg_default"
-    android:orientation="vertical">
+    android:orientation="vertical"
+    tools:ignore="Overdraw">
 
     <com.google.android.material.appbar.AppBarLayout
         android:id="@+id/chat_appbar"
@@ -80,7 +81,8 @@
             android:id="@+id/messagesListView"
             android:layout_width="match_parent"
             android:layout_height="match_parent"
-            android:paddingBottom="0dp"
+            android:paddingBottom="20dp"
+            android:clipToPadding="false"
             android:visibility="gone"
             app:dateHeaderTextSize="13sp"
             app:incomingBubblePaddingBottom="@dimen/message_bubble_corners_vertical_padding"
@@ -108,19 +110,20 @@
             app:outcomingTextLinkColor="@color/high_emphasis_text"
             app:outcomingTextSize="@dimen/chat_text_size"
             app:outcomingTimeTextSize="12sp"
-            app:textAutoLink="all" />
+            app:textAutoLink="all"
+            tools:visibility="visible"/>
 
         <com.nextcloud.ui.popupbubble.PopupBubble
             android:id="@+id/popupBubbleView"
             android:theme="@style/Button.Primary"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-            android:layout_alignParentBottom="true"
+            android:layout_alignBottom="@id/typing_indicator_wrapper"
             android:layout_centerHorizontal="true"
             android:layout_marginStart="64dp"
             android:layout_marginTop="16dp"
             android:layout_marginEnd="64dp"
-            android:layout_marginBottom="16dp"
+            android:layout_marginBottom="26dp"
             android:minHeight="@dimen/min_size_clickable_area"
             android:layout_toStartOf="@+id/scrollDownButton"
             android:text="@string/nc_new_messages"
@@ -148,6 +151,36 @@
             app:iconPadding="0dp"
             app:iconSize="24dp" />
 
+        <LinearLayout
+            android:id="@+id/typing_indicator_wrapper"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical"
+            android:layout_alignParentBottom="true"
+            android:layout_marginBottom="-19dp">
+
+            <View
+                android:id="@+id/separator_1"
+                android:layout_width="match_parent"
+                android:layout_height="1dp"
+                android:background="@color/controller_chat_separator" />
+
+            <TextView
+                android:id="@+id/typing_indicator"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:maxLines="2"
+                android:ellipsize="end"
+                android:layout_marginStart="@dimen/side_margin"
+                android:layout_marginEnd="@dimen/side_margin"
+                android:background="@color/bg_default"
+                android:textColor="@color/low_emphasis_text"
+                tools:text="Marcel is typing"
+                tools:ignore="Overdraw">
+            </TextView>
+
+        </LinearLayout>
+
     </RelativeLayout>
 
     <LinearLayout
@@ -155,12 +188,6 @@
         android:layout_height="wrap_content"
         android:orientation="vertical">
 
-        <View
-            android:id="@+id/separator_1"
-            android:layout_width="match_parent"
-            android:layout_height="1dp"
-            android:background="@color/controller_chat_separator" />
-
         <com.nextcloud.talk.ui.MessageInput
             android:id="@+id/messageInputView"
             android:layout_width="match_parent"

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

@@ -264,6 +264,14 @@
             apc:mp_key="@string/nc_settings_read_privacy_key"
             apc:mp_summary="@string/nc_settings_read_privacy_desc"
             apc:mp_title="@string/nc_settings_read_privacy_title" />
+
+        <com.yarolegovich.mp.MaterialSwitchPreference
+            android:id="@+id/settings_typing_status"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            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" />
     </com.yarolegovich.mp.MaterialPreferenceCategory>
 
     <com.yarolegovich.mp.MaterialPreferenceCategory

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

@@ -463,6 +463,7 @@
     <string name="title_attachments">Pièces jointes</string>
     <string name="today">Aujourd\'hui</string>
     <string name="translate">Traduire</string>
+    <string name="translation_copy_translated_text">Copier le texte traduit</string>
     <string name="translation_detect_language">Détecter la langue</string>
     <string name="translation_device_settings">Paramètres de l\'appareil</string>
     <string name="translation_error_message">Impossible de détecter la langue</string>

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

@@ -444,6 +444,7 @@
     <string name="today">Danes</string>
     <string name="translate">Prevodi</string>
     <string name="translation_device_settings">Nastavitve naprave</string>
+    <string name="translation_error_message">Ni mogoče zaznati jezika.</string>
     <string name="translation_from">Od</string>
     <string name="translation_to">Za</string>
     <string name="upload_new_avatar_from_device">Posodobi podobo z naprave</string>

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

@@ -44,6 +44,7 @@ How to translate with transifex:
     <!-- Common -->
     <string name="nc_yes">Yes</string>
     <string name="nc_no">No</string>
+    <string name="nc_common_and">and</string>
     <string name="nc_common_skip">Skip</string>
     <string name="nc_common_set">Set</string>
     <string name="nc_common_dismiss">Dismiss</string>
@@ -150,6 +151,8 @@ How to translate with transifex:
     <string name="nc_locked">Locked</string>
     <string name="nc_settings_read_privacy_desc">Share my read-status and show the read-status of others</string>
     <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_screen_lock_timeout_30">30 seconds</string>
     <string name="nc_screen_lock_timeout_60">1 minute</string>
@@ -447,6 +450,10 @@ How to translate with transifex:
     <string name="open_in_files_app">Open in Files app</string>
     <string name="send_to_forbidden">You are not allowed to share content to this chat</string>
 
+    <string name="typing_is_typing">is typing …</string>
+    <string name="typing_are_typing">are typing …</string>
+    <string name="typing_1_other">and 1 other is typing …</string>
+    <string name="typing_x_others">and %1$s others are typing …</string>
 
     <!-- Upload -->
     <string name="nc_add_file">Add to conversation</string>