Skip to content

#3296 Typing Notification update #5206

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Feb 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3296.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Typing notifications moved from the header to the bottom of the timeline.
2 changes: 1 addition & 1 deletion tools/check/check_code_quality.sh
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ else
chmod u+x ${checkLongFilesScript}
fi

maxLines=2500
maxLines=2800

echo
echo "Search for kotlin files with more than ${maxLines} lines..."
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another possible implementation could be like in the View ReadReceiptsView. I do not know if it is better or not though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First Idea was to use this view, but too much differences with what is asked from UI team.
But, in other way TypingMessageAvatar could be used from showing the read receipts, with some modification.

context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {

companion object {
const val AVATAR_SIZE_DP = 20
const val OVERLAP_FACT0R = -3 // =~ 30% to left
}

fun render(typingUsers: List<SenderInfo>, avatarRenderer: 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
avatarRenderer.render(value.toMatrixItem(), avatar)
addView(avatar)
}
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
removeAllViews()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* 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.ObjectAnimator
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) {
ObjectAnimator.ofFloat(circle, "alpha", DEFAULT_MAX_ALPHA, DEFAULT_MIN_ALPHA).apply {
duration = DEFAULT_CIRCLE_DURATION
startDelay = DEFAULT_START_ANIM_CIRCLE_DURATION * index
repeatCount = ValueAnimator.INFINITE
}.start()
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
circles.forEach { it.clearAnimation() }
}
}
Original file line number Diff line number Diff line change
@@ -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>, avatarRenderer: AvatarRenderer) {
views.usersName.text = typingHelper.getNotificationTypingMessage(typingUsers)
views.avatars.render(typingUsers, avatarRenderer)
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
removeAllViews()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ class TimelineFragment @Inject constructor(
CurrentCallsView.Callback {

companion object {

/**
* Sanitize the display name.
*
Expand All @@ -287,6 +288,7 @@ class TimelineFragment @Inject constructor(
return displayName
}

const val MAX_TYPING_MESSAGE_USERS_COUNT = 4
private const val ircPattern = " (IRC)"
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I understand than when there is no typing user, there is an empty area at the bottom of the timeline, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's about 20dp, otherwise we will see the jump of the time line recycler view.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, thanks

state.typingUsers
?.take(MAX_TYPING_MESSAGE_USERS_COUNT)
?.let { senders -> views.typingMessageView.render(senders, avatarRenderer) }
} else {
views.typingMessageView.isInvisible = true
}
}

private fun renderToolbar(roomSummary: RoomSummary?, typingMessage: String?) {
if (!isThreadTimeLine()) {
views.includeRoomToolbar.roomToolbarContentView.isVisible = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,7 @@ class TimelineViewModel @AssistedInject constructor(
setState {
val typingMessage = typingHelper.getTypingMessage(summary.typingUsers)
copy(
typingUsers = summary.typingUsers,
formattedTypingUsers = typingMessage,
hasFailedSending = summary.hasFailedSending
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,15 @@ class TypingHelper @Inject constructor(private val stringProvider: StringProvide
typingUsers[1].disambiguatedDisplayName)
}
}

fun getNotificationTypingMessage(typingUsers: List<SenderInfo>): String {
return when {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not change it, but I think we could have when(typingUsers.size())

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah it's similar than the fun above :)

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 changes: 9 additions & 0 deletions vector/src/main/res/drawable/ic_typing_dot.xml
Original file line number Diff line number Diff line change
@@ -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>
24 changes: 17 additions & 7 deletions vector/src/main/res/layout/fragment_timeline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
tools:visibility="visible"
android:layout_height="20dp"/>

<im.vector.app.core.ui.views.NotificationAreaView
android:id="@+id/notificationAreaView"
Expand All @@ -97,7 +107,8 @@
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible"/>

<ViewStub
android:id="@+id/failedMessagesWarningStub"
Expand All @@ -119,8 +130,7 @@
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert this change. Layout editor should show all the views if possible

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.
Thanks !

app:layout_constraintStart_toStartOf="parent"/>

<im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
android:id="@+id/voiceMessageRecorderView"
Expand Down
Loading