-
Notifications
You must be signed in to change notification settings - Fork 782
#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
Changes from all commits
da9fdf1
a9b8871
f6c6e92
5deabfa
b39a3ab
91ab472
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
---|---|---|
@@ -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 = 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 |
---|---|---|
|
@@ -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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -42,4 +42,15 @@ class TypingHelper @Inject constructor(private val stringProvider: StringProvide | |
typingUsers[1].disambiguatedDisplayName) | ||
} | ||
} | ||
|
||
fun getNotificationTypingMessage(typingUsers: List<SenderInfo>): String { | ||
return when { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not change it, but I think we could have There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
} | ||
} | ||
} |
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> |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
tools:visibility="visible" | ||
android:layout_height="20dp"/> | ||
|
||
<im.vector.app.core.ui.views.NotificationAreaView | ||
android:id="@+id/notificationAreaView" | ||
|
@@ -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" | ||
|
@@ -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" /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||
app:layout_constraintStart_toStartOf="parent"/> | ||
|
||
<im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView | ||
android:id="@+id/voiceMessageRecorderView" | ||
|
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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.