View all threads screen implementation & UI

Add user friendly message thread summary on the SDK side
Fix not encrypted rooms thread summaries
This commit is contained in:
ariskotsomitopoulos 2021-11-23 13:34:24 +02:00
parent 586b3d8caa
commit 722f367690
28 changed files with 654 additions and 186 deletions

View File

@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.Room
import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams
import org.matrix.android.sdk.api.session.room.members.RoomMemberQueryParams
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.ReadReceipt
@ -98,6 +99,13 @@ class FlowRoom(private val room: Room) {
fun liveNotificationState(): Flow<RoomNotificationState> {
return room.getLiveRoomNotificationState().asFlow()
}
fun liveThreadList(): Flow<List<TimelineEvent>> {
return room.getAllThreadsLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getAllThreads()
}
}
}
fun Room.flow(): FlowRoom {

View File

@ -27,11 +27,13 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.threads.ThreadDetails
import org.matrix.android.sdk.api.util.ContentUtils
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
import org.matrix.android.sdk.internal.di.MoshiProvider
import org.matrix.android.sdk.internal.session.presence.model.PresenceContent
import org.matrix.android.sdk.internal.session.room.send.removeInReplyFallbacks
import timber.log.Timber
typealias Content = JsonDict
@ -188,14 +190,39 @@ data class Event(
return contentMap?.let { JSONObject(adapter.toJson(it)).toString(4) }
}
fun getDecryptedMessageText(): String {
return getValueFromPayload(mxDecryptionResult?.payload).orEmpty()
/**
* Returns a user friendly content depending on the message type.
* It can be used especially for message summaries.
* It will return a decrypted text message or an empty string otherwise.
*/
fun getDecryptedUserFriendlyTextSummary(): String {
val text = getDecryptedValue().orEmpty()
return when {
isReply() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text)
isFileMessage() -> "sent a file."
isAudioMessage() -> "sent an audio file."
isImageMessage() -> "sent an image."
isVideoMessage() -> "sent a video."
else -> text
}
}
@Suppress("UNCHECKED_CAST")
private fun getValueFromPayload(payload: JsonDict?, key: String = "body"): String? {
val content = payload?.get("content") as? JsonDict
return content?.get(key) as? String
private fun Event.isQuote(): Boolean {
if (isReply()) return false
return getDecryptedValue("formatted_body")?.contains("<blockquote>") ?: false
}
/**
* Decrypt the message, or return the pure payload value if there is no encryption
*/
private fun getDecryptedValue(key: String = "body"): String? {
return if (isEncrypted()) {
@Suppress("UNCHECKED_CAST")
val content = mxDecryptionResult?.payload?.get("content") as? JsonDict
content?.get(key) as? String
} else {
content?.get(key) as? String
}
}
/**

View File

@ -55,4 +55,17 @@ interface TimelineService {
* Returns a snapshot list of TimelineEvent with EventType.MESSAGE and MessageType.MSGTYPE_IMAGE or MessageType.MSGTYPE_VIDEO.
*/
fun getAttachmentMessages(): List<TimelineEvent>
/**
* Get a live list of all the thread for the specified roomId
* @return the [LiveData] of [TimelineEvent]
*/
fun getAllThreadsLive(): LiveData<List<TimelineEvent>>
/**
* Get a list of all the thread for the specified roomId
* @return the [LiveData] of [TimelineEvent]
*/
fun getAllThreads(): List<TimelineEvent>
}

View File

@ -17,6 +17,7 @@
package org.matrix.android.sdk.internal.database.helper
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.Sort
import org.matrix.android.sdk.BuildConfig
@ -82,7 +83,17 @@ internal fun EventEntity.findAllThreadsForRootEventId(realm: Realm, rootThreadEv
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findAll()
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
.findAll()
/**
* Find all TimelineEventEntity that are root threads for the specified room
* @param roomId The room that all stored root threads will be returned
*/
internal fun TimelineEventEntity.Companion.findAllThreadsForRoomId(realm: Realm, roomId: String): RealmQuery<TimelineEventEntity> =
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD,true)
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)

View File

@ -22,11 +22,9 @@ import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.UnsignedData
import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId
import org.matrix.android.sdk.api.session.events.model.isThread
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
import org.matrix.android.sdk.api.session.threads.ThreadDetails
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.di.MoshiProvider
@ -113,7 +111,7 @@ internal object EventMapper {
avatarUrl = timelineEventEntity.senderAvatar
)
},
threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedMessageText().orEmpty()
threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedUserFriendlyTextSummary().orEmpty()
)
}
}

