diff --git a/.idea/shelf/Uncommitted_changes_before_rebase__Default_Changelist_.xml b/.idea/shelf/Uncommitted_changes_before_rebase__Default_Changelist_.xml new file mode 100644 index 0000000000..341c087858 --- /dev/null +++ b/.idea/shelf/Uncommitted_changes_before_rebase__Default_Changelist_.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/changelog.d/3296.bugfix b/changelog.d/3296.bugfix new file mode 100644 index 0000000000..e5f8799f21 --- /dev/null +++ b/changelog.d/3296.bugfix @@ -0,0 +1 @@ +Typing notifications moved from the header to the bottom of the timeline. \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/sender/SenderInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/sender/SenderInfo.kt index 9b73136fc3..f739fe9e1b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/sender/SenderInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/sender/SenderInfo.kt @@ -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) diff --git a/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageAvatar.kt b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageAvatar.kt new file mode 100644 index 0000000000..2682a97a2c --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageAvatar.kt @@ -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, 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() + } +} diff --git a/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageDotsView.kt b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageDotsView.kt new file mode 100644 index 0000000000..6628234704 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageDotsView.kt @@ -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() + + 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() } + } +} diff --git a/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt new file mode 100644 index 0000000000..11248bde74 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt @@ -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, avatarRender: AvatarRenderer) { + views.usersName.text = typingHelper.getNotificationTypingMessage(typingUsers) + views.avatars.render(typingUsers, avatarRender) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + removeAllViews() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 71a299e11b..e2b97b0900 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -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? = null ) : MavericksState { constructor(args: TimelineArgs) : this( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index b6cbd538f3..8fbe8e9afa 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 0198c77280..14f5df9055 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -1172,6 +1172,7 @@ class TimelineViewModel @AssistedInject constructor( setState { val typingMessage = typingHelper.getTypingMessage(summary.typingUsers) copy( + typingUsers = summary.typingUsers, formattedTypingUsers = typingMessage, hasFailedSending = summary.hasFailedSending ) diff --git a/vector/src/main/java/im/vector/app/features/home/room/typing/TypingHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/typing/TypingHelper.kt index 5878f99468..ca948e6fdb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/typing/TypingHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/typing/TypingHelper.kt @@ -42,4 +42,15 @@ class TypingHelper @Inject constructor(private val stringProvider: StringProvide typingUsers[1].disambiguatedDisplayName) } } + + fun getNotificationTypingMessage(typingUsers: List): 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) + } + } } diff --git a/vector/src/main/res/drawable/ic_typing_dot.xml b/vector/src/main/res/drawable/ic_typing_dot.xml new file mode 100644 index 0000000000..0a3c10a0c9 --- /dev/null +++ b/vector/src/main/res/drawable/ic_typing_dot.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/layout/fragment_timeline.xml b/vector/src/main/res/layout/fragment_timeline.xml index cb8e984cc6..137791d13d 100644 --- a/vector/src/main/res/layout/fragment_timeline.xml +++ b/vector/src/main/res/layout/fragment_timeline.xml @@ -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"/> + app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView"/> + + + app:layout_constraintStart_toStartOf="parent"/> + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/vector_settings_round_avatar.xml b/vector/src/main/res/layout/vector_settings_round_avatar.xml index 596eef5d3c..ca9c39825f 100644 --- a/vector/src/main/res/layout/vector_settings_round_avatar.xml +++ b/vector/src/main/res/layout/vector_settings_round_avatar.xml @@ -1,8 +1,8 @@ + android:layout_width="24dp" + android:layout_height="24dp"> + \ No newline at end of file diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 800d1092f8..227527d9fa 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -956,6 +956,9 @@ %s is typing… %1$s & %2$s are typing… %1$s & %2$s & others are typing… + + %1$s and %2$s + %1$s, %2$s and others Send an encrypted message… Send a message (unencrypted)… Send an encrypted reply…