Merge branch 'feature/home_state_issues' into develop

This commit is contained in:
ganfra 2019-03-04 17:00:50 +01:00
commit a146fc43e4
28 changed files with 590 additions and 302 deletions

View File

@ -3,9 +3,14 @@
<words>
<w>connectable</w>
<w>coroutine</w>
<w>linkify</w>
<w>markon</w>
<w>markwon</w>
<w>merlins</w>
<w>moshi</w>
<w>persistor</w>
<w>restorable</w>
<w>restorables</w>
<w>synchronizer</w>
<w>untimelined</w>
</words>

View File

@ -23,6 +23,7 @@ import com.facebook.stetho.Stetho
import com.jakewharton.threetenabp.AndroidThreeTen
import im.vector.matrix.android.BuildConfig
import im.vector.riotredesign.core.di.AppModule
import im.vector.riotredesign.features.home.HomeModule
import org.koin.log.EmptyLogger
import org.koin.standalone.StandAloneContext.startKoin
import timber.log.Timber
@ -37,7 +38,9 @@ class Riot : Application() {
Stetho.initializeWithDefaults(this)
}
AndroidThreeTen.init(this)
startKoin(listOf(AppModule(this).definition), logger = EmptyLogger())
val appModule = AppModule(applicationContext).definition
val homeModule = HomeModule().definition
startKoin(listOf(appModule, homeModule), logger = EmptyLogger())
}

override fun attachBaseContext(base: Context) {

View File

@ -18,10 +18,14 @@ package im.vector.riotredesign.core.di

import android.content.Context
import android.content.Context.MODE_PRIVATE
import im.vector.matrix.android.api.Matrix
import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.core.resources.LocaleProvider
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.group.SelectedGroupStore
import im.vector.riotredesign.features.home.room.VisibleRoomStore
import im.vector.riotredesign.features.home.room.list.RoomSelectionRepository
import im.vector.riotredesign.features.home.room.list.RoomSummaryComparator
import org.koin.dsl.module.module

class AppModule(private val context: Context) {
@ -48,5 +52,22 @@ class AppModule(private val context: Context) {
RoomSelectionRepository(get())
}

single {
SelectedGroupStore()
}

single {
VisibleRoomStore()
}

single {
RoomSummaryComparator()
}

factory {
Matrix.getInstance().currentSession
}


}
}

View File