View File

@ -168,7 +168,11 @@ internal class DefaultTimeline(
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, it)
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findAll()
.or()
.equalTo(TimelineEventEntityFields.ROOT.EVENT_ID, rootThreadEventId)
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
.findAll()
} ?: buildEventQuery(realm).sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findAll()
timelineEvents.addChangeListener(eventsChangeListener)

View File

@ -31,9 +31,11 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.database.RealmSessionProvider
import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.database.query.findAllInRoomWithSendStates
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask
@ -102,4 +104,17 @@ internal class DefaultTimelineService @AssistedInject constructor(
.orEmpty()
}
}
override fun getAllThreadsLive(): LiveData<List<TimelineEvent>> {
return monarchy.findAllMappedWithChanges(
{ TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun getAllThreads(): List<TimelineEvent> {
return monarchy.fetchAllMappedSync(
{ TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
}

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/coordinatorLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -58,7 +58,7 @@ import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment
import im.vector.app.features.home.room.detail.TimelineFragment
import im.vector.app.features.home.room.detail.search.SearchFragment
import im.vector.app.features.home.room.list.RoomListFragment
import im.vector.app.features.home.room.threads.detail.ThreadListFragment
import im.vector.app.features.home.room.threads.list.views.ThreadListFragment
import im.vector.app.features.login.LoginCaptchaFragment
import im.vector.app.features.login.LoginFragment
import im.vector.app.features.login.LoginGenericTextInputFormFragment

View File

@ -68,6 +68,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.jakewharton.rxbinding3.view.focusChanges
import com.jakewharton.rxbinding3.widget.textChanges
import com.vanniktech.emoji.EmojiPopup
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.dialogs.ConfirmationDialogBuilder
import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper
@ -971,7 +972,7 @@ class TimelineFragment @Inject constructor(
true
}
R.id.threads -> {
requireActivity().toast("View All Threads")
navigateToThreadList()
true
}
R.id.search -> {
@ -1776,7 +1777,7 @@ class TimelineFragment @Inject constructor(
roomDetailViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId))
}
}
if (isRootThreadEvent) {
if (BuildConfig.THREADING_ENABLED && isRootThreadEvent && !isThreadTimeLine()) {
navigateToThreadTimeline(informationData.eventId)
}
}
@ -2136,8 +2137,8 @@ class TimelineFragment @Inject constructor(
}
/**
* Navigate to Threads timeline for the specified threadRootEventId
* using the RoomThreadDetailActivity
* Navigate to Threads timeline for the specified rootThreadEventId
* using the ThreadsActivity
*/
private fun navigateToThreadTimeline(rootThreadEventId: String) {
@ -2151,6 +2152,21 @@ class TimelineFragment @Inject constructor(
}
}
/**
* Navigate to Threads list for the current room
* using the ThreadsActivity
*/
private fun navigateToThreadList() {
context?.let {
val roomThreadDetailArgs = ThreadTimelineArgs(
roomId = timelineArgs.roomId,
displayName = roomDetailViewModel.getRoomSummary()?.displayName,
avatarUrl = roomDetailViewModel.getRoomSummary()?.avatarUrl)
navigator.openThreadList(it, roomThreadDetailArgs)
}
}
// VectorInviteView.Callback
override fun onAcceptInvite() {

View File

@ -27,6 +27,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.onClick
@ -105,14 +106,17 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
holder.eventSendingIndicator.isVisible = attributes.informationData.sendStateDecoration == SendStateDecoration.SENDING_MEDIA
// Threads
attributes.threadDetails?.let { threadDetails ->
threadDetails.isRootThread
holder.threadSummaryConstraintLayout.isVisible = threadDetails.isRootThread
holder.threadSummaryCounterTextView.text = threadDetails.numberOfThreads.toString()
holder.threadSummaryInfoTextView.text = threadDetails.threadSummaryLatestTextMessage
threadDetails.threadSummarySenderInfo?.let { senderInfo ->
attributes.avatarRenderer.render(MatrixItem.UserItem(senderInfo.userId, senderInfo.displayName, senderInfo.avatarUrl), holder.threadSummaryAvatarImageView)
if(BuildConfig.THREADING_ENABLED) {
attributes.threadDetails?.let { threadDetails ->
holder.threadSummaryConstraintLayout.isVisible = threadDetails.isRootThread
holder.threadSummaryCounterTextView.text = threadDetails.numberOfThreads.toString()
holder.threadSummaryInfoTextView.text = threadDetails.threadSummaryLatestTextMessage
threadDetails.threadSummarySenderInfo?.let { senderInfo ->
attributes.avatarRenderer.render(MatrixItem.UserItem(senderInfo.userId, senderInfo.displayName, senderInfo.avatarUrl), holder.threadSummaryAvatarImageView)
}
}
}else{
holder.threadSummaryConstraintLayout.isVisible = false
}
}

View File

@ -25,14 +25,13 @@ import im.vector.app.core.di.ScreenComponent
import im.vector.app.core.extensions.replaceFragment
import im.vector.app.core.platform.ToolbarConfigurable
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.databinding.ActivityThreadsBinding
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.arguments.TimelineArgs
import im.vector.app.features.home.room.detail.TimelineFragment
import im.vector.app.features.home.room.threads.arguments.ThreadListArgs
import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs
import im.vector.app.features.home.room.threads.detail.ThreadListFragment
import im.vector.app.features.home.room.threads.list.views.ThreadListFragment
import javax.inject.Inject
class ThreadsActivity : VectorBaseActivity<ActivityThreadsBinding>(), ToolbarConfigurable {

View File

@ -21,5 +21,7 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class ThreadListArgs(
val roomId: String
val roomId: String,
val displayName: String?,
val avatarUrl: String?,
) : Parcelable

View File

@ -1,62 +0,0 @@
/*
* Copyright 2021 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.features.home.room.threads.detail
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.args
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentThreadListBinding
import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs
import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject
class ThreadListFragment @Inject constructor(
private val session: Session
) : VectorBaseFragment<FragmentThreadListBinding>() {
private val threadTimelineArgs: ThreadTimelineArgs by args()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentThreadListBinding {
return FragmentThreadListBinding.inflate(inflater, container, false)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initTextComposer()
// lifecycleScope.launch(Dispatchers.IO) {
// Realm.getInstance(realmConfiguration).executeTransaction {
// val eventId = roomThreadDetailArgs.eventId ?: return@executeTransaction
// val r = EventEntity.where(it, eventId = eventId)
// .findFirst() ?: return@executeTransaction
// Timber.i("------> $eventId isThread: ${EventMapper.map(r).isThread()}")
// }
// }
//// views.testTextVeiwddasda.text = "${roomThreadDetailArgs.eventId} -- ${roomThreadDetailArgs.roomId}"
}
private fun initTextComposer(){
// views.roomThreadDetailTextComposerView.views.sendButton.isVisible = true
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright 2019 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.features.home.room.threads.list.model
import android.widget.ImageView
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.AvatarRenderer
import org.matrix.android.sdk.api.util.MatrixItem
@EpoxyModelClass(layout = R.layout.item_thread_summary)
abstract class ThreadSummaryModel : VectorEpoxyModel<ThreadSummaryModel.Holder>() {
@EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
@EpoxyAttribute lateinit var matrixItem: MatrixItem
@EpoxyAttribute lateinit var title: String
@EpoxyAttribute lateinit var date: String
@EpoxyAttribute lateinit var rootMessage: String
@EpoxyAttribute lateinit var lastMessage: String
@EpoxyAttribute lateinit var lastMessageCounter: String
@EpoxyAttribute lateinit var lastMessageMatrixItem: MatrixItem
override fun bind(holder: Holder) {
super.bind(holder)
avatarRenderer.render(matrixItem, holder.avatarImageView)
holder.avatarImageView.contentDescription = matrixItem.getBestName()
holder.titleTextView.text = title
holder.dateTextView.text = date
holder.rootMessageTextView.text = rootMessage
// Last message summary
avatarRenderer.render(lastMessageMatrixItem, holder.lastMessageAvatarImageView)
holder.lastMessageAvatarImageView.contentDescription = lastMessageMatrixItem.getBestName()
holder.lastMessageTextView.text = lastMessage
holder.lastMessageCounterTextView.text = lastMessageCounter
}
class Holder : VectorEpoxyHolder() {
val avatarImageView by bind<ImageView>(R.id.threadSummaryAvatarImageView)
val titleTextView by bind<TextView>(R.id.threadSummaryTitleTextView)
val dateTextView by bind<TextView>(R.id.threadSummaryDateTextView)
val rootMessageTextView by bind<TextView>(R.id.threadSummaryRootMessageTextView)
val lastMessageAvatarImageView by bind<ImageView>(R.id.messageThreadSummaryAvatarImageView)
val lastMessageCounterTextView by bind<TextView>(R.id.messageThreadSummaryCounterTextView)
val lastMessageTextView by bind<TextView>(R.id.messageThreadSummaryInfoTextView)
}
}

View File

@ -0,0 +1,73 @@
/*
* Copyright 2020 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.features.home.room.threads.list.viewmodel
import com.airbnb.epoxy.EpoxyController
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.threads.list.model.threadSummary
import org.matrix.android.sdk.api.util.toMatrixItem
import javax.inject.Inject
class ThreadSummaryController @Inject constructor(
private val avatarRenderer: AvatarRenderer
) : EpoxyController() {
var listener: Listener? = null
private var viewState: ThreadSummaryViewState? = null
init {
// We are requesting a model build directly as the first build of epoxy is on the main thread.
// It avoids to build the whole list of breadcrumbs on the main thread.
requestModelBuild()
}
fun update(viewState: ThreadSummaryViewState) {
this.viewState = viewState
requestModelBuild()
}
override fun buildModels() {
val safeViewState = viewState ?: return
val host = this
// Add a ZeroItem to avoid automatic scroll when the breadcrumbs are updated from another client
// zeroItem {
// id("top")
// }
// An empty breadcrumbs list can only be temporary because when entering in a room,
// this one is added to the breadcrumbs
safeViewState.rootThreadEventList.invoke()
?.forEach { timelineEvent ->
threadSummary {
id(timelineEvent.eventId)
avatarRenderer(host.avatarRenderer)
matrixItem(timelineEvent.senderInfo.toMatrixItem())
title(timelineEvent.senderInfo.displayName)
date(timelineEvent.root.ageLocalTs.toString())
rootMessage(timelineEvent.root.getDecryptedUserFriendlyTextSummary())
lastMessage(timelineEvent.root.threadDetails?.threadSummaryLatestTextMessage.orEmpty())
lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString())
lastMessageMatrixItem(timelineEvent.root.threadDetails?.threadSummarySenderInfo?.toMatrixItem())
}
}
}
interface Listener {
fun onBreadcrumbClicked(roomId: String)
}
}

View File

@ -0,0 +1,72 @@
/*
* Copyright 2020 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.features.home.room.threads.list.viewmodel
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.platform.EmptyAction
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.home.room.threads.list.views.ThreadListFragment
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.Room
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams
import org.matrix.android.sdk.flow.flow
class ThreadSummaryViewModel @AssistedInject constructor(@Assisted val initialState: ThreadSummaryViewState,
private val session: Session) :
VectorViewModel<ThreadSummaryViewState, EmptyAction, EmptyViewEvents>(initialState) {
private val room = session.getRoom(initialState.roomId)
@AssistedFactory
interface Factory {
fun create(initialState: ThreadSummaryViewState): ThreadSummaryViewModel
}
companion object : MavericksViewModelFactory<ThreadSummaryViewModel, ThreadSummaryViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: ThreadSummaryViewState): ThreadSummaryViewModel? {
val fragment: ThreadListFragment = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.threadSummaryViewModelFactory.create(state)
}
}
init {
observeThreadsSummary()
}
override fun handle(action: EmptyAction) {
// No op
}
private fun observeThreadsSummary() {
room?.flow()
?.liveThreadList()
?.execute { asyncThreads ->
copy(rootThreadEventList = asyncThreads)
}
}
}

View File

@ -0,0 +1,31 @@
/*
* Copyright 2020 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.features.home.room.threads.list.viewmodel
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.home.room.threads.arguments.ThreadListArgs
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
data class ThreadSummaryViewState(
val rootThreadEventList: Async<List<TimelineEvent>> = Uninitialized,
val roomId: String
) : MavericksState{
constructor(args: ThreadListArgs) : this(roomId = args.roomId)
}

View File

@ -0,0 +1,91 @@
/*
* Copyright (c) 2021 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.features.home.room.threads.list.views
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.app.R
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentThreadListBinding
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsAnimator
import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsViewModel
import im.vector.app.features.home.room.detail.RoomDetailSharedActionViewModel
import im.vector.app.features.home.room.threads.arguments.ThreadListArgs
import im.vector.app.features.home.room.threads.list.viewmodel.ThreadSummaryController
import im.vector.app.features.home.room.threads.list.viewmodel.ThreadSummaryViewModel
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.util.MatrixItem
import javax.inject.Inject
class ThreadListFragment @Inject constructor(
private val session: Session,
private val avatarRenderer: AvatarRenderer,
private val threadSummaryController: ThreadSummaryController,
val threadSummaryViewModelFactory: ThreadSummaryViewModel.Factory
) : VectorBaseFragment<FragmentThreadListBinding>() {
private val threadSummaryViewModel: ThreadSummaryViewModel by fragmentViewModel()
private val threadListArgs: ThreadListArgs by args()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentThreadListBinding {
return FragmentThreadListBinding.inflate(inflater, container, false)
}
override fun getMenuRes() = R.menu.menu_thread_list
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initToolbar()
views.threadListRecyclerView.configureWith(threadSummaryController, BreadcrumbsAnimator(), hasFixedSize = false)
// threadSummaryController.listener = this
}
override fun onDestroyView() {
views.threadListRecyclerView.cleanup()
// breadcrumbsController.listener = null
super.onDestroyView()
}
private fun initToolbar(){
setupToolbar(views.threadListToolbar)
renderToolbar()
}
override fun invalidate() = withState(threadSummaryViewModel) { state ->
threadSummaryController.update(state)
}
private fun renderToolbar() {
views.includeThreadListToolbar.roomToolbarThreadConstraintLayout.isVisible = true
val matrixItem = MatrixItem.RoomItem(threadListArgs.roomId, threadListArgs.displayName, threadListArgs.avatarUrl)
avatarRenderer.render(matrixItem, views.includeThreadListToolbar.roomToolbarThreadImageView)
views.includeThreadListToolbar.roomToolbarThreadSubtitleTextView.text = threadListArgs.displayName
}
}

View File

@ -517,4 +517,14 @@ class DefaultNavigator @Inject constructor(
threadTimelineArgs = threadTimelineArgs,
threadListArgs =null))
}
override fun openThreadList(context: Context, threadTimelineArgs: ThreadTimelineArgs) {
context.startActivity(ThreadsActivity.newIntent(
context = context,
threadTimelineArgs = null,
threadListArgs = ThreadListArgs(
roomId = threadTimelineArgs.roomId,
displayName = threadTimelineArgs.displayName,
avatarUrl = threadTimelineArgs.avatarUrl
)))
}
}

View File

@ -143,5 +143,6 @@ interface Navigator {
fun openCallTransfer(context: Context, callId: String)
fun openThread(context: Context, threadTimelineArgs: ThreadTimelineArgs)
fun openThreadList(context: Context, threadTimelineArgs: ThreadTimelineArgs)
}

View File

@ -0,0 +1,40 @@
<?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="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/threadListAppBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/threadListToolbar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:transitionName="toolbar">
<include
android:id="@+id/includeThreadListToolbar"
layout="@layout/view_room_detail_thread_toolbar" />
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/threadListRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/threadListAppBarLayout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:background="?android:colorBackground"
tools:listitem="@layout/item_thread_summary" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,91 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingTop="12dp"
android:paddingEnd="0dp">
<ImageView
android:id="@+id/threadSummaryAvatarImageView"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/avatar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@sample/user_round_avatars" />
<TextView
android:id="@+id/threadSummaryTitleTextView"
style="@style/Widget.Vector.TextView.Body.Medium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textStyle="bold"
android:textColor="@color/element_name_04"
app:layout_constraintEnd_toStartOf="@id/threadSummaryDateTextView"
app:layout_constraintStart_toEndOf="@id/threadSummaryAvatarImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="Aris" />
<TextView
android:id="@+id/threadSummaryDateTextView"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toBottomOf="@id/threadSummaryTitleTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/threadSummaryTitleTextView"
tools:text="10 minutes ago" />
<TextView
android:id="@+id/threadSummaryRootMessageTextView"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="?vctr_content_primary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/threadSummaryTitleTextView"
app:layout_constraintTop_toBottomOf="@id/threadSummaryTitleTextView"
tools:text="Its really encouraging to feel like you are a part of something greater than yourself. I Its really encouraging to feel like you are a part of something greater than yourself. I " />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/threadSummaryConstraintLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:contentDescription="@string/room_threads_filter"
android:maxWidth="496dp"
android:minWidth="144dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/threadSummaryTitleTextView"
app:layout_constraintTop_toBottomOf="@id/threadSummaryRootMessageTextView"
tools:visibility="visible">
<include layout="@layout/view_thread_room_summary" />
</androidx.constraintlayout.widget.ConstraintLayout>
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:background="?vctr_content_quinary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/threadSummaryConstraintLayout"
app:layout_constraintTop_toBottomOf="@id/threadSummaryConstraintLayout" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -200,7 +200,27 @@
</com.google.android.flexbox.FlexboxLayout>
</LinearLayout>
<include
layout="@layout/view_thread_room_summary" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/messageThreadSummaryConstraintLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/informationBottom"
android:layout_marginEnd="32dp"
android:layout_marginBottom="4dp"
android:paddingStart="13dp"
android:paddingEnd="13dp"
android:layout_toEndOf="@id/messageStartGuideline"
android:background="@drawable/rounded_rect_shape_8"
android:contentDescription="@string/room_threads_filter"
android:maxWidth="496dp"
android:minWidth="144dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:visibility="gone"
tools:visibility="visible">
<include layout="@layout/view_thread_room_summary" />
</androidx.constraintlayout.widget.ConstraintLayout>
</RelativeLayout>

View File

@ -1,74 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/messageThreadSummaryConstraintLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/informationBottom"
android:layout_marginEnd="32dp"
android:layout_marginBottom="4dp"
android:layout_toEndOf="@id/messageStartGuideline"
android:background="@drawable/rounded_rect_shape_8"
android:contentDescription="@string/room_threads_filter"
android:maxWidth="496dp"
android:minWidth="144dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:visibility="gone"
tools:visibility="visible">
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<ImageView
android:id="@+id/messageThreadSummaryImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="13dp"
android:layout_marginTop="2dp"
android:contentDescription="@string/room_threads_filter"
android:src="@drawable/ic_thread_summary" />
android:src="@drawable/ic_thread_summary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/messageThreadSummaryCounterTextView"
style="@style/Widget.Vector.TextView.Caption"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/messageThreadSummaryImageView"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/messageThreadSummaryImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="187" />
<ImageView
android:id="@+id/messageThreadSummaryAvatarImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/messageThreadSummaryCounterTextView"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="13dp"
android:contentDescription="@string/avatar"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/messageThreadSummaryCounterTextView"
app:layout_constraintTop_toTopOf="parent"
tools:src="@sample/user_round_avatars" />
<TextView
android:id="@+id/messageThreadSummaryInfoTextView"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/messageThreadSummaryAvatarImageView"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_default="wrap"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="13dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?vctr_content_secondary"
tools:text="Hello There, whats up! Its a large centence" />
</androidx.constraintlayout.widget.ConstraintLayout>
tools:text="Hello There, whats up! Its a large centence, whats up! Its a large centence" />
</merge>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_filter"
android:icon="@drawable/ic_filter"
android:title="@string/room_threads_filter"
app:showAsAction="always" />
</menu>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<item
android:id="@+id/menu_thread_list_filter"
android:icon="@drawable/ic_filter"
android:title="@string/room_threads_filter"
app:iconTint="?vctr_content_secondary"
app:showAsAction="ifRoom" />
</menu>

View File

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/test"
android:icon="@drawable/ic_search"
android:title="@string/action_thread_view_in_room"
app:showAsAction="always">
<menu>
<item
android:id="@+id/menuWithIconText"
android:icon="@drawable/ic_thread_menu_item"
android:title="@string/action_thread_share"
/>
</menu>
</item>
<item
android:id="@+id/menu_thread_timeline_view_in_room"
android:icon="@drawable/ic_settings_x"
android:title="@string/action_thread_view_in_room"
app:showAsAction="never" />
<item
android:id="@+id/menu_thread_timeline_copy"
android:title="@string/action_thread_copy_link_to_thread"
app:showAsAction="never" />
<item
android:id="@+id/menu_thread_timeline_share"
android:title="@string/action_thread_share"
app:showAsAction="never" />
</menu>