Add date in view all threads

UI Improvements on threads summary
Add View In Room bottom sheet action from within thread timeline root message
This commit is contained in:
ariskotsomitopoulos 2021-11-23 17:09:58 +02:00
parent 722f367690
commit 5e5ce614ef
16 changed files with 103 additions and 37 deletions

View File

@ -676,7 +676,7 @@ class RoomDetailViewModel @AssistedInject constructor(
if (initialState.isThreadTimeline()) {
when (itemId) {
R.id.menu_thread_timeline_more -> true
else -> false
else -> false
}
} else {
when (itemId) {
@ -688,7 +688,7 @@ class RoomDetailViewModel @AssistedInject constructor(
// Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^
R.id.join_conference -> !state.isWebRTCCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined
R.id.search -> true
R.id.threads -> true
R.id.threads -> BuildConfig.THREADING_ENABLED
R.id.dev_tools -> vectorPreferences.developerMode()
else -> false
}

View File

@ -2012,6 +2012,13 @@ class TimelineFragment @Inject constructor(
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
}
}
is EventSharedAction.ViewInRoom -> {
if (!views.voiceMessageRecorderView.isActive()) {
handleViewInRoomAction()
} else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
}
}
is EventSharedAction.CopyPermalink -> {
val permalink = session.permalinkService().createPermalink(timelineArgs.roomId, action.eventId)
copyToClipboard(requireContext(), permalink, false)

View File

@ -104,6 +104,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
roomSummary = state.asyncRoomSummary(),
rootThreadEventId = state.rootThreadEventId
)
fun isFromThreadTimeline():Boolean = rootThreadEventId != null
}
interface Callback :
@ -193,7 +195,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
// it's sent by the same user so we are sure we have up to date information.
val invalidatedSenderId: String? = currentSnapshot.getOrNull(position)?.senderInfo?.userId
val prevDisplayableEventIndex = currentSnapshot.subList(0, position).indexOfLast {
timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId, partialState.rootThreadEventId )
timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId, partialState.isFromThreadTimeline() )
}
if (prevDisplayableEventIndex != -1 && currentSnapshot[prevDisplayableEventIndex].senderInfo.userId == invalidatedSenderId) {
modelCache[prevDisplayableEventIndex] = null
@ -370,7 +372,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
val nextEvent = currentSnapshot.nextOrNull(position)
val prevEvent = currentSnapshot.prevOrNull(position)
val nextDisplayableEvent = currentSnapshot.subList(position + 1, currentSnapshot.size).firstOrNull {
timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId, partialState.rootThreadEventId)
timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId, partialState.isFromThreadTimeline())
}
// Should be build if not cached or if model should be refreshed
if (modelCache[position] == null || modelCache[position]?.isCacheable == false) {
@ -452,7 +454,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
return null
}
// If the event is not shown, we go to the next one
if (!timelineEventVisibilityHelper.shouldShowEvent(event, partialState.highlightedEventId, partialState.rootThreadEventId)) {
if (!timelineEventVisibilityHelper.shouldShowEvent(event, partialState.highlightedEventId, partialState.isFromThreadTimeline())) {
continue
}
// If the event is sent by us, we update the holder with the eventId and stop the search
@ -474,7 +476,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
val currentReadReceipts = ArrayList(event.readReceipts).filter {
it.user.userId != session.myUserId
}
if (timelineEventVisibilityHelper.shouldShowEvent(event, partialState.highlightedEventId, partialState.rootThreadEventId)) {
if (timelineEventVisibilityHelper.shouldShowEvent(event, partialState.highlightedEventId, partialState.isFromThreadTimeline())) {
lastShownEventId = event.eventId
}
if (lastShownEventId == null) {

View File

@ -48,10 +48,14 @@ sealed class EventSharedAction(@StringRes val titleRes: Int,
data class Reply(val eventId: String) :
EventSharedAction(R.string.reply, R.drawable.ic_reply)
// TODO add translations
data class ReplyInThread(val eventId: String) :
// TODO add translations
EventSharedAction(R.string.reply_in_thread, R.drawable.ic_reply_in_thread)
// TODO add translations
object ViewInRoom :
EventSharedAction(R.string.view_in_room, R.drawable.ic_thread_view_in_room_menu_item)
data class Share(val eventId: String, val messageContent: MessageContent) :
EventSharedAction(R.string.share, R.drawable.ic_share)

View File

@ -331,6 +331,10 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
add(EventSharedAction.ReplyInThread(eventId))
}
if (canViewInRoom(timelineEvent, messageContent, actionPermissions)) {
add(EventSharedAction.ViewInRoom)
}
if (canEdit(timelineEvent, session.myUserId, actionPermissions)) {
add(EventSharedAction.Edit(eventId))
}
@ -417,6 +421,11 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
}
}
/**
* Determine whether or not the Reply In Thread bottom sheet setting will be visible
* to the user
*/
// TODO handle reply in thread for images etc
private fun canReplyInThread(event: TimelineEvent,
messageContent: MessageContent?,
actionPermissions: ActionPermissions): Boolean {
@ -437,6 +446,32 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
}
}
/**
* Determine whether or no the selected event is a root thread event from within
* a thread timeline
*/
private fun canViewInRoom(event: TimelineEvent,
messageContent: MessageContent?,
actionPermissions: ActionPermissions): Boolean {
// Only event of type EventType.MESSAGE are supported for the moment
if (!BuildConfig.THREADING_ENABLED) return false
if (!initialState.isFromThreadTimeline) return false
if (event.root.getClearType() != EventType.MESSAGE) return false
if (!actionPermissions.canSendMessage) return false
return when (messageContent?.msgType) {
MessageType.MSGTYPE_TEXT -> event.root.threadDetails?.isRootThread ?: false
// MessageType.MSGTYPE_NOTICE,
// MessageType.MSGTYPE_EMOTE,
// MessageType.MSGTYPE_IMAGE,
// MessageType.MSGTYPE_VIDEO,
// MessageType.MSGTYPE_AUDIO,
// MessageType.MSGTYPE_FILE -> true
else -> false
}
}
private fun canQuote(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean {
// Only event of type EventType.MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false

View File

@ -83,7 +83,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde
eventIdToHighlight: String?,
requestModelBuild: () -> Unit,
callback: TimelineEventController.Callback?): MergedMembershipEventsItem_? {
val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents(items, currentPosition, 2, eventIdToHighlight, partialState.rootThreadEventId)
val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents(items, currentPosition, 2, eventIdToHighlight, partialState.isFromThreadTimeline())
return if (mergedEvents.isEmpty()) {
null
} else {

View File

@ -125,6 +125,9 @@ class MessageItemFactory @Inject constructor(
pillsPostProcessorFactory.create(roomId)
}
fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? {
val event = params.event
val highlight = params.isHighlighted
@ -149,7 +152,10 @@ class MessageItemFactory @Inject constructor(
// This is an edit event, we should display it when debugging as a notice event
return noticeItemFactory.create(params)
}
val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback, event.root.threadDetails)
// always hide summary when we are on thread timeline
val threadDetails = if(params.isFromThreadTimeline()) null else event.root.threadDetails
val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback, threadDetails)
// val all = event.root.toContent()
// val ev = all.toModel<Event>()
@ -174,6 +180,9 @@ class MessageItemFactory @Inject constructor(
}
}
private fun isFromThreadTimeline(params: TimelineItemFactoryParams){
params.rootThreadEventId
}
private fun buildOptionsMessageItem(messageContent: MessageOptionsContent,
informationData: MessageInformationData,
highlight: Boolean,

View File

@ -42,8 +42,8 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*> {
val event = params.event
val computedModel = try {
if (!timelineEventVisibilityHelper.shouldShowEvent(event, params.highlightedEventId, params.rootThreadEventId)) {
return buildEmptyItem(event, params.prevEvent, params.highlightedEventId, params.rootThreadEventId)
if (!timelineEventVisibilityHelper.shouldShowEvent(event, params.highlightedEventId, params.isFromThreadTimeline())) {
return buildEmptyItem(event, params.prevEvent, params.highlightedEventId, params.isFromThreadTimeline())
}
when (event.root.getClearType()) {
// Message itemsX
@ -109,11 +109,11 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
Timber.e(throwable, "failed to create message item")
defaultItemFactory.create(params, throwable)
}
return computedModel ?: buildEmptyItem(event, params.prevEvent, params.highlightedEventId, params.rootThreadEventId)
return computedModel ?: buildEmptyItem(event, params.prevEvent, params.highlightedEventId, params.isFromThreadTimeline())
}
private fun buildEmptyItem(timelineEvent: TimelineEvent, prevEvent: TimelineEvent?, highlightedEventId: String?, rootThreadEventId: String?): TimelineEmptyItem {
val isNotBlank = prevEvent == null || timelineEventVisibilityHelper.shouldShowEvent(prevEvent, highlightedEventId, rootThreadEventId)
private fun buildEmptyItem(timelineEvent: TimelineEvent, prevEvent: TimelineEvent?, highlightedEventId: String?, isFromThreadTimeline: Boolean): TimelineEmptyItem {
val isNotBlank = prevEvent == null || timelineEventVisibilityHelper.shouldShowEvent(prevEvent, highlightedEventId, isFromThreadTimeline)
return TimelineEmptyItem_()
.id(timelineEvent.localId)
.eventId(timelineEvent.eventId)

View File

@ -38,4 +38,6 @@ data class TimelineItemFactoryParams(
get() = partialState.rootThreadEventId
val isHighlighted = highlightedEventId == event.eventId
fun isFromThreadTimeline(): Boolean = rootThreadEventId != null
}

View File

@ -40,7 +40,7 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
*
* @return a list of timeline events which have sequentially the same type following the next direction.
*/
private fun nextSameTypeEvents(timelineEvents: List<TimelineEvent>, index: Int, minSize: Int, eventIdToHighlight: String?, rootThreadEventId: String?): List<TimelineEvent> {
private fun nextSameTypeEvents(timelineEvents: List<TimelineEvent>, index: Int, minSize: Int, eventIdToHighlight: String?, isFromThreadTimeline: Boolean): List<TimelineEvent> {
if (index >= timelineEvents.size - 1) {
return emptyList()
}
@ -62,7 +62,7 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
} else {
nextSameDayEvents.subList(0, indexOfFirstDifferentEventType)
}
val filteredSameTypeEvents = sameTypeEvents.filter { shouldShowEvent(it, eventIdToHighlight, rootThreadEventId) }
val filteredSameTypeEvents = sameTypeEvents.filter { shouldShowEvent(it, eventIdToHighlight, isFromThreadTimeline) }
if (filteredSameTypeEvents.size < minSize) {
return emptyList()
}
@ -77,21 +77,22 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
*
* @return a list of timeline events which have sequentially the same type following the prev direction.
*/
fun prevSameTypeEvents(timelineEvents: List<TimelineEvent>, index: Int, minSize: Int, eventIdToHighlight: String?, rootThreadEventId: String?): List<TimelineEvent> {
fun prevSameTypeEvents(timelineEvents: List<TimelineEvent>, index: Int, minSize: Int, eventIdToHighlight: String?, isFromThreadTimeline: Boolean): List<TimelineEvent> {
val prevSub = timelineEvents.subList(0, index + 1)
return prevSub
.reversed()
.let {
nextSameTypeEvents(it, 0, minSize, eventIdToHighlight, rootThreadEventId)
nextSameTypeEvents(it, 0, minSize, eventIdToHighlight, isFromThreadTimeline)
}
}
/**
* @param timelineEvent the event to check for visibility
* @param highlightedEventId can be checked to force visibility to true
* @param rootThreadEventId if this param is null it means we are in the original timeline
* @return true if the event should be shown in the timeline.
*/
fun shouldShowEvent(timelineEvent: TimelineEvent, highlightedEventId: String?, rootThreadEventId: String?): Boolean {
fun shouldShowEvent(timelineEvent: TimelineEvent, highlightedEventId: String?, isFromThreadTimeline: Boolean): Boolean {
// If show hidden events is true we should always display something
if (userPreferencesProvider.shouldShowHiddenEvents()) {
return true
@ -105,14 +106,14 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
}
// Check for special case where we should hide the event, like redacted, relation, memberships... according to user preferences.
return !timelineEvent.shouldBeHidden(rootThreadEventId)
return !timelineEvent.shouldBeHidden(isFromThreadTimeline)
}
private fun TimelineEvent.isDisplayable(): Boolean {
return TimelineDisplayableEvents.DISPLAYABLE_TYPES.contains(root.getClearType())
}
private fun TimelineEvent.shouldBeHidden(rootThreadEventId: String?): Boolean {
private fun TimelineEvent.shouldBeHidden(isFromThreadTimeline: Boolean): Boolean {
if (root.isRedacted() && !userPreferencesProvider.shouldShowRedactedMessages()) {
return true
}
@ -125,7 +126,7 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
if ((diff.isAvatarChange || diff.isDisplaynameChange) && !userPreferencesProvider.shouldShowAvatarDisplayNameChanges()) return true
}
if(BuildConfig.THREADING_ENABLED && rootThreadEventId == null && root.isThread() && root.getRootThreadEventId() != null){
if(BuildConfig.THREADING_ENABLED && !isFromThreadTimeline && root.isThread() && root.getRootThreadEventId() != null){
return true
}

View File

@ -114,9 +114,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
threadDetails.threadSummarySenderInfo?.let { senderInfo ->
attributes.avatarRenderer.render(MatrixItem.UserItem(senderInfo.userId, senderInfo.displayName, senderInfo.avatarUrl), holder.threadSummaryAvatarImageView)
}
}
}else{
holder.threadSummaryConstraintLayout.isVisible = false
} ?: run{holder.threadSummaryConstraintLayout.isVisible = false}
}
}

View File

@ -37,7 +37,7 @@ abstract class ThreadSummaryModel : VectorEpoxyModel<ThreadSummaryModel.Holder>(
@EpoxyAttribute lateinit var rootMessage: String
@EpoxyAttribute lateinit var lastMessage: String
@EpoxyAttribute lateinit var lastMessageCounter: String
@EpoxyAttribute lateinit var lastMessageMatrixItem: MatrixItem
@EpoxyAttribute var lastMessageMatrixItem: MatrixItem? = null
override fun bind(holder: Holder) {
super.bind(holder)
@ -48,8 +48,10 @@ abstract class ThreadSummaryModel : VectorEpoxyModel<ThreadSummaryModel.Holder>(
holder.rootMessageTextView.text = rootMessage
// Last message summary
avatarRenderer.render(lastMessageMatrixItem, holder.lastMessageAvatarImageView)
holder.lastMessageAvatarImageView.contentDescription = lastMessageMatrixItem.getBestName()
lastMessageMatrixItem?.let {
avatarRenderer.render(it, holder.lastMessageAvatarImageView)
}
holder.lastMessageAvatarImageView.contentDescription = lastMessageMatrixItem?.getBestName()
holder.lastMessageTextView.text = lastMessage
holder.lastMessageCounterTextView.text = lastMessageCounter
}

View File

@ -17,13 +17,16 @@
package im.vector.app.features.home.room.threads.list.viewmodel
import com.airbnb.epoxy.EpoxyController
import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter
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
private val avatarRenderer: AvatarRenderer,
private val dateFormatter: VectorDateFormatter
) : EpoxyController() {
var listener: Listener? = null
@ -53,12 +56,13 @@ class ThreadSummaryController @Inject constructor(
// this one is added to the breadcrumbs
safeViewState.rootThreadEventList.invoke()
?.forEach { timelineEvent ->
val date = dateFormatter.format(timelineEvent.root.originServerTs, DateFormatKind.ROOM_LIST)
threadSummary {
id(timelineEvent.eventId)
avatarRenderer(host.avatarRenderer)
matrixItem(timelineEvent.senderInfo.toMatrixItem())
title(timelineEvent.senderInfo.displayName)
date(timelineEvent.root.ageLocalTs.toString())
date(date)
rootMessage(timelineEvent.root.getDecryptedUserFriendlyTextSummary())
lastMessage(timelineEvent.root.threadDetails?.threadSummaryLatestTextMessage.orEmpty())
lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString())

View File

@ -31,7 +31,7 @@
app:layout_constraintEnd_toStartOf="@id/threadSummaryDateTextView"
app:layout_constraintStart_toEndOf="@id/threadSummaryAvatarImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="Aris" />
tools:text="Aris Kots" />
<TextView
@ -40,21 +40,21 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:layout_marginEnd="25dp"
android:maxLines="1"
android:gravity="end"
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" />
tools:text="10 minutes" />
<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:layout_marginEnd="25dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="?vctr_content_primary"
@ -67,7 +67,7 @@
android:id="@+id/threadSummaryConstraintLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginEnd="25dp"
android:contentDescription="@string/room_threads_filter"
android:maxWidth="496dp"
android:minWidth="144dp"

View File

@ -20,12 +20,13 @@
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minEms="1"
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" />
tools:text="192" />
<ImageView
android:id="@+id/messageThreadSummaryAvatarImageView"

View File

@ -2183,6 +2183,7 @@
<string name="edit">Edit</string>
<string name="reply">Reply</string>
<string name="reply_in_thread">Reply In Thread</string>
<string name="view_in_room">View In Room</string>
<string name="global_retry">Retry</string>
<string name="room_list_empty">"Join a room to start using the app."</string>