@ -0,0 +1,48 @@
/*
* 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.riotredesign.core.epoxy

import android.os.Bundle
import android.os.Parcelable
import androidx.recyclerview.widget.RecyclerView
import im.vector.riotredesign.core.platform.DefaultListUpdateCallback
import im.vector.riotredesign.core.platform.Restorable
import java.util.concurrent.atomic.AtomicReference

private const val LAYOUT_MANAGER_STATE = "LAYOUT_MANAGER_STATE"

class LayoutManagerStateRestorer(private val layoutManager: RecyclerView.LayoutManager) : Restorable, DefaultListUpdateCallback {

private var layoutManagerState = AtomicReference<Parcelable?>()

override fun onSaveInstanceState(outState: Bundle) {
val layoutManagerState = layoutManager.onSaveInstanceState()
outState.putParcelable(LAYOUT_MANAGER_STATE, layoutManagerState)
}

override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
val parcelable = savedInstanceState?.getParcelable<Parcelable>(LAYOUT_MANAGER_STATE)
layoutManagerState.set(parcelable)
}

override fun onInserted(position: Int, count: Int) {
layoutManagerState.getAndSet(null)?.also {
layoutManager.onRestoreInstanceState(it)
}
}

}

View File

@ -0,0 +1,27 @@
/*
* 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.riotredesign.core.platform

import android.os.Bundle

interface Restorable {

fun onSaveInstanceState(outState: Bundle)

fun onRestoreInstanceState(savedInstanceState: Bundle?)

}

View File

@ -16,13 +16,34 @@

package im.vector.riotredesign.core.platform

import android.os.Bundle
import androidx.annotation.MainThread
import com.airbnb.mvrx.BaseMvRxActivity
import com.bumptech.glide.util.Util
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable

abstract class RiotActivity : BaseMvRxActivity() {

private val uiDisposables = CompositeDisposable()
private val restorables = ArrayList<Restorable>()

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
restorables.forEach { it.onSaveInstanceState(outState) }
}

override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
restorables.forEach { it.onRestoreInstanceState(savedInstanceState) }
super.onRestoreInstanceState(savedInstanceState)
}

@MainThread
protected fun <T : Restorable> T.register(): T {
Util.assertMainThread()
restorables.add(this)
return this
}

protected fun Disposable.disposeOnDestroy(): Disposable {
uiDisposables.add(this)

View File

@ -18,8 +18,10 @@ package im.vector.riotredesign.core.platform

import android.os.Bundle
import android.os.Parcelable
import androidx.annotation.MainThread
import com.airbnb.mvrx.BaseMvRxFragment
import com.airbnb.mvrx.MvRx
import com.bumptech.glide.util.Util.assertMainThread

abstract class RiotFragment : BaseMvRxFragment(), OnBackPressed {

@ -27,6 +29,18 @@ abstract class RiotFragment : BaseMvRxFragment(), OnBackPressed {
activity as RiotActivity
}

private val restorables = ArrayList<Restorable>()

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
restorables.forEach { it.onSaveInstanceState(outState) }
}

override fun onViewStateRestored(savedInstanceState: Bundle?) {
restorables.forEach { it.onRestoreInstanceState(savedInstanceState) }
super.onViewStateRestored(savedInstanceState)
}

override fun onBackPressed(): Boolean {
return false
}
@ -39,4 +53,11 @@ abstract class RiotFragment : BaseMvRxFragment(), OnBackPressed {
arguments = args?.let { Bundle().apply { putParcelable(MvRx.KEY_ARG, it) } }
}

@MainThread
protected fun <T : Restorable> T.register(): T {
assertMainThread()
restorables.add(this)
return this
}

}

View File

@ -19,74 +19,98 @@ package im.vector.riotredesign.features.home
import android.content.Context
import android.graphics.drawable.Drawable
import android.widget.ImageView
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat
import com.amulyakhare.textdrawable.TextDrawable
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.DrawableImageViewTarget
import com.bumptech.glide.request.target.Target
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.MatrixPatterns
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.R
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.core.glide.GlideRequest
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import im.vector.riotredesign.core.glide.GlideRequests

/**
* This helper centralise ways to retrieve avatar into ImageView or even generic Target<Drawable>
*/
object AvatarRenderer {

@UiThread
fun render(roomMember: RoomMember, imageView: ImageView) {
render(roomMember.avatarUrl, roomMember.displayName, imageView)
}

@UiThread
fun render(roomSummary: RoomSummary, imageView: ImageView) {
render(roomSummary.avatarUrl, roomSummary.displayName, imageView)
}

@UiThread
fun render(avatarUrl: String?, name: String?, imageView: ImageView) {
render(imageView.context, GlideApp.with(imageView), avatarUrl, name, imageView.height, DrawableImageViewTarget(imageView))
}

@UiThread
fun render(context: Context,
glideRequest: GlideRequests,
avatarUrl: String?,
name: String?,
size: Int,
target: Target<Drawable>) {
if (name.isNullOrEmpty()) {
return
}
val placeholder = buildPlaceholderDrawable(imageView.context, name)
buildGlideRequest(imageView.context, avatarUrl)
val placeholder = buildPlaceholderDrawable(context, name)
buildGlideRequest(glideRequest, avatarUrl, size)
.placeholder(placeholder)
.into(imageView)
.into(target)
}

fun load(context: Context, avatarUrl: String?, name: String?, size: Int, callback: Callback) {
if (name.isNullOrEmpty()) {
return
}
val request = buildGlideRequest(context, avatarUrl)
GlobalScope.launch {
val placeholder = buildPlaceholderDrawable(context, name)
callback.onDrawableUpdated(placeholder)
try {
val drawable = request.submit(size, size).get()
callback.onDrawableUpdated(drawable)
} catch (exception: Exception) {
callback.onDrawableUpdated(placeholder)
}
@WorkerThread
fun getCachedOrPlaceholder(context: Context,
glideRequest: GlideRequests,
avatarUrl: String?,
text: String,
size: Int): Drawable {
val future = buildGlideRequest(glideRequest, avatarUrl, size).onlyRetrieveFromCache(true).submit()
return try {
future.get()
} catch (exception: Exception) {
buildPlaceholderDrawable(context, text)
}
}

private fun buildGlideRequest(context: Context, avatarUrl: String?): GlideRequest<Drawable> {
val resolvedUrl = Matrix.getInstance().currentSession.contentUrlResolver().resolveFullSize(avatarUrl)
return GlideApp
.with(context)
// PRIVATE API *********************************************************************************

private fun buildGlideRequest(glideRequest: GlideRequests, avatarUrl: String?, size: Int): GlideRequest<Drawable> {
val resolvedUrl = Matrix.getInstance().currentSession
.contentUrlResolver()
.resolveThumbnail(avatarUrl, size, size, ContentUrlResolver.ThumbnailMethod.SCALE)

return glideRequest
.load(resolvedUrl)
.apply(RequestOptions.circleCropTransform())
.diskCacheStrategy(DiskCacheStrategy.DATA)
}

private fun buildPlaceholderDrawable(context: Context, name: String): Drawable {
private fun buildPlaceholderDrawable(context: Context, text: String): Drawable {
val avatarColor = ContextCompat.getColor(context, R.color.pale_teal)
val isNameUserId = MatrixPatterns.isUserId(name)
val firstLetterIndex = if (isNameUserId) 1 else 0
val firstLetter = name[firstLetterIndex].toString().toUpperCase()
return TextDrawable.builder().buildRound(firstLetter, avatarColor)
}
return if (text.isEmpty()) {
TextDrawable.builder().buildRound("", avatarColor)
} else {
val isUserId = MatrixPatterns.isUserId(text)
val firstLetterIndex = if (isUserId) 1 else 0
val firstLetter = text[firstLetterIndex].toString().toUpperCase()
TextDrawable.builder().buildRound(firstLetter, avatarColor)
}

interface Callback {
fun onDrawableUpdated(drawable: Drawable?)
}

}

View File

@ -37,12 +37,12 @@ import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.features.home.room.detail.LoadingRoomDetailFragment
import kotlinx.android.synthetic.main.activity_home.*
import org.koin.android.ext.android.inject
import org.koin.standalone.StandAloneContext.loadKoinModules
import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope


class HomeActivity : RiotActivity(), ToolbarConfigurable {


private val homeActivityViewModel: HomeActivityViewModel by viewModel()
private val homeNavigator by inject<HomeNavigator>()

@ -53,10 +53,10 @@ class HomeActivity : RiotActivity(), ToolbarConfigurable {
}

override fun onCreate(savedInstanceState: Bundle?) {
loadKoinModules(listOf(HomeModule(this).definition))
homeNavigator.activity = this
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_home)
bindScope(getOrCreateScope(HomeModule.HOME_SCOPE))
homeNavigator.activity = this
drawerLayout.addDrawerListener(drawerListener)
if (savedInstanceState == null) {
val homeDrawerFragment = HomeDrawerFragment.newInstance()

View File

@ -16,9 +16,9 @@

package im.vector.riotredesign.features.home

import im.vector.matrix.android.api.Matrix
import im.vector.riotredesign.features.home.group.SelectedGroupStore
import im.vector.riotredesign.features.home.room.VisibleRoomStore
import androidx.fragment.app.Fragment
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.features.home.group.GroupSummaryController
import im.vector.riotredesign.features.home.room.detail.timeline.CallItemFactory
import im.vector.riotredesign.features.home.room.detail.timeline.DefaultItemFactory
import im.vector.riotredesign.features.home.room.detail.timeline.MessageItemFactory
@ -30,89 +30,56 @@ import im.vector.riotredesign.features.home.room.detail.timeline.TimelineDateFor
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineItemFactory
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.list.RoomSummaryComparator
import im.vector.riotredesign.features.home.room.list.RoomSummaryController
import im.vector.riotredesign.features.html.EventHtmlRenderer
import org.koin.dsl.module.module

class HomeModule(homeActivity: HomeActivity) {
class HomeModule {

val definition = module(override = true) {
companion object {
const val HOME_SCOPE = "HOME_SCOPE"
const val ROOM_DETAIL_SCOPE = "ROOM_DETAIL_SCOPE"
const val ROOM_LIST_SCOPE = "ROOM_LIST_SCOPE"
const val GROUP_LIST_SCOPE = "GROUP_LIST_SCOPE"
}

single {
Matrix.getInstance().currentSession
}
val definition = module {

single {
TimelineDateFormatter(get())
}
// Activity scope

single {
EventHtmlRenderer(homeActivity, get())
}

single {
MessageItemFactory(get(), get(), get(), get())
}

single {
RoomNameItemFactory(get())
}

single {
RoomTopicItemFactory(get())
}

single {
RoomMemberItemFactory(get())
}

single {
CallItemFactory(get())
}

single {
RoomHistoryVisibilityItemFactory(get())
}

single {
DefaultItemFactory()
}

single {
TimelineItemFactory(get(), get(), get(), get(), get(), get(), get())
}

single {
scope(HOME_SCOPE) {
HomeNavigator()
}

factory {
RoomSummaryController(get())
}

factory { (roomId: String) ->
TimelineEventController(roomId, get(), get(), get())
}

single {
TimelineMediaSizeProvider()
}

single {
SelectedGroupStore()
}

single {
VisibleRoomStore()
}

single {
scope(HOME_SCOPE) {
HomePermalinkHandler(get())
}

single {
RoomSummaryComparator()
// Fragment scopes

scope(ROOM_DETAIL_SCOPE) { (fragment: Fragment) ->
val eventHtmlRenderer = EventHtmlRenderer(GlideApp.with(fragment), fragment.requireContext(), get())
val timelineDateFormatter = TimelineDateFormatter(get())
val timelineMediaSizeProvider = TimelineMediaSizeProvider()
val messageItemFactory = MessageItemFactory(get(), timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer)

val timelineItemFactory = TimelineItemFactory(messageItemFactory = messageItemFactory,
roomNameItemFactory = RoomNameItemFactory(get()),
roomTopicItemFactory = RoomTopicItemFactory(get()),
roomMemberItemFactory = RoomMemberItemFactory(get()),
roomHistoryVisibilityItemFactory = RoomHistoryVisibilityItemFactory(get()),
callItemFactory = CallItemFactory(get()),
defaultItemFactory = DefaultItemFactory()
)
TimelineEventController(timelineDateFormatter, timelineItemFactory, timelineMediaSizeProvider)
}

scope(ROOM_LIST_SCOPE) {
RoomSummaryController(get())
}

scope(GROUP_LIST_SCOPE) {
GroupSummaryController()
}



View File

@ -36,9 +36,6 @@ class HomeNavigator {
eventId: String?,
addToBackstack: Boolean = false) {
Timber.v("Open room detail $roomId - $eventId - $addToBackstack")
if (!addToBackstack && isRoot(roomId)) {
return
}
activity?.let {
val args = RoomDetailArgs(roomId, eventId)
val roomDetailFragment = RoomDetailFragment.newInstance(args)

View File

@ -27,7 +27,11 @@ import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.RiotFragment
import im.vector.riotredesign.core.platform.StateView
import im.vector.riotredesign.features.home.HomeModule
import kotlinx.android.synthetic.main.fragment_group_list.*
import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope

class GroupListFragment : RiotFragment(), GroupSummaryController.Callback {

@ -38,8 +42,7 @@ class GroupListFragment : RiotFragment(), GroupSummaryController.Callback {
}

private val viewModel: GroupListViewModel by fragmentViewModel()

private lateinit var groupController: GroupSummaryController
private val groupController by inject<GroupSummaryController>()

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_group_list, container, false)
@ -47,7 +50,8 @@ class GroupListFragment : RiotFragment(), GroupSummaryController.Callback {

override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
groupController = GroupSummaryController(this)
bindScope(getOrCreateScope(HomeModule.GROUP_LIST_SCOPE))
groupController.callback = this
stateView.contentView = epoxyRecyclerView
epoxyRecyclerView.setController(groupController)
viewModel.subscribe { renderState(it) }
@ -56,7 +60,7 @@ class GroupListFragment : RiotFragment(), GroupSummaryController.Callback {
private fun renderState(state: GroupListViewState) {
when (state.asyncGroups) {
is Incomplete -> renderLoading()
is Success -> renderSuccess(state)
is Success -> renderSuccess(state)
}
}


View File

@ -19,7 +19,6 @@ package im.vector.riotredesign.features.home.group
import arrow.core.Option
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.RiotViewModel
@ -34,7 +33,7 @@ class GroupListViewModel(initialState: GroupListViewState,

@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: GroupListViewState): GroupListViewModel? {
val currentSession = Matrix.getInstance().currentSession
val currentSession = viewModelContext.activity.get<Session>()
val selectedGroupHolder = viewModelContext.activity.get<SelectedGroupStore>()
return GroupListViewModel(state, selectedGroupHolder, currentSession)
}

View File

@ -19,8 +19,9 @@ package im.vector.riotredesign.features.home.group
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.matrix.android.api.session.group.model.GroupSummary

class GroupSummaryController(private val callback: Callback? = null
) : TypedEpoxyController<GroupListViewState>() {
class GroupSummaryController : TypedEpoxyController<GroupListViewState>() {

var callback: Callback? = null

override fun buildModels(viewState: GroupListViewState) {
buildGroupModels(viewState.asyncGroups(), viewState.selectedGroup)

View File

@ -25,18 +25,21 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotredesign.core.platform.RiotFragment
import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.home.HomePermalinkHandler
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_detail.*
import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope
import org.koin.core.parameter.parametersOf

@Parcelize
@ -45,6 +48,7 @@ data class RoomDetailArgs(
val eventId: String? = null
) : Parcelable


class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {

companion object {
@ -57,10 +61,9 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
}

private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel()
private val roomDetailArgs: RoomDetailArgs by args()
private val timelineEventController: TimelineEventController by inject { parametersOf(this) }
private val homePermalinkHandler: HomePermalinkHandler by inject()

private val timelineEventController by inject<TimelineEventController> { parametersOf(roomDetailArgs.roomId) }
private val homePermalinkHandler by inject<HomePermalinkHandler>()
private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@ -69,6 +72,7 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {

override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
bindScope(getOrCreateScope(HomeModule.ROOM_DETAIL_SCOPE))
setupRecyclerView()
setupToolbar()
setupSendButton()
@ -80,6 +84,8 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
roomDetailViewModel.process(RoomDetailActions.IsDisplayed)
}

// PRIVATE METHODS *****************************************************************************

private fun setupToolbar() {
val parentActivity = riotActivity
if (parentActivity is ToolbarConfigurable) {
@ -91,10 +97,14 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
val epoxyVisibilityTracker = EpoxyVisibilityTracker()
epoxyVisibilityTracker.attach(recyclerView)
val layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true)
val stateRestorer = LayoutManagerStateRestorer(layoutManager).register()
scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager)
recyclerView.layoutManager = layoutManager
recyclerView.setHasFixedSize(true)
timelineEventController.addModelBuildListener { it.dispatchTo(scrollOnNewMessageCallback) }
timelineEventController.addModelBuildListener {
it.dispatchTo(stateRestorer)
it.dispatchTo(scrollOnNewMessageCallback)
}
recyclerView.setController(timelineEventController)
timelineEventController.callback = this
}

View File

@ -19,7 +19,6 @@ package im.vector.riotredesign.features.home.room.detail
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.Event
@ -46,7 +45,7 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,

@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: RoomDetailViewState): RoomDetailViewModel? {
val currentSession = Matrix.getInstance().currentSession
val currentSession = viewModelContext.activity.get<Session>()
val visibleRoomHolder = viewModelContext.activity.get<VisibleRoomStore>()
return RoomDetailViewModel(state, currentSession, visibleRoomHolder)
}

View File

@ -16,13 +16,18 @@

package im.vector.riotredesign.features.home.room.detail.timeline

import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.util.Linkify
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageEmoteContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageNoticeContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.RiotEpoxyModel
@ -146,7 +151,7 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
.informationData(informationData)
}

private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): CharSequence {
private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): Spannable {
val spannable = SpannableStringBuilder(body)
MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback {
override fun onUrlClicked(url: String) {

View File

@ -16,30 +16,54 @@

package im.vector.riotredesign.features.home.room.detail.timeline

import android.text.Spannable
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.PrecomputedTextCompat
import androidx.core.widget.TextViewCompat
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.riotredesign.R
import im.vector.riotredesign.features.html.PillImageSpan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

@EpoxyModelClass(layout = R.layout.item_timeline_event_text_message)
abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {

@EpoxyAttribute var message: CharSequence? = null
@EpoxyAttribute var message: Spannable? = null
@EpoxyAttribute override lateinit var informationData: MessageInformationData

override fun bind(holder: Holder) {
super.bind(holder)
holder.messageView.text = message
MatrixLinkify.addLinkMovementMethod(holder.messageView)
val textFuture = PrecomputedTextCompat.getTextFuture(message ?: "",
TextViewCompat.getTextMetricsParams(holder.messageView),
null)
holder.messageView.setTextFuture(textFuture)
findPillsAndProcess { it.bind(holder.messageView) }
}

private fun findPillsAndProcess(processBlock: (span: PillImageSpan) -> Unit) {
GlobalScope.launch(Dispatchers.Main) {
val pillImageSpans: Array<PillImageSpan>? = withContext(Dispatchers.IO) {
message?.let { spannable ->
spannable.getSpans(0, spannable.length, PillImageSpan::class.java)
}
}
pillImageSpans?.forEach { processBlock(it) }
}
}

class Holder : AbsMessageItem.Holder() {
override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
override val memberNameView by bind<TextView>(R.id.messageMemberNameView)
override val timeView by bind<TextView>(R.id.messageTimeView)
val messageView by bind<TextView>(R.id.messageTextView)
val messageView by bind<AppCompatTextView>(R.id.messageTextView)
}



View File

@ -29,8 +29,7 @@ import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.paging.PagedListEpoxyController

class TimelineEventController(private val roomId: String,
private val dateFormatter: TimelineDateFormatter,
class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
private val timelineItemFactory: TimelineItemFactory,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider
) : PagedListEpoxyController<TimelineEvent>(
@ -82,7 +81,7 @@ class TimelineEventController(private val roomId: String,
}
if (addDaySeparator) {
val formattedDay = dateFormatter.formatMessageDay(date)
val daySeparatorItem = DaySeparatorItem_().formattedDay(formattedDay).id(roomId + formattedDay)
val daySeparatorItem = DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay)
epoxyModels.add(daySeparatorItem)
}
return epoxyModels
@ -90,13 +89,13 @@ class TimelineEventController(private val roomId: String,

override fun addModels(models: List<EpoxyModel<*>>) {
LoadingItemModel_()
.id(roomId + "forward_loading_item")
.id("forward_loading_item")
.addIf(isLoadingForward, this)

super.add(models)

LoadingItemModel_()
.id(roomId + "backward_loading_item")
.id("backward_loading_item")
.addIf(!hasReachedEnd, this)
}


View File

@ -22,8 +22,8 @@ sealed class RoomListActions {

data class SelectRoom(val roomSummary: RoomSummary) : RoomListActions()

object RoomDisplayed : RoomListActions()

data class FilterRooms(val roomName: CharSequence? = null) : RoomListActions()

data class ToggleCategory(val category: RoomCategory) : RoomListActions()

}

View File

@ -22,19 +22,25 @@ import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Incomplete
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.fragmentViewModel
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotredesign.core.extensions.observeEvent
import im.vector.riotredesign.core.extensions.setupAsSearch
import im.vector.riotredesign.core.platform.RiotFragment
import im.vector.riotredesign.core.platform.StateView
import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.home.HomeNavigator
import kotlinx.android.synthetic.main.fragment_room_list.*
import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope

class RoomListFragment : RiotFragment(), RoomSummaryController.Callback {

@ -44,9 +50,9 @@ class RoomListFragment : RiotFragment(), RoomSummaryController.Callback {
}
}

private val homeNavigator by inject<HomeNavigator>()
private val roomController by inject<RoomSummaryController>()
private val homeViewModel: RoomListViewModel by activityViewModel()
private val homeNavigator by inject<HomeNavigator>()
private val roomListViewModel: RoomListViewModel by fragmentViewModel()

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_room_list, container, false)
@ -54,11 +60,36 @@ class RoomListFragment : RiotFragment(), RoomSummaryController.Callback {

override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
bindScope(getOrCreateScope(HomeModule.ROOM_LIST_SCOPE))
setupRecyclerView()
setupFilterView()
roomListViewModel.subscribe { renderState(it) }
roomListViewModel.openRoomLiveData.observeEvent(this) {
homeNavigator.openRoomDetail(it, null)
}
}

private fun setupRecyclerView() {
val layoutManager = LinearLayoutManager(context)
val stateRestorer = LayoutManagerStateRestorer(layoutManager).register()
epoxyRecyclerView.layoutManager = layoutManager
roomController.callback = this
roomController.addModelBuildListener { it.dispatchTo(stateRestorer) }
stateView.contentView = epoxyRecyclerView
epoxyRecyclerView.setController(roomController)
setupFilterView()
homeViewModel.subscribe { renderState(it) }
}

private fun setupFilterView() {
filterRoomView.setupAsSearch()
filterRoomView.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = Unit

override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit

override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
roomListViewModel.accept(RoomListActions.FilterRooms(s))
}
})
}

private fun renderState(state: RoomListViewState) {
@ -90,24 +121,13 @@ class RoomListFragment : RiotFragment(), RoomSummaryController.Callback {
stateView.state = StateView.State.Error(message)
}

private fun setupFilterView() {
filterRoomView.setupAsSearch()
filterRoomView.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = Unit

override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit

override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
homeViewModel.accept(RoomListActions.FilterRooms(s))
}
})
}

// RoomSummaryController.Callback **************************************************************

override fun onRoomSelected(room: RoomSummary) {
homeViewModel.accept(RoomListActions.SelectRoom(room))
homeNavigator.openRoomDetail(room.roomId, null)
roomListViewModel.accept(RoomListActions.SelectRoom(room))
}

override fun onToggleRoomCategory(roomCategory: RoomCategory) {
roomListViewModel.accept(RoomListActions.ToggleCategory(roomCategory))
}
}

View File

@ -16,22 +16,23 @@

package im.vector.riotredesign.features.home.room.list

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import arrow.core.Option
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.tag.RoomTag
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.RiotViewModel
import im.vector.riotredesign.core.utils.LiveEvent
import im.vector.riotredesign.features.home.group.SelectedGroupStore
import im.vector.riotredesign.features.home.room.VisibleRoomStore
import io.reactivex.Observable
import io.reactivex.functions.Function3
import io.reactivex.rxkotlin.subscribeBy
import org.koin.android.ext.android.get
import java.util.concurrent.TimeUnit

@ -49,7 +50,7 @@ class RoomListViewModel(initialState: RoomListViewState,

@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: RoomListViewState): RoomListViewModel? {
val currentSession = Matrix.getInstance().currentSession
val currentSession = viewModelContext.activity.get<Session>()
val roomSelectionRepository = viewModelContext.activity.get<RoomSelectionRepository>()
val selectedGroupHolder = viewModelContext.activity.get<SelectedGroupStore>()
val visibleRoomHolder = viewModelContext.activity.get<VisibleRoomStore>()
@ -61,6 +62,10 @@ class RoomListViewModel(initialState: RoomListViewState,

private val roomListFilter = BehaviorRelay.createDefault<Option<RoomListFilterName>>(Option.empty())

private val _openRoomLiveData = MutableLiveData<LiveEvent<String>>()
val openRoomLiveData: LiveData<LiveEvent<String>>
get() = _openRoomLiveData

init {
observeRoomSummaries()
observeVisibleRoom()
@ -68,16 +73,18 @@ class RoomListViewModel(initialState: RoomListViewState,

fun accept(action: RoomListActions) {
when (action) {
is RoomListActions.SelectRoom -> handleSelectRoom(action)
is RoomListActions.FilterRooms -> handleFilterRooms(action)
is RoomListActions.SelectRoom -> handleSelectRoom(action)
is RoomListActions.FilterRooms -> handleFilterRooms(action)
is RoomListActions.ToggleCategory -> handleToggleCategory(action)
}
}

// PRIVATE METHODS *****************************************************************************

private fun handleSelectRoom(action: RoomListActions.SelectRoom) = withState { state ->
if (state.selectedRoomId != action.roomSummary.roomId) {
if (state.visibleRoomId != action.roomSummary.roomId) {
roomSelectionRepository.saveLastSelectedRoom(action.roomSummary.roomId)
_openRoomLiveData.postValue(LiveEvent(action.roomSummary.roomId))
}
}

@ -86,10 +93,14 @@ class RoomListViewModel(initialState: RoomListViewState,
roomListFilter.accept(optionalFilter)
}

private fun handleToggleCategory(action: RoomListActions.ToggleCategory) = setState {
this.toggle(action.category)
}

private fun observeVisibleRoom() {
visibleRoomHolder.observe()
.doOnNext {
setState { copy(selectedRoomId = it) }
setState { copy(visibleRoomId = it) }
}
.subscribe()
.disposeOnClear()
@ -159,13 +170,13 @@ class RoomListViewModel(initialState: RoomListViewState,
}
}

return RoomSummaries(
favourites = favourites.sortedWith(roomSummaryComparator),
directRooms = directChats.sortedWith(roomSummaryComparator),
groupRooms = groupRooms.sortedWith(roomSummaryComparator),
lowPriorities = lowPriorities.sortedWith(roomSummaryComparator),
serverNotices = serverNotices.sortedWith(roomSummaryComparator)
)
return RoomSummaries().apply {
put(RoomCategory.FAVOURITE, favourites.sortedWith(roomSummaryComparator))
put(RoomCategory.DIRECT, directChats.sortedWith(roomSummaryComparator))
put(RoomCategory.GROUP, groupRooms.sortedWith(roomSummaryComparator))
put(RoomCategory.LOW_PRIORITY, lowPriorities.sortedWith(roomSummaryComparator))
put(RoomCategory.SERVER_NOTICE, serverNotices.sortedWith(roomSummaryComparator))
}
}



View File

@ -16,24 +16,54 @@

package im.vector.riotredesign.features.home.room.list

import androidx.annotation.StringRes
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.R

data class RoomListViewState(
val asyncRooms: Async<RoomSummaries> = Uninitialized,
val selectedRoomId: String? = null
) : MvRxState
val visibleRoomId: String? = null,
val isFavouriteRoomsExpanded: Boolean = true,
val isDirectRoomsExpanded: Boolean = false,
val isGroupRoomsExpanded: Boolean = false,
val isLowPriorityRoomsExpanded: Boolean = false,
val isServerNoticeRoomsExpanded: Boolean = false
) : MvRxState {

data class RoomSummaries(
val favourites: List<RoomSummary>,
val directRooms: List<RoomSummary>,
val groupRooms: List<RoomSummary>,
val lowPriorities: List<RoomSummary>,
val serverNotices: List<RoomSummary>
)
fun isCategoryExpanded(roomCategory: RoomCategory): Boolean {
return when (roomCategory) {
RoomCategory.FAVOURITE -> isFavouriteRoomsExpanded
RoomCategory.DIRECT -> isDirectRoomsExpanded
RoomCategory.GROUP -> isGroupRoomsExpanded
RoomCategory.LOW_PRIORITY -> isLowPriorityRoomsExpanded
RoomCategory.SERVER_NOTICE -> isServerNoticeRoomsExpanded
}
}

fun toggle(roomCategory: RoomCategory): RoomListViewState {
return when (roomCategory) {
RoomCategory.FAVOURITE -> copy(isFavouriteRoomsExpanded = !isFavouriteRoomsExpanded)
RoomCategory.DIRECT -> copy(isDirectRoomsExpanded = !isDirectRoomsExpanded)
RoomCategory.GROUP -> copy(isGroupRoomsExpanded = !isGroupRoomsExpanded)
RoomCategory.LOW_PRIORITY -> copy(isLowPriorityRoomsExpanded = !isLowPriorityRoomsExpanded)
RoomCategory.SERVER_NOTICE -> copy(isServerNoticeRoomsExpanded = !isServerNoticeRoomsExpanded)
}
}
}

typealias RoomSummaries = LinkedHashMap<RoomCategory, List<RoomSummary>>

enum class RoomCategory(@StringRes val titleRes: Int) {
FAVOURITE(R.string.room_list_favourites),
DIRECT(R.string.room_list_direct),
GROUP(R.string.room_list_group),
LOW_PRIORITY(R.string.room_list_low_priority),
SERVER_NOTICE(R.string.room_list_system_alert)
}

fun RoomSummaries?.isNullOrEmpty(): Boolean {
return this == null || (directRooms.isEmpty() && groupRooms.isEmpty() && favourites.isEmpty() && lowPriorities.isEmpty() && serverNotices.isEmpty())
return this == null || isEmpty()
}

View File

@ -19,65 +19,34 @@ package im.vector.riotredesign.features.home.room.list
import androidx.annotation.StringRes
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider

class RoomSummaryController(private val stringProvider: StringProvider
) : TypedEpoxyController<RoomListViewState>() {

private var isFavoriteRoomsExpanded = true
private var isDirectRoomsExpanded = false
private var isGroupRoomsExpanded = false
private var isLowPriorityRoomsExpanded = false
private var isServerNoticeRoomsExpanded = false

var callback: Callback? = null

override fun buildModels(viewState: RoomListViewState) {
val roomSummaries = viewState.asyncRooms()
val favourites = roomSummaries?.favourites ?: emptyList()
buildRoomCategory(viewState, favourites, R.string.room_list_favourites, isFavoriteRoomsExpanded) {
isFavoriteRoomsExpanded = !isFavoriteRoomsExpanded
roomSummaries?.forEach { (category, summaries) ->
if (summaries.isEmpty()) {
return@forEach
} else {
val isExpanded = viewState.isCategoryExpanded(category)
buildRoomCategory(viewState, summaries, category.titleRes, viewState.isCategoryExpanded(category)) {
callback?.onToggleRoomCategory(category)
}
if (isExpanded) {
buildRoomModels(summaries, viewState.visibleRoomId)
}
}
}
if (isFavoriteRoomsExpanded) {
buildRoomModels(favourites, viewState.selectedRoomId)
}

val directRooms = roomSummaries?.directRooms ?: emptyList()
buildRoomCategory(viewState, directRooms, R.string.room_list_direct, isDirectRoomsExpanded) {
isDirectRoomsExpanded = !isDirectRoomsExpanded
}
if (isDirectRoomsExpanded) {
buildRoomModels(directRooms, viewState.selectedRoomId)
}

val groupRooms = roomSummaries?.groupRooms ?: emptyList()
buildRoomCategory(viewState, groupRooms, R.string.room_list_group, isGroupRoomsExpanded) {
isGroupRoomsExpanded = !isGroupRoomsExpanded
}
if (isGroupRoomsExpanded) {
buildRoomModels(groupRooms, viewState.selectedRoomId)
}

val lowPriorities = roomSummaries?.lowPriorities ?: emptyList()
buildRoomCategory(viewState, lowPriorities, R.string.room_list_low_priority, isLowPriorityRoomsExpanded) {
isLowPriorityRoomsExpanded = !isLowPriorityRoomsExpanded
}
if (isLowPriorityRoomsExpanded) {
buildRoomModels(lowPriorities, viewState.selectedRoomId)
}

val serverNotices = roomSummaries?.serverNotices ?: emptyList()
buildRoomCategory(viewState, serverNotices, R.string.room_list_system_alert, isServerNoticeRoomsExpanded) {
isServerNoticeRoomsExpanded = !isServerNoticeRoomsExpanded
}
if (isServerNoticeRoomsExpanded) {
buildRoomModels(serverNotices, viewState.selectedRoomId)
}

}

private fun buildRoomCategory(viewState: RoomListViewState, summaries: List<RoomSummary>, @StringRes titleRes: Int, isExpanded: Boolean, mutateExpandedState: () -> Unit) {
if (summaries.isEmpty()) {
return
}
//TODO should add some business logic later
val unreadCount = if (summaries.isEmpty()) {
0
@ -117,6 +86,7 @@ class RoomSummaryController(private val stringProvider: StringProvider
}

interface Callback {
fun onToggleRoomCategory(roomCategory: RoomCategory)
fun onRoomSelected(room: RoomSummary)
}


View File

@ -19,10 +19,10 @@
package im.vector.riotredesign.features.html

import android.content.Context
import android.text.style.ImageSpan
import im.vector.matrix.android.api.permalinks.PermalinkData
import im.vector.matrix.android.api.permalinks.PermalinkParser
import im.vector.matrix.android.api.session.Session
import im.vector.riotredesign.core.glide.GlideRequests
import org.commonmark.node.BlockQuote
import org.commonmark.node.HtmlBlock
import org.commonmark.node.HtmlInline
@ -49,11 +49,12 @@ import ru.noties.markwon.html.tag.SuperScriptHandler
import ru.noties.markwon.html.tag.UnderlineHandler
import java.util.Arrays.asList

class EventHtmlRenderer(private val context: Context,
private val session: Session) {
class EventHtmlRenderer(glideRequests: GlideRequests,
context: Context,
session: Session) {

private val markwon = Markwon.builder(context)
.usePlugin(MatrixPlugin.create(context, session))
.usePlugin(MatrixPlugin.create(glideRequests, context, session))
.build()

fun render(text: String): CharSequence {
@ -62,7 +63,8 @@ class EventHtmlRenderer(private val context: Context,

}

private class MatrixPlugin private constructor(private val context: Context,
private class MatrixPlugin private constructor(private val glideRequests: GlideRequests,
private val context: Context,
private val session: Session) : AbstractMarkwonPlugin() {

override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
@ -76,7 +78,7 @@ private class MatrixPlugin private constructor(private val context: Context,
ImageHandler.create())
.addHandler(
"a",
MxLinkHandler(context, session))
MxLinkHandler(glideRequests, context, session))
.addHandler(
"blockquote",
BlockquoteHandler())
@ -128,13 +130,15 @@ private class MatrixPlugin private constructor(private val context: Context,

companion object {

fun create(context: Context, session: Session): MatrixPlugin {
return MatrixPlugin(context, session)
fun create(glideRequests: GlideRequests, context: Context, session: Session): MatrixPlugin {
return MatrixPlugin(glideRequests, context, session)
}
}
}

private class MxLinkHandler(private val context: Context, private val session: Session) : TagHandler() {
private class MxLinkHandler(private val glideRequests: GlideRequests,
private val context: Context,
private val session: Session) : TagHandler() {

private val linkHandler = LinkHandler()

@ -144,9 +148,8 @@ private class MxLinkHandler(private val context: Context, private val session: S
val permalinkData = PermalinkParser.parse(link)
when (permalinkData) {
is PermalinkData.UserLink -> {
val user = session.getUser(permalinkData.userId) ?: return
val drawable = PillDrawableFactory.create(context, permalinkData.userId, user)
val span = ImageSpan(drawable)
val user = session.getUser(permalinkData.userId)
val span = PillImageSpan(glideRequests, context, permalinkData.userId, user)
SpannableBuilder.setSpans(
visitor.builder(),
span,

View File

@ -1,59 +0,0 @@
/*
* 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.riotredesign.features.html

import android.content.Context
import android.graphics.drawable.Drawable
import com.google.android.material.chip.ChipDrawable
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.R
import im.vector.riotredesign.features.home.AvatarRenderer
import java.lang.ref.WeakReference

object PillDrawableFactory {

fun create(context: Context, userId: String, user: User?): Drawable {
val textPadding = context.resources.getDimension(R.dimen.pill_text_padding)

val chipDrawable = ChipDrawable.createFromResource(context, R.xml.pill_view).apply {
setText(user?.displayName ?: userId)
textEndPadding = textPadding
textStartPadding = textPadding
setChipMinHeightResource(R.dimen.pill_min_height)
setChipIconSizeResource(R.dimen.pill_avatar_size)
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
}
val avatarRendererCallback = AvatarRendererChipCallback(chipDrawable)
AvatarRenderer.load(context, user?.avatarUrl, user?.displayName, 80, avatarRendererCallback)
return chipDrawable
}

private class AvatarRendererChipCallback(chipDrawable: ChipDrawable) : AvatarRenderer.Callback {

private val weakChipDrawable = WeakReference<ChipDrawable>(chipDrawable)

override fun onDrawableUpdated(drawable: Drawable?) {
weakChipDrawable.get()?.apply {
chipIcon = drawable
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
}
}

}

}

View File

@ -0,0 +1,138 @@
/*
* 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.riotredesign.features.html

import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.drawable.Drawable
import android.text.style.ReplacementSpan
import android.widget.TextView
import androidx.annotation.UiThread
import com.bumptech.glide.request.target.SimpleTarget
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.chip.ChipDrawable
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.R
import im.vector.riotredesign.core.glide.GlideRequests
import im.vector.riotredesign.features.home.AvatarRenderer
import java.lang.ref.WeakReference

/**
* This span is able to replace a text by a [ChipDrawable]
* It's needed to call [bind] method to start requesting avatar, otherwise only the placeholder icon will be displayed if not already cached.
*/

private const val PILL_AVATAR_SIZE = 80

class PillImageSpan(private val glideRequests: GlideRequests,
private val context: Context,
private val userId: String,
private val user: User?) : ReplacementSpan() {

private val displayName by lazy {
if (user?.displayName.isNullOrEmpty()) userId else user?.displayName!!
}

private val pillDrawable = createChipDrawable()
private val target = PillImageSpanTarget(this)
private var tv: WeakReference<TextView>? = null

@UiThread
fun bind(textView: TextView) {
tv = WeakReference(textView)
AvatarRenderer.render(context, glideRequests, user?.avatarUrl, displayName, PILL_AVATAR_SIZE, target)
}

// ReplacementSpan *****************************************************************************

override fun getSize(paint: Paint, text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?): Int {
val rect = pillDrawable.bounds
if (fm != null) {
fm.ascent = -rect.bottom
fm.descent = 0
fm.top = fm.ascent
fm.bottom = 0
}
return rect.right
}

override fun draw(canvas: Canvas, text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint) {
canvas.save()
val transY = bottom - pillDrawable.bounds.bottom
canvas.translate(x, transY.toFloat())
pillDrawable.draw(canvas)
canvas.restore()
}

internal fun updateAvatarDrawable(drawable: Drawable?) {
pillDrawable.apply {
chipIcon = drawable
}
tv?.get()?.apply {
invalidate()
}
}

// Private methods *****************************************************************************

private fun createChipDrawable(): ChipDrawable {
val textPadding = context.resources.getDimension(R.dimen.pill_text_padding)
return ChipDrawable.createFromResource(context, R.xml.pill_view).apply {
setText(displayName)
textEndPadding = textPadding
textStartPadding = textPadding
setChipMinHeightResource(R.dimen.pill_min_height)
setChipIconSizeResource(R.dimen.pill_avatar_size)
chipIcon = AvatarRenderer.getCachedOrPlaceholder(context, glideRequests, user?.avatarUrl, displayName, PILL_AVATAR_SIZE)
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
}
}

}

/**
* Glide target to handle avatar retrieval into [PillImageSpan].
*/
private class PillImageSpanTarget(pillImageSpan: PillImageSpan) : SimpleTarget<Drawable>() {

private val pillImageSpan = WeakReference(pillImageSpan)

override fun onResourceReady(drawable: Drawable, transition: Transition<in Drawable>?) {
updateWith(drawable)
}

override fun onLoadCleared(placeholder: Drawable?) {
updateWith(placeholder)
}

private fun updateWith(drawable: Drawable?) {
pillImageSpan.get()?.apply {
updateAvatarDrawable(drawable)
}
}
}

View File

@ -15,7 +15,6 @@
android:layout_marginStart="64dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

@ -24,14 +23,15 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:textColor="@color/slate_grey"
android:textSize="14sp"
android:textStyle="italic"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/itemNoticeAvatarView"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintTop_toTopOf="@+id/itemNoticeAvatarView"
tools:text="Mon item" />

</androidx.constraintlayout.widget.ConstraintLayout>