diff --git a/.idea/shelf/Uncommitted_changes_before_rebase__Default_Changelist_.xml b/.idea/shelf/Uncommitted_changes_before_rebase__Default_Changelist_.xml
new file mode 100644
index 0000000000..341c087858
--- /dev/null
+++ b/.idea/shelf/Uncommitted_changes_before_rebase__Default_Changelist_.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/changelog.d/3296.bugfix b/changelog.d/3296.bugfix
new file mode 100644
index 0000000000..e5f8799f21
--- /dev/null
+++ b/changelog.d/3296.bugfix
@@ -0,0 +1 @@
+Typing notifications moved from the header to the bottom of the timeline.
\ No newline at end of file
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/sender/SenderInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/sender/SenderInfo.kt
index 9b73136fc3..f739fe9e1b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/sender/SenderInfo.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/sender/SenderInfo.kt
@@ -16,6 +16,7 @@
package org.matrix.android.sdk.api.session.room.sender
+import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.internal.util.replaceSpaceChars
data class SenderInfo(
@@ -35,3 +36,5 @@ data class SenderInfo(
else -> "$displayName ($userId)"
}
}
+
+fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, displayName, avatarUrl)
diff --git a/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageAvatar.kt b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageAvatar.kt
new file mode 100644
index 0000000000..2682a97a2c
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageAvatar.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.core.ui.views
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import im.vector.app.core.utils.DimensionConverter
+import im.vector.app.features.home.AvatarRenderer
+import org.matrix.android.sdk.api.session.room.sender.SenderInfo
+import org.matrix.android.sdk.api.util.toMatrixItem
+
+class TypingMessageAvatar @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : LinearLayout(context, attrs, defStyleAttr) {
+
+ companion object {
+ const val AVATAR_SIZE_DP = 24
+ const val OVERLAP_FACT0R = -3 // =~ 30% to left
+ }
+
+ fun render(typingUsers: List, avatarRender: AvatarRenderer) {
+ removeAllViews()
+ for ((index, value) in typingUsers.withIndex()) {
+ val avatar = ImageView(context)
+ avatar.id = View.generateViewId()
+ val layoutParams = MarginLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+ if (index != 0) layoutParams.marginStart = DimensionConverter(resources).dpToPx(AVATAR_SIZE_DP / OVERLAP_FACT0R)
+ layoutParams.width = DimensionConverter(resources).dpToPx(AVATAR_SIZE_DP)
+ layoutParams.height = DimensionConverter(resources).dpToPx(AVATAR_SIZE_DP)
+ avatar.layoutParams = layoutParams
+ avatarRender.render(value.toMatrixItem(), avatar)
+ addView(avatar)
+ }
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ removeAllViews()
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageDotsView.kt b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageDotsView.kt
new file mode 100644
index 0000000000..6628234704
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageDotsView.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.core.ui.views
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.util.AttributeSet
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import androidx.annotation.DrawableRes
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.view.setMargins
+import im.vector.app.R
+
+class TypingMessageDotsView(context: Context, attrs: AttributeSet) :
+ LinearLayout(context, attrs) {
+
+ companion object {
+ const val DEFAULT_CIRCLE_DURATION = 1000L
+ const val DEFAULT_START_ANIM_CIRCLE_DURATION = 300L
+ const val DEFAULT_MAX_ALPHA = 1f
+ const val DEFAULT_MIN_ALPHA = .5f
+ const val DEFAULT_DOTS_MARGIN = 5
+ const val DEFAULT_DOTS_COUNT = 3
+ }
+
+ private val circles = mutableListOf()
+
+ init {
+ orientation = HORIZONTAL
+ gravity = Gravity.CENTER
+ setCircles()
+ }
+
+ private fun setCircles() {
+ circles.clear()
+ removeAllViews()
+ for (i in 0 until DEFAULT_DOTS_COUNT) {
+ val view = obtainCircle(R.drawable.ic_typing_dot)
+ addView(view)
+ circles.add(view)
+ }
+ }
+
+ private fun obtainCircle(@DrawableRes imageCircle: Int): View {
+ val image = AppCompatImageView(context)
+ image.id = View.generateViewId()
+ val params = MarginLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+ params.setMargins(DEFAULT_DOTS_MARGIN)
+ image.layoutParams = params
+ image.setImageResource(imageCircle)
+ image.adjustViewBounds = false
+ return image
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ circles.forEachIndexed { index, circle -> animateCircle(index, circle) }
+ }
+
+ private fun animateCircle(index: Int, circle: View) {
+ val animator = ValueAnimator.ofFloat(DEFAULT_MAX_ALPHA, DEFAULT_MIN_ALPHA)
+ animator.duration = DEFAULT_CIRCLE_DURATION
+ animator.startDelay = DEFAULT_START_ANIM_CIRCLE_DURATION * index
+ animator.repeatCount = ValueAnimator.INFINITE
+ animator.addUpdateListener {
+ circle.alpha = it.animatedValue as Float
+ }
+ animator.start()
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ circles.forEach { it.clearAnimation() }
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt
new file mode 100644
index 0000000000..11248bde74
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.core.ui.views
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.constraintlayout.widget.ConstraintLayout
+import dagger.hilt.android.AndroidEntryPoint
+import im.vector.app.R
+import im.vector.app.databinding.TypingMessageLayoutBinding
+import im.vector.app.features.home.AvatarRenderer
+import im.vector.app.features.home.room.typing.TypingHelper
+import org.matrix.android.sdk.api.session.room.sender.SenderInfo
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class TypingMessageView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) {
+
+ val views: TypingMessageLayoutBinding
+
+ @Inject
+ lateinit var typingHelper: TypingHelper
+
+ init {
+ inflate(context, R.layout.typing_message_layout, this)
+ views = TypingMessageLayoutBinding.bind(this)
+ }
+
+ fun render(typingUsers: List, avatarRender: AvatarRenderer) {
+ views.usersName.text = typingHelper.getNotificationTypingMessage(typingUsers)
+ views.avatars.render(typingUsers, avatarRender)
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ removeAllViews()
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt
index 71a299e11b..e2b97b0900 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt
@@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.initsync.SyncStatusService
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary
+import org.matrix.android.sdk.api.session.room.sender.SenderInfo
import org.matrix.android.sdk.api.session.sync.SyncState
import org.matrix.android.sdk.api.session.threads.ThreadNotificationBadgeState
import org.matrix.android.sdk.api.session.widgets.model.Widget
@@ -72,7 +73,8 @@ data class RoomDetailViewState(
val jitsiState: JitsiState = JitsiState(),
val switchToParentSpace: Boolean = false,
val rootThreadEventId: String? = null,
- val threadNotificationBadgeState: ThreadNotificationBadgeState = ThreadNotificationBadgeState()
+ val threadNotificationBadgeState: ThreadNotificationBadgeState = ThreadNotificationBadgeState(),
+ val typingUsers: List? = null
) : MavericksState {
constructor(args: TimelineArgs) : this(
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
index b6cbd538f3..8fbe8e9afa 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
@@ -273,6 +273,7 @@ class TimelineFragment @Inject constructor(
CurrentCallsView.Callback {
companion object {
+
/**
* Sanitize the display name.
*
@@ -287,6 +288,7 @@ class TimelineFragment @Inject constructor(
return displayName
}
+ const val MAX_TYPING_MESSAGE_USERS_COUNT = 4
private const val ircPattern = " (IRC)"
}
@@ -1546,6 +1548,7 @@ class TimelineFragment @Inject constructor(
invalidateOptionsMenu()
val summary = mainState.asyncRoomSummary()
renderToolbar(summary, mainState.formattedTypingUsers)
+ renderTypingMessageNotification(summary, mainState)
views.removeJitsiWidgetView.render(mainState)
if (mainState.hasFailedSending) {
lazyLoadedViews.failedMessagesWarningView(inflateIfNeeded = true, createFailedMessagesWarningCallback())?.isVisible = true
@@ -1558,6 +1561,7 @@ class TimelineFragment @Inject constructor(
views.jumpToBottomView.drawBadge = summary.hasUnreadMessages
timelineEventController.update(mainState)
lazyLoadedViews.inviteView(false)?.isVisible = false
+
if (mainState.tombstoneEvent == null) {
views.composerLayout.isInvisible = !messageComposerState.isComposerVisible
views.voiceMessageRecorderView.isVisible = messageComposerState.isVoiceMessageRecorderVisible
@@ -1601,6 +1605,17 @@ class TimelineFragment @Inject constructor(
voiceMessageRecorderView.isVisible = false
}
+ private fun renderTypingMessageNotification(roomSummary: RoomSummary?, state: RoomDetailViewState) {
+ if (!isThreadTimeLine() && roomSummary != null) {
+ views.typingMessageView.isInvisible = state.typingUsers.isNullOrEmpty()
+ state.typingUsers?.let { senders ->
+ views.typingMessageView.render(senders.take(MAX_TYPING_MESSAGE_USERS_COUNT), avatarRenderer)
+ }
+ } else {
+ views.typingMessageView.isInvisible = true
+ }
+ }
+
private fun renderToolbar(roomSummary: RoomSummary?, typingMessage: String?) {
if (!isThreadTimeLine()) {
views.includeRoomToolbar.roomToolbarContentView.isVisible = true
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
index 0198c77280..14f5df9055 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
@@ -1172,6 +1172,7 @@ class TimelineViewModel @AssistedInject constructor(
setState {
val typingMessage = typingHelper.getTypingMessage(summary.typingUsers)
copy(
+ typingUsers = summary.typingUsers,
formattedTypingUsers = typingMessage,
hasFailedSending = summary.hasFailedSending
)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/typing/TypingHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/typing/TypingHelper.kt
index 5878f99468..ca948e6fdb 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/typing/TypingHelper.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/typing/TypingHelper.kt
@@ -42,4 +42,15 @@ class TypingHelper @Inject constructor(private val stringProvider: StringProvide
typingUsers[1].disambiguatedDisplayName)
}
}
+
+ fun getNotificationTypingMessage(typingUsers: List): String {
+ return when {
+ typingUsers.isEmpty() -> ""
+ typingUsers.size == 1 -> typingUsers[0].disambiguatedDisplayName
+ typingUsers.size == 2 -> stringProvider.getString(R.string.room_notification_two_users_are_typing,
+ typingUsers[0].disambiguatedDisplayName, typingUsers[1].disambiguatedDisplayName)
+ else -> stringProvider.getString(R.string.room_notification_more_than_two_users_are_typing,
+ typingUsers[0].disambiguatedDisplayName, typingUsers[1].disambiguatedDisplayName)
+ }
+ }
}
diff --git a/vector/src/main/res/drawable/ic_typing_dot.xml b/vector/src/main/res/drawable/ic_typing_dot.xml
new file mode 100644
index 0000000000..0a3c10a0c9
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_typing_dot.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/vector/src/main/res/layout/fragment_timeline.xml b/vector/src/main/res/layout/fragment_timeline.xml
index cb8e984cc6..137791d13d 100644
--- a/vector/src/main/res/layout/fragment_timeline.xml
+++ b/vector/src/main/res/layout/fragment_timeline.xml
@@ -17,8 +17,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
- android:visibility="gone"
- tools:visibility="gone" />
+ android:visibility="gone"/>
+ app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView"/>
+
+
+ app:layout_constraintStart_toStartOf="parent"/>
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vector/src/main/res/layout/vector_settings_round_avatar.xml b/vector/src/main/res/layout/vector_settings_round_avatar.xml
index 596eef5d3c..ca9c39825f 100644
--- a/vector/src/main/res/layout/vector_settings_round_avatar.xml
+++ b/vector/src/main/res/layout/vector_settings_round_avatar.xml
@@ -1,8 +1,8 @@
+ android:layout_width="24dp"
+ android:layout_height="24dp">
+
\ No newline at end of file
diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml
index 800d1092f8..227527d9fa 100644
--- a/vector/src/main/res/values/strings.xml
+++ b/vector/src/main/res/values/strings.xml
@@ -956,6 +956,9 @@
%s is typing…
%1$s & %2$s are typing…
%1$s & %2$s & others are typing…
+
+ %1$s and %2$s
+ %1$s, %2$s and others
Send an encrypted message…
Send a message (unencrypted)…
Send an encrypted reply…