Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
 - Adding a typing message notification view at the bottom of the timeline in rooms.

Signed-off-by: Ahmed Radhouane Belkilani <[email protected]>
  • Loading branch information
ahmed-radhouane committed Feb 23, 2022
1 parent 924a4f8 commit da9fdf1
Show file tree
Hide file tree
Showing 16 changed files with 319 additions and 9 deletions.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -35,3 +36,5 @@ data class SenderInfo(
else -> "$displayName ($userId)"
}
}

fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, displayName, avatarUrl)
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(
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()
}
}
Original file line number Diff line number Diff line change
@@ -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() }
}
}
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>, avatarRender: AvatarRenderer) {
views.usersName.text = typingHelper.getNotificationTypingMessage(typingUsers)
views.avatars.render(typingUsers, avatarRender)
}

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()
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
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 {
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>
21 changes: 15 additions & 6 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"
android:visibility="invisible"
android:layout_height="wrap_content"/>

<im.vector.app.core.ui.views.NotificationAreaView
android:id="@+id/notificationAreaView"
Expand Down Expand Up @@ -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"
Expand Down
Loading

0 comments on commit da9fdf1

Please sign in to comment.