mirror of
https://github.com/element-hq/element-android
synced 2024-11-24 10:25:35 +03:00
- 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:
parent
924a4f8c94
commit
da9fdf1b18
16 changed files with 319 additions and 9 deletions
|
@ -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
1
changelog.d/3296.bugfix
Normal file
|
@ -0,0 +1 @@
|
|||
Typing notifications moved from the header to the bottom of the timeline.
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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() }
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1172,6 +1172,7 @@ class TimelineViewModel @AssistedInject constructor(
|
|||
setState {
|
||||
val typingMessage = typingHelper.getTypingMessage(summary.typingUsers)
|
||||
copy(
|
||||
typingUsers = summary.typingUsers,
|
||||
formattedTypingUsers = typingMessage,
|
||||
hasFailedSending = summary.hasFailedSending
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
9
vector/src/main/res/drawable/ic_typing_dot.xml
Normal file
9
vector/src/main/res/drawable/ic_typing_dot.xml
Normal 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>
|
|
@ -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"
|
||||
|
|
38
vector/src/main/res/layout/typing_message_layout.xml
Normal file
38
vector/src/main/res/layout/typing_message_layout.xml
Normal 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>
|
|
@ -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"
|
||||
|
|
|
@ -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"/>
|
|
@ -956,6 +956,9 @@
|
|||
<string name="room_one_user_is_typing">%s is typing…</string>
|
||||
<string name="room_two_users_are_typing">%1$s & %2$s are typing…</string>
|
||||
<string name="room_many_users_are_typing">%1$s & %2$s & 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>
|
||||
|
|
Loading…
Reference in a new issue