diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt index 1e87cfc1..7d433ba7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt @@ -26,8 +26,13 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class MatrixError( @Json(name = "errcode") val code: String, - @Json(name = "error") val message: String -) { + @Json(name = "error") val message: String, + + @Json(name = "consent_uri") val consentUri: String? = null, + // RESOURCE_LIMIT_EXCEEDED data + @Json(name = "limit_type") val limitType: String? = null, + @Json(name = "admin_contact") val adminUri: String? = null) { + companion object { const val FORBIDDEN = "M_FORBIDDEN" @@ -55,5 +60,8 @@ data class MatrixError( const val M_CONSENT_NOT_GIVEN = "M_CONSENT_NOT_GIVEN" const val RESOURCE_LIMIT_EXCEEDED = "M_RESOURCE_LIMIT_EXCEEDED" const val WRONG_ROOM_KEYS_VERSION = "M_WRONG_ROOM_KEYS_VERSION" + + // Possible value for "limit_type" + const val LIMIT_TYPE_MAU = "monthly_active_user" } } \ No newline at end of file diff --git a/vector/src/debug/res/layout/view_notification_area.xml b/vector/src/debug/res/layout/view_notification_area.xml new file mode 100644 index 00000000..8af520c2 --- /dev/null +++ b/vector/src/debug/res/layout/view_notification_area.xml @@ -0,0 +1,33 @@ + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/core/error/ResourceLimitErrorFormatter.kt b/vector/src/main/java/im/vector/riotx/core/error/ResourceLimitErrorFormatter.kt new file mode 100644 index 00000000..b57014f1 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/error/ResourceLimitErrorFormatter.kt @@ -0,0 +1,68 @@ +/* + * 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.riotx.core.error + +import android.content.Context +import android.text.Html +import androidx.annotation.StringRes +import im.vector.matrix.android.api.failure.MatrixError +import im.vector.riotx.R +import me.gujun.android.span.span + +class ResourceLimitErrorFormatter(private val context: Context) { + + // 'hard' if the logged in user has been locked out, 'soft' if they haven't + sealed class Mode(@StringRes val mauErrorRes: Int, @StringRes val defaultErrorRes: Int, @StringRes val contactRes: Int) { + // User can still send message (will be used in a near future) + object Soft : Mode(R.string.resource_limit_soft_mau, R.string.resource_limit_soft_default, R.string.resource_limit_soft_contact) + + // User cannot send message anymore + object Hard : Mode(R.string.resource_limit_hard_mau, R.string.resource_limit_hard_default, R.string.resource_limit_hard_contact) + } + + fun format(matrixError: MatrixError, + mode: Mode, + separator: CharSequence = " ", + clickable: Boolean = false): CharSequence { + val error = if (MatrixError.LIMIT_TYPE_MAU == matrixError.limitType) { + context.getString(mode.mauErrorRes) + } else { + context.getString(mode.defaultErrorRes) + } + val contact = if (clickable && matrixError.adminUri != null) { + val contactSubString = uriAsLink(matrixError.adminUri!!) + val contactFullString = context.getString(mode.contactRes, contactSubString) + Html.fromHtml(contactFullString) + } else { + val contactSubString = context.getString(R.string.resource_limit_contact_admin) + context.getString(mode.contactRes, contactSubString) + } + return span { + text = error + } + .append(separator) + .append(contact) + } + + /** + * Create a HTML link with a uri + */ + private fun uriAsLink(uri: String): String { + val contactStr = context.getString(R.string.resource_limit_contact_admin) + return "$contactStr" + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/core/platform/NotificationAreaView.kt b/vector/src/main/java/im/vector/riotx/core/platform/NotificationAreaView.kt new file mode 100644 index 00000000..6a9af149 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/platform/NotificationAreaView.kt @@ -0,0 +1,324 @@ +/* + * 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.riotx.core.platform + +import android.content.Context +import android.graphics.Color +import android.text.SpannableString +import android.text.TextPaint +import android.text.TextUtils +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.text.bold +import butterknife.BindView +import butterknife.ButterKnife +import im.vector.matrix.android.api.failure.MatrixError +import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan +import im.vector.matrix.android.api.permalinks.PermalinkFactory +import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent +import im.vector.riotx.R +import im.vector.riotx.core.error.ResourceLimitErrorFormatter +import im.vector.riotx.features.themes.ThemeUtils +import me.gujun.android.span.addSpan +import me.gujun.android.span.span +import me.saket.bettermovementmethod.BetterLinkMovementMethod +import timber.log.Timber + +/** + * The view used to show some information about the room + * It does have a unique render method + */ +class NotificationAreaView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RelativeLayout(context, attrs, defStyleAttr) { + + @BindView(R.id.room_notification_icon) + lateinit var imageView: ImageView + @BindView(R.id.room_notification_message) + lateinit var messageView: TextView + + var delegate: Delegate? = null + private var state: State = State.Initial + + init { + setupView() + } + + /** + * This methods is responsible for rendering the view according to the newState + * + * @param newState the newState representing the view + */ + fun render(newState: State) { + if (newState == state) { + Timber.d("State unchanged") + return + } + Timber.d("Rendering $newState") + cleanUp() + state = newState + when (newState) { + is State.Default -> renderDefault() + is State.Hidden -> renderHidden() + is State.Tombstone -> renderTombstone(newState) + is State.ResourceLimitExceededError -> renderResourceLimitExceededError(newState) + is State.ConnectionError -> renderConnectionError() + is State.Typing -> renderTyping(newState) + is State.UnreadPreview -> renderUnreadPreview() + is State.ScrollToBottom -> renderScrollToBottom(newState) + is State.UnsentEvents -> renderUnsent(newState) + } + } + + // PRIVATE METHODS ***************************************************************************************************************************************** + + private fun setupView() { + inflate(context, R.layout.view_notification_area, this) + ButterKnife.bind(this) + } + + private fun cleanUp() { + messageView.setOnClickListener(null) + imageView.setOnClickListener(null) + setBackgroundColor(Color.TRANSPARENT) + messageView.text = null + imageView.setImageResource(0) + } + + private fun renderTombstone(state: State.Tombstone) { + val roomTombstoneContent = state.tombstoneContent + val roomLink = PermalinkFactory.createPermalink(roomTombstoneContent.replacementRoom) + ?: return + + visibility = View.VISIBLE + imageView.setImageResource(R.drawable.error) + val textColorInt = ThemeUtils.getColor(context, R.attr.vctr_message_text_color) + val message = span { + +resources.getString(R.string.room_tombstone_versioned_description) + +"\n" + span(resources.getString(R.string.room_tombstone_continuation_link)) { + textDecorationLine = "underline" + onClick = { delegate?.onUrlClicked(roomLink) } + } + } + messageView.movementMethod = BetterLinkMovementMethod.getInstance() + messageView.text = message + } + + private fun renderResourceLimitExceededError(state: State.ResourceLimitExceededError) { + visibility = View.VISIBLE + val resourceLimitErrorFormatter = ResourceLimitErrorFormatter(context) + val formatterMode: ResourceLimitErrorFormatter.Mode + val backgroundColor: Int + if (state.isSoft) { + backgroundColor = R.color.soft_resource_limit_exceeded + formatterMode = ResourceLimitErrorFormatter.Mode.Soft + } else { + backgroundColor = R.color.hard_resource_limit_exceeded + formatterMode = ResourceLimitErrorFormatter.Mode.Hard + } + val message = resourceLimitErrorFormatter.format(state.matrixError, formatterMode, clickable = true) + messageView.setTextColor(Color.WHITE) + messageView.text = message + messageView.movementMethod = LinkMovementMethod.getInstance() + messageView.setLinkTextColor(Color.WHITE) + setBackgroundColor(ContextCompat.getColor(context, backgroundColor)) + } + + private fun renderConnectionError() { + visibility = View.VISIBLE + imageView.setImageResource(R.drawable.error) + messageView.setTextColor(ContextCompat.getColor(context, R.color.vector_fuchsia_color)) + messageView.text = SpannableString(resources.getString(R.string.room_offline_notification)) + } + + private fun renderTyping(state: State.Typing) { + visibility = View.VISIBLE + imageView.setImageResource(R.drawable.vector_typing) + messageView.text = SpannableString(state.message) + messageView.setTextColor(ThemeUtils.getColor(context, R.attr.vctr_room_notification_text_color)) + } + + private fun renderUnreadPreview() { + visibility = View.VISIBLE + imageView.setImageResource(R.drawable.scrolldown) + messageView.setTextColor(ThemeUtils.getColor(context, R.attr.vctr_room_notification_text_color)) + imageView.setOnClickListener { delegate?.closeScreen() } + } + + private fun renderScrollToBottom(state: State.ScrollToBottom) { + visibility = View.VISIBLE + if (state.unreadCount > 0) { + imageView.setImageResource(R.drawable.newmessages) + messageView.setTextColor(ContextCompat.getColor(context, R.color.vector_fuchsia_color)) + messageView.text = SpannableString(resources.getQuantityString(R.plurals.room_new_messages_notification, state.unreadCount, state.unreadCount)) + } else { + imageView.setImageResource(R.drawable.scrolldown) + messageView.setTextColor(ThemeUtils.getColor(context, R.attr.vctr_room_notification_text_color)) + if (!TextUtils.isEmpty(state.message)) { + messageView.text = SpannableString(state.message) + } + } + messageView.setOnClickListener { delegate?.jumpToBottom() } + imageView.setOnClickListener { delegate?.jumpToBottom() } + } + + private fun renderUnsent(state: State.UnsentEvents) { + visibility = View.VISIBLE + imageView.setImageResource(R.drawable.error) + val cancelAll = resources.getString(R.string.room_prompt_cancel) + val resendAll = resources.getString(R.string.room_prompt_resend) + val messageRes = if (state.hasUnknownDeviceEvents) R.string.room_unknown_devices_messages_notification else R.string.room_unsent_messages_notification + val message = context.getString(messageRes, resendAll, cancelAll) + val cancelAllPos = message.indexOf(cancelAll) + val resendAllPos = message.indexOf(resendAll) + val spannableString = SpannableString(message) + // cancelAllPos should always be > 0 but a GA crash reported here + if (cancelAllPos >= 0) { + spannableString.setSpan(CancelAllClickableSpan(), cancelAllPos, cancelAllPos + cancelAll.length, 0) + } + + // resendAllPos should always be > 0 but a GA crash reported here + if (resendAllPos >= 0) { + spannableString.setSpan(ResendAllClickableSpan(), resendAllPos, resendAllPos + resendAll.length, 0) + } + messageView.movementMethod = LinkMovementMethod.getInstance() + messageView.setTextColor(ContextCompat.getColor(context, R.color.vector_fuchsia_color)) + messageView.text = spannableString + } + + private fun renderDefault() { + visibility = View.GONE + } + + private fun renderHidden() { + visibility = View.GONE + } + + /** + * Track the cancel all click. + */ + private inner class CancelAllClickableSpan : ClickableSpan() { + override fun onClick(widget: View) { + delegate?.deleteUnsentEvents() + render(state) + } + + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.color = ContextCompat.getColor(context, R.color.vector_fuchsia_color) + ds.bgColor = 0 + ds.isUnderlineText = true + } + } + + /** + * Track the resend all click. + */ + private inner class ResendAllClickableSpan : ClickableSpan() { + override fun onClick(widget: View) { + delegate?.resendUnsentEvents() + render(state) + } + + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.color = ContextCompat.getColor(context, R.color.vector_fuchsia_color) + ds.bgColor = 0 + ds.isUnderlineText = true + } + } + + /** + * The state representing the view + * It can take one state at a time + * Priority of state is managed in {@link VectorRoomActivity.refreshNotificationsArea() } + */ + sealed class State { + + // Not yet rendered + object Initial : State() + + // View will be Invisible + object Default : State() + + // View will be Gone + object Hidden : State() + + // Resource limit exceeded error will be displayed (only hard for the moment) + data class ResourceLimitExceededError(val isSoft: Boolean, val matrixError: MatrixError) : State() + + // Server connection is lost + object ConnectionError : State() + + // The room is dead + data class Tombstone(val tombstoneContent: RoomTombstoneContent) : State() + + // Somebody is typing + data class Typing(val message: String) : State() + + // Some new messages are unread in preview + object UnreadPreview : State() + + // Some new messages are unread (grey or red) + data class ScrollToBottom(val unreadCount: Int, val message: String? = null) : State() + + // Some event has been unsent + data class UnsentEvents(val hasUndeliverableEvents: Boolean, val hasUnknownDeviceEvents: Boolean) : State() + } + + /** + * An interface to delegate some actions to another object + */ + interface Delegate { + fun onUrlClicked(url: String) + fun resendUnsentEvents() + fun deleteUnsentEvents() + fun closeScreen() + fun jumpToBottom() + } + + companion object { + /** + * Preference key. + */ + private const val SHOW_INFO_AREA_KEY = "SETTINGS_SHOW_INFO_AREA_KEY" + + /** + * Always show the info area. + */ + private const val SHOW_INFO_AREA_VALUE_ALWAYS = "always" + + /** + * Show the info area when it has messages or errors. + */ + private const val SHOW_INFO_AREA_VALUE_MESSAGES_AND_ERRORS = "messages_and_errors" + + /** + * Show the info area only when it has errors. + */ + private const val SHOW_INFO_AREA_VALUE_ONLY_ERRORS = "only_errors" + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 204dfb5d..ab766e14 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -76,6 +76,7 @@ import im.vector.riotx.core.extensions.observeEvent import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.core.files.addEntryToDownloadManager import im.vector.riotx.core.glide.GlideApp +import im.vector.riotx.core.platform.NotificationAreaView import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.utils.* import im.vector.riotx.features.autocomplete.command.AutocompleteCommandPresenter @@ -203,6 +204,7 @@ class RoomDetailFragment : setupComposer() setupAttachmentButton() setupInviteView() + setupNotificationView() roomDetailViewModel.subscribe { renderState(it) } textComposerViewModel.subscribe { renderTextComposerState(it) } roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) } @@ -239,6 +241,36 @@ class RoomDetailFragment : } } + private fun setupNotificationView() { + notificationAreaView.delegate = object : NotificationAreaView.Delegate { + + override fun onUrlClicked(url: String) { + permalinkHandler.launch(requireActivity(), url, object : NavigateToRoomInterceptor { + override fun navToRoom(roomId: String, eventId: String?): Boolean { + requireActivity().finish() + return false + } + }) + } + + override fun resendUnsentEvents() { + TODO("not implemented") + } + + override fun deleteUnsentEvents() { + TODO("not implemented") + } + + override fun closeScreen() { + TODO("not implemented") + } + + override fun jumpToBottom() { + TODO("not implemented") + } + } + } + private fun exitSpecialMode() { commandAutocompletePolicy.enabled = true composerLayout.collapse() @@ -259,17 +291,17 @@ class RoomDetailFragment : if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) { val parser = Parser.builder().build() val document = parser.parse(messageContent.formattedBody - ?: messageContent.body) + ?: messageContent.body) formattedBody = eventHtmlRenderer.render(document) } composerLayout.composerRelatedMessageContent.text = formattedBody - ?: nonFormattedBody + ?: nonFormattedBody composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "") composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) avatarRenderer.render(event.senderAvatar, event.root.senderId - ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) + ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length) composerLayout.expand { @@ -298,9 +330,9 @@ class RoomDetailFragment : REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data) REACTION_SELECT_REQUEST_CODE -> { val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID) - ?: return + ?: return val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT) - ?: return + ?: return //TODO check if already reacted with that? roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId)) } @@ -335,26 +367,26 @@ class RoomDetailFragment : if (VectorPreferences.swipeToReplyIsEnabled(requireContext())) { val swipeCallback = RoomMessageTouchHelperCallback(requireContext(), - R.drawable.ic_reply, - object : RoomMessageTouchHelperCallback.QuickReplayHandler { - override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { - (model as? AbsMessageItem)?.informationData?.let { - val eventId = it.eventId - roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) - } - } + R.drawable.ic_reply, + object : RoomMessageTouchHelperCallback.QuickReplayHandler { + override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { + (model as? AbsMessageItem)?.informationData?.let { + val eventId = it.eventId + roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) + } + } - override fun canSwipeModel(model: EpoxyModel<*>): Boolean { - return when (model) { - is MessageFileItem, - is MessageImageVideoItem, - is MessageTextItem -> { - return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED - } - else -> false - } - } - }) + override fun canSwipeModel(model: EpoxyModel<*>): Boolean { + return when (model) { + is MessageFileItem, + is MessageImageVideoItem, + is MessageTextItem -> { + return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED + } + else -> false + } + } + }) val touchHelper = ItemTouchHelper(swipeCallback) touchHelper.attachToRecyclerView(recyclerView) } @@ -534,12 +566,14 @@ class RoomDetailFragment : } else if (state.asyncInviter.complete) { vectorBaseActivity.finish() } + if (state.tombstoneContent == null) { composerLayout.visibility = View.VISIBLE composerLayout.setRoomEncrypted(state.isEncrypted) + notificationAreaView.render(NotificationAreaView.State.Hidden) } else { composerLayout.visibility = View.GONE - showSnackWithMessage("TOMBSTONED", duration = Snackbar.LENGTH_INDEFINITE) + notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneContent)) } } @@ -636,7 +670,7 @@ class RoomDetailFragment : val intent = ImageMediaViewerActivity.newIntent(vectorBaseActivity, mediaData, ViewCompat.getTransitionName(view)) val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), view, ViewCompat.getTransitionName(view) - ?: "").toBundle() + ?: "").toBundle() startActivity(intent, bundle) } @@ -716,7 +750,17 @@ class RoomDetailFragment : ViewEditHistoryBottomSheet.newInstance(roomDetailArgs.roomId, informationData) .show(requireActivity().supportFragmentManager, "DISPLAY_EDITS") } -// AutocompleteUserPresenter.Callback + + override fun onRoomCreateLinkClicked(url: String) { + permalinkHandler.launch(requireContext(), url, object : NavigateToRoomInterceptor { + override fun navToRoom(roomId: String, eventId: String?): Boolean { + requireActivity().finish() + return false + } + }) + } + + // AutocompleteUserPresenter.Callback override fun onQueryUsers(query: CharSequence?) { textComposerViewModel.process(TextComposerActions.QueryUsers(query)) @@ -730,7 +774,7 @@ class RoomDetailFragment : } MessageMenuViewModel.ACTION_VIEW_REACTIONS -> { val messageInformationData = actionData.data as? MessageInformationData - ?: return + ?: return ViewReactionBottomSheet.newInstance(roomDetailArgs.roomId, messageInformationData) .show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS") } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index 0b78f815..e8268bac 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -54,6 +54,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim interface Callback : ReactionPillCallback, AvatarCallback, BaseCallback, UrlClickCallback { fun onEventVisible(event: TimelineEvent) + fun onRoomCreateLinkClicked(url: String) fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View) fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) @@ -158,7 +159,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim synchronized(modelCache) { for (i in 0 until modelCache.size) { if (modelCache[i]?.eventId == eventIdToHighlight - || modelCache[i]?.eventId == this.eventIdToHighlight) { + || modelCache[i]?.eventId == this.eventIdToHighlight) { modelCache[i] = null } } @@ -219,8 +220,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim // Should be build if not cached or if cached but contains mergedHeader or formattedDay // We then are sure we always have items up to date. if (modelCache[position] == null - || modelCache[position]?.mergedHeaderModel != null - || modelCache[position]?.formattedDayModel != null) { + || modelCache[position]?.mergedHeaderModel != null + || modelCache[position]?.formattedDayModel != null) { modelCache[position] = buildItemModels(position, currentSnapshot) } } @@ -294,7 +295,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim // => handle case where paginating from mergeable events and we get more val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull() val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey) - ?: true + ?: true val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState } if (isCollapsed) { collapsedEventIds.addAll(mergedEventIds) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt index 21fe85d7..e32e2746 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt @@ -18,7 +18,6 @@ package im.vector.riotx.features.home.room.detail.timeline.factory -import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan import im.vector.matrix.android.api.permalinks.PermalinkFactory import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent @@ -37,21 +36,16 @@ class RoomCreateItemFactory @Inject constructor(private val colorProvider: Color fun create(event: TimelineEvent, callback: TimelineEventController.Callback?): RoomCreateItem? { val createRoomContent = event.root.getClearContent().toModel() - ?: return null + ?: return null val predecessor = createRoomContent.predecessor ?: return null val roomLink = PermalinkFactory.createPermalink(predecessor.roomId) ?: return null - val urlSpan = MatrixPermalinkSpan(roomLink, object : MatrixPermalinkSpan.Callback { - override fun onUrlClicked(url: String) { - callback?.onUrlClicked(roomLink) - } - }) - val textColorInt = colorProvider.getColor(R.color.riot_primary_text_color_light) val text = span { - text = stringProvider.getString(R.string.room_tombstone_continuation_description) - append("\n") - append( - stringProvider.getString(R.string.room_tombstone_predecessor_link) - ) + +stringProvider.getString(R.string.room_tombstone_continuation_description) + +"\n" + span(stringProvider.getString(R.string.room_tombstone_predecessor_link)) { + textDecorationLine = "underline" + onClick = { callback?.onRoomCreateLinkClicked(roomLink) } + } } return RoomCreateItem_() .text(text) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/RoomCreateItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/RoomCreateItem.kt index 117f4bd2..3e5ef30d 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/RoomCreateItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/RoomCreateItem.kt @@ -21,10 +21,10 @@ package im.vector.riotx.features.home.room.detail.timeline.item import android.widget.TextView import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass -import com.airbnb.epoxy.EpoxyModelWithHolder import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyModel +import me.saket.bettermovementmethod.BetterLinkMovementMethod @EpoxyModelClass(layout = R.layout.item_timeline_event_create) abstract class RoomCreateItem : VectorEpoxyModel() { @@ -32,6 +32,7 @@ abstract class RoomCreateItem : VectorEpoxyModel() { @EpoxyAttribute lateinit var text: CharSequence override fun bind(holder: Holder) { + holder.description.movementMethod = BetterLinkMovementMethod.getInstance() holder.description.text = text } diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml index f8cec433..ae38b510 100644 --- a/vector/src/main/res/layout/fragment_room_detail.xml +++ b/vector/src/main/res/layout/fragment_room_detail.xml @@ -74,12 +74,18 @@ android:id="@+id/recyclerView" android:layout_width="0dp" android:layout_height="0dp" - app:layout_constraintBottom_toTopOf="@+id/composerLayout" + app:layout_constraintBottom_toTopOf="@+id/recyclerViewBarrier" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/roomToolbar" tools:listitem="@layout/item_timeline_event_base" /> + + +