- Adding a typing message notification view at the bottom of the timeline in rooms.

Signed-off-by: Ahmed Radhouane Belkilani <arbelkilani@gmail.com>
This commit is contained in:
Ahmed Radhouane Belkilani 2022-02-11 10:18:13 +01:00
parent 924a4f8c94
commit da9fdf1b18
16 changed files with 319 additions and 9 deletions

View file

@ -0,0 +1,4 @@
<changelist name="Uncommitted_changes_before_rebase_[Default_Changelist]" date="1644570474870" recycled="true" deleted="true">
<option name="PATH" value="$PROJECT_DIR$/.idea/shelf/Uncommitted_changes_before_rebase_[Default_Changelist]/shelved.patch" />
<option name="DESCRIPTION" value="Uncommitted changes before rebase [Default Changelist]" />
</changelist>

1
changelog.d/3296.bugfix Normal file
View file

@ -0,0 +1 @@
Typing notifications moved from the header to the bottom of the timeline.

View file

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

View file

@ -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<SenderInfo>, 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()
}
}

View file

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

View file

@ -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<SenderInfo>, avatarRender: AvatarRenderer) {
views.usersName.text = typingHelper.getNotificationTypingMessage(typingUsers)
views.avatars.render(typingUsers, avatarRender)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
removeAllViews()
}
}

View file

@ -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<SenderInfo>? = null
) : MavericksState {
constructor(args: TimelineArgs) : this(

View file

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

View file

@ -1172,6 +1172,7 @@ class TimelineViewModel @AssistedInject constructor(
setState {
val typingMessage = typingHelper.getTypingMessage(summary.typingUsers)
copy(
typingUsers = summary.typingUsers,
formattedTypingUsers = typingMessage,
hasFailedSending = summary.hasFailedSending
)

View file

@ -42,4 +42,15 @@ class TypingHelper @Inject constructor(private val stringProvider: StringProvide
typingUsers[1].disambiguatedDisplayName)
}
}
fun getNotificationTypingMessage(typingUsers: List<SenderInfo>): 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)
}
}
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="7dp"
android:height="6dp"
android:viewportWidth="7"
android:viewportHeight="6">
<path
android:pathData="M3.22495,3.00004m-2.9,0a2.9,2.9 0,1 1,5.8 0a2.9,2.9 0,1 1,-5.8 0"
android:fillColor="#8D99A5"/>
</vector>

View file

@ -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"/>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/roomToolbar"
@ -87,8 +86,19 @@
app:closeIcon="@drawable/ic_close_24dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView"
tools:visibility="visible" />
app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView"/>
<im.vector.app.core.ui.views.TypingMessageView
android:id="@+id/typingMessageView"
app:layout_constraintBottom_toTopOf="@id/composerLayout"
app:layout_constraintTop_toBottomOf="@id/timelineRecyclerView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:layout_width="0dp"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:visibility="invisible"
android:layout_height="wrap_content"/>
<im.vector.app.core.ui.views.NotificationAreaView
android:id="@+id/notificationAreaView"
@ -119,8 +129,7 @@
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
app:layout_constraintStart_toStartOf="parent"/>
<im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
android:id="@+id/voiceMessageRecorderView"

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<im.vector.app.core.ui.views.TypingMessageAvatar
android:id="@+id/avatars"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/users_name"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:gravity="center"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toBottomOf="@id/avatars"
app:layout_constraintStart_toEndOf="@id/avatars"
app:layout_constraintTop_toTopOf="@id/avatars" />
<im.vector.app.core.ui.views.TypingMessageDotsView
android:id="@+id/viewDots"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:gravity="center"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="@id/users_name"
app:layout_constraintStart_toEndOf="@id/users_name"
app:layout_constraintTop_toTopOf="@id/users_name" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="40dp"
android:layout_height="40dp">
android:layout_width="24dp"
android:layout_height="24dp">
<ImageView
android:id="@+id/settings_avatar"

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:gravity="center"
android:layout_width="wrap_content"
android:layout_height="16dp"/>

View file

@ -956,6 +956,9 @@
<string name="room_one_user_is_typing">%s is typing…</string>
<string name="room_two_users_are_typing">%1$s &#038; %2$s are typing…</string>
<string name="room_many_users_are_typing">%1$s &#038; %2$s &#038; others are typing…</string>
<!--TODO #3296 add next two strings values -->
<string name="room_notification_two_users_are_typing">%1$s and %2$s</string>
<string name="room_notification_more_than_two_users_are_typing">%1$s, %2$s and others</string>
<string name="room_message_placeholder_encrypted">Send an encrypted message…</string>
<string name="room_message_placeholder_not_encrypted">Send a message (unencrypted)…</string>
<string name="room_message_placeholder_reply_to_encrypted">Send an encrypted reply…</string>