mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2024-11-21 17:05:39 +03:00
Continue to work on timeline/pagination. WIP
This commit is contained in:
parent
6e3992e70e
commit
702abccb38
15 changed files with 122 additions and 72 deletions
|
@ -9,4 +9,8 @@ fun AppCompatActivity.addFragment(fragment: Fragment, frameId: Int) {
|
|||
|
||||
fun AppCompatActivity.replaceFragment(fragment: Fragment, frameId: Int) {
|
||||
supportFragmentManager.inTransaction { replace(frameId, fragment) }
|
||||
}
|
||||
|
||||
fun AppCompatActivity.addFragmentToBackstack(fragment: Fragment, frameId: Int, tag: String? = null) {
|
||||
supportFragmentManager.inTransaction { replace(frameId, fragment).addToBackStack(tag) }
|
||||
}
|
|
@ -16,4 +16,12 @@ fun Fragment.addChildFragment(fragment: Fragment, frameId: Int) {
|
|||
|
||||
fun Fragment.replaceChildFragment(fragment: Fragment, frameId: Int) {
|
||||
childFragmentManager.inTransaction { replace(frameId, fragment) }
|
||||
}
|
||||
|
||||
fun Fragment.addFragmentToBackstack(fragment: Fragment, frameId: Int, tag: String? = null) {
|
||||
fragmentManager?.inTransaction { replace(frameId, fragment).addToBackStack(tag) }
|
||||
}
|
||||
|
||||
fun Fragment.addChildFragmentToBackstack(fragment: Fragment, frameId: Int, tag: String? = null) {
|
||||
childFragmentManager.inTransaction { replace(frameId, fragment).addToBackStack(tag) }
|
||||
}
|
|
@ -3,19 +3,21 @@ package im.vector.riotredesign.features.home
|
|||
import android.arch.lifecycle.Observer
|
||||
import android.arch.paging.PagedList
|
||||
import android.os.Bundle
|
||||
import android.support.v7.widget.LinearLayoutManager
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import im.vector.matrix.android.api.Matrix
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.room.Room
|
||||
import im.vector.riotredesign.R
|
||||
import im.vector.riotredesign.core.platform.RiotFragment
|
||||
import im.vector.riotredesign.core.utils.FragmentArgumentDelegate
|
||||
import kotlinx.android.synthetic.main.fragment_room_list.*
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class RoomDetailFragment : RiotFragment(), RoomController.Callback {
|
||||
class RoomDetailFragment : RiotFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
|
@ -29,11 +31,8 @@ class RoomDetailFragment : RiotFragment(), RoomController.Callback {
|
|||
private val matrix by inject<Matrix>()
|
||||
private val currentSession = matrix.currentSession!!
|
||||
private var roomId by FragmentArgumentDelegate<String>()
|
||||
|
||||
private val timelineController = TimelineEventController()
|
||||
private val room: Room? by lazy {
|
||||
currentSession.getRoom(roomId)
|
||||
}
|
||||
private lateinit var room: Room
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_room_detail, container, false)
|
||||
|
@ -41,19 +40,20 @@ class RoomDetailFragment : RiotFragment(), RoomController.Callback {
|
|||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
if (room == null) {
|
||||
activity?.onBackPressed()
|
||||
return
|
||||
}
|
||||
room?.liveTimeline()?.observe(this, Observer { renderEvents(it) })
|
||||
setupRecyclerView()
|
||||
room = currentSession.getRoom(roomId)!!
|
||||
room.liveTimeline().observe(this, Observer { renderEvents(it) })
|
||||
}
|
||||
|
||||
private fun renderEvents(events: PagedList<Event>?) {
|
||||
timelineController.submitList(events)
|
||||
}
|
||||
|
||||
override fun onRoomSelected(room: Room) {
|
||||
Toast.makeText(context, "Room ${room.roomId} clicked", Toast.LENGTH_SHORT).show()
|
||||
private fun setupRecyclerView() {
|
||||
val linearLayoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
|
||||
linearLayoutManager.stackFromEnd = true
|
||||
epoxyRecyclerView.layoutManager = linearLayoutManager
|
||||
epoxyRecyclerView.setController(timelineController)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -2,13 +2,14 @@ package im.vector.riotredesign.features.home
|
|||
|
||||
import android.arch.lifecycle.Observer
|
||||
import android.os.Bundle
|
||||
import android.support.v7.widget.LinearLayoutManager
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import im.vector.matrix.android.api.Matrix
|
||||
import im.vector.matrix.android.api.session.room.Room
|
||||
import im.vector.riotredesign.R
|
||||
import im.vector.riotredesign.core.extensions.replaceFragment
|
||||
import im.vector.riotredesign.core.extensions.addFragmentToBackstack
|
||||
import im.vector.riotredesign.core.platform.RiotFragment
|
||||
import kotlinx.android.synthetic.main.fragment_room_list.*
|
||||
import org.koin.android.ext.android.inject
|
||||
|
@ -43,7 +44,7 @@ class RoomListFragment : RiotFragment(), RoomController.Callback {
|
|||
|
||||
override fun onRoomSelected(room: Room) {
|
||||
val detailFragment = RoomDetailFragment.newInstance(room.roomId)
|
||||
replaceFragment(detailFragment, R.id.homeFragmentContainer)
|
||||
addFragmentToBackstack(detailFragment, R.id.homeFragmentContainer)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -6,14 +6,15 @@ import com.airbnb.epoxy.paging.PagedListEpoxyController
|
|||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
|
||||
class TimelineEventController : PagedListEpoxyController<Event>(
|
||||
modelBuildingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
|
||||
modelBuildingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler(),
|
||||
diffingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
|
||||
) {
|
||||
|
||||
override fun buildItemModel(currentPosition: Int, item: Event?): EpoxyModel<*> {
|
||||
return if (item == null) {
|
||||
LoadingItemModel_().id(-currentPosition)
|
||||
} else {
|
||||
TimelineEventItem(item.eventId ?: "$currentPosition").id(currentPosition)
|
||||
TimelineEventItem(item.toString()).id(currentPosition)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/titleView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="80dp"
|
||||
android:padding="16dp"
|
||||
android:textSize="14sp"
|
||||
tools:text="Room name" />
|
||||
android:textSize="14sp" />
|
|
@ -1,5 +1,6 @@
|
|||
package im.vector.matrix.android.internal.database.query
|
||||
|
||||
import im.vector.matrix.android.internal.database.DBConstants
|
||||
import im.vector.matrix.android.internal.database.model.ChunkEntity
|
||||
import io.realm.Realm
|
||||
import io.realm.RealmQuery
|
||||
|
@ -40,5 +41,7 @@ fun ChunkEntity.Companion.findLastFromRoom(realm: Realm, roomId: String): ChunkE
|
|||
fun ChunkEntity.Companion.findAllIncludingEvents(realm: Realm, eventIds: List<String>): RealmResults<ChunkEntity> {
|
||||
return realm.where(ChunkEntity::class.java)
|
||||
.`in`("events.eventId", eventIds.toTypedArray())
|
||||
.notEqualTo("prevToken", DBConstants.STATE_EVENTS_CHUNK_TOKEN)
|
||||
.notEqualTo("nextToken", DBConstants.STATE_EVENTS_CHUNK_TOKEN)
|
||||
.findAll()
|
||||
}
|
|
@ -11,11 +11,15 @@ fun EventEntity.Companion.where(realm: Realm, roomId: String): RealmQuery<EventE
|
|||
.equalTo("chunk.room.roomId", roomId)
|
||||
}
|
||||
|
||||
fun EventEntity.Companion.where(realm: Realm, chunk: ChunkEntity): RealmQuery<EventEntity> {
|
||||
return realm.where(EventEntity::class.java)
|
||||
.equalTo("chunk.prevToken", chunk.prevToken)
|
||||
.and()
|
||||
.equalTo("chunk.nextToken", chunk.nextToken)
|
||||
fun EventEntity.Companion.where(realm: Realm, chunk: ChunkEntity?): RealmQuery<EventEntity> {
|
||||
var query = realm.where(EventEntity::class.java)
|
||||
if (chunk?.prevToken != null) {
|
||||
query = query.equalTo("chunk.prevToken", chunk.prevToken)
|
||||
}
|
||||
if (chunk?.nextToken != null) {
|
||||
query = query.equalTo("chunk.nextToken", chunk.nextToken)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
fun RealmResults<EventEntity>.getLast(type: String? = null): EventEntity? {
|
||||
|
@ -23,5 +27,5 @@ fun RealmResults<EventEntity>.getLast(type: String? = null): EventEntity? {
|
|||
if (type != null) {
|
||||
query = query.equalTo("type", type)
|
||||
}
|
||||
return query.findAll().sort("age").last(null)
|
||||
return query.findAll().last(null)
|
||||
}
|
|
@ -5,7 +5,9 @@ import im.vector.matrix.android.internal.network.parsing.UriMoshiAdapter
|
|||
|
||||
object MoshiProvider {
|
||||
|
||||
private val moshi: Moshi = Moshi.Builder().add(UriMoshiAdapter()).build()
|
||||
private val moshi: Moshi = Moshi.Builder()
|
||||
.add(UriMoshiAdapter())
|
||||
.build()
|
||||
|
||||
fun providesMoshi(): Moshi {
|
||||
return moshi
|
||||
|
|
|
@ -8,7 +8,6 @@ import im.vector.matrix.android.api.session.events.model.Event
|
|||
import im.vector.matrix.android.api.session.room.Room
|
||||
import im.vector.matrix.android.internal.database.mapper.EventMapper
|
||||
import im.vector.matrix.android.internal.database.model.ChunkEntity
|
||||
import im.vector.matrix.android.internal.database.model.EventEntity
|
||||
import im.vector.matrix.android.internal.database.query.where
|
||||
import im.vector.matrix.android.internal.session.room.timeline.PaginationRequest
|
||||
import im.vector.matrix.android.internal.session.room.timeline.TimelineBoundaryCallback
|
||||
|
@ -26,15 +25,17 @@ data class DefaultRoom(
|
|||
|
||||
override fun liveTimeline(): LiveData<PagedList<Event>> {
|
||||
val realmDataSourceFactory = monarchy.createDataSourceFactory { realm ->
|
||||
val lastChunk = ChunkEntity.where(realm, roomId).findAll().last(null)
|
||||
if (lastChunk == null) {
|
||||
EventEntity.where(realm, roomId)
|
||||
} else {
|
||||
EventEntity.where(realm, lastChunk)
|
||||
}
|
||||
ChunkEntity.where(realm, roomId).findAll().last(null).let { it?.events }?.where()
|
||||
}
|
||||
val domainSourceFactory = realmDataSourceFactory.map { EventMapper.map(it) }
|
||||
val livePagedListBuilder = LivePagedListBuilder(domainSourceFactory, 20).setBoundaryCallback(boundaryCallback)
|
||||
|
||||
val pagedListConfig = PagedList.Config.Builder()
|
||||
.setEnablePlaceholders(false)
|
||||
.setPageSize(10)
|
||||
.setPrefetchDistance(5)
|
||||
.build()
|
||||
|
||||
val livePagedListBuilder = LivePagedListBuilder(domainSourceFactory, pagedListConfig).setBoundaryCallback(boundaryCallback)
|
||||
return monarchy.findAllPagedWithChanges(realmDataSourceFactory, livePagedListBuilder)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package im.vector.matrix.android.internal.session.room
|
||||
|
||||
import im.vector.matrix.android.internal.network.NetworkConstants
|
||||
import im.vector.matrix.android.internal.session.room.model.TokenChunkEvent
|
||||
import kotlinx.coroutines.Deferred
|
||||
import retrofit2.Response
|
||||
|
@ -18,12 +19,13 @@ interface RoomAPI {
|
|||
* @param limit the maximum number of messages to retrieve. Optional.
|
||||
* @param filter A JSON RoomEventFilter to filter returned events with. Optional.
|
||||
*/
|
||||
@GET("rooms/{roomId}/messages")
|
||||
@GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/messages")
|
||||
fun getRoomMessagesFrom(@Path("roomId") roomId: String,
|
||||
@Query("from") from: String,
|
||||
@Query("dir") dir: String,
|
||||
@Query("limit") limit: Int,
|
||||
@Query("filter") filter: String?): Deferred<Response<TokenChunkEvent>>
|
||||
@Query("filter") filter: String?
|
||||
): Deferred<Response<TokenChunkEvent>>
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package im.vector.matrix.android.internal.session.room.model
|
||||
|
||||
enum class PaginationDirection(val value: String) {
|
||||
/**
|
||||
* Forwards when the event is added to the end of the timeline.
|
||||
* These events come from the /sync stream or from forwards pagination.
|
||||
*/
|
||||
FORWARDS("f"),
|
||||
|
||||
/**
|
||||
* Backwards when the event is added to the start of the timeline.
|
||||
* These events come from a back pagination.
|
||||
*/
|
||||
BACKWARDS("b")
|
||||
}
|
|
@ -6,8 +6,8 @@ import im.vector.matrix.android.api.session.events.model.Event
|
|||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TokenChunkEvent(
|
||||
@Json(name = "start") val prevToken: String? = null,
|
||||
@Json(name = "end") val nextToken: String? = null,
|
||||
@Json(name = "chunks") val chunk: List<Event> = emptyList(),
|
||||
@Json(name = "start") val nextToken: String? = null,
|
||||
@Json(name = "end") val prevToken: String? = null,
|
||||
@Json(name = "chunk") val chunk: List<Event> = emptyList(),
|
||||
@Json(name = "state") val stateEvents: List<Event> = emptyList()
|
||||
)
|
|
@ -9,6 +9,7 @@ import im.vector.matrix.android.api.failure.Failure
|
|||
import im.vector.matrix.android.api.util.Cancelable
|
||||
import im.vector.matrix.android.internal.database.mapper.asEntity
|
||||
import im.vector.matrix.android.internal.database.model.ChunkEntity
|
||||
import im.vector.matrix.android.internal.database.model.EventEntity
|
||||
import im.vector.matrix.android.internal.database.model.RoomEntity
|
||||
import im.vector.matrix.android.internal.database.query.findAllIncludingEvents
|
||||
import im.vector.matrix.android.internal.database.query.findWithNextToken
|
||||
|
@ -16,6 +17,7 @@ import im.vector.matrix.android.internal.database.query.findWithPrevToken
|
|||
import im.vector.matrix.android.internal.database.query.where
|
||||
import im.vector.matrix.android.internal.network.executeRequest
|
||||
import im.vector.matrix.android.internal.session.room.RoomAPI
|
||||
import im.vector.matrix.android.internal.session.room.model.PaginationDirection
|
||||
import im.vector.matrix.android.internal.session.room.model.TokenChunkEvent
|
||||
import im.vector.matrix.android.internal.util.CancelableCoroutine
|
||||
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
|
||||
|
@ -28,8 +30,8 @@ class PaginationRequest(private val roomAPI: RoomAPI,
|
|||
private val coroutineDispatchers: MatrixCoroutineDispatchers) {
|
||||
|
||||
fun execute(roomId: String,
|
||||
from: String? = null,
|
||||
direction: String,
|
||||
from: String?,
|
||||
direction: PaginationDirection,
|
||||
limit: Int = 10,
|
||||
filter: String? = null,
|
||||
callback: MatrixCallback<TokenChunkEvent>
|
||||
|
@ -42,22 +44,24 @@ class PaginationRequest(private val roomAPI: RoomAPI,
|
|||
}
|
||||
|
||||
private suspend fun execute(roomId: String,
|
||||
from: String? = null,
|
||||
direction: String,
|
||||
from: String?,
|
||||
direction: PaginationDirection,
|
||||
limit: Int = 10,
|
||||
filter: String? = null) = withContext(coroutineDispatchers.io) {
|
||||
filter: String?) = withContext(coroutineDispatchers.io) {
|
||||
|
||||
if (from == null) {
|
||||
return@withContext Either.left(Failure.Unknown(RuntimeException("From token can't be null")))
|
||||
return@withContext Either.left(
|
||||
Failure.Unknown(RuntimeException("From token shouldn't be null"))
|
||||
)
|
||||
}
|
||||
executeRequest<TokenChunkEvent> {
|
||||
apiCall = roomAPI.getRoomMessagesFrom(roomId, from, direction, limit, filter)
|
||||
return@withContext executeRequest<TokenChunkEvent> {
|
||||
apiCall = roomAPI.getRoomMessagesFrom(roomId, from, direction.value, limit, filter)
|
||||
}.leftIfNull {
|
||||
Failure.Unknown(RuntimeException("TokenChunkEvent shouldn't be null"))
|
||||
}.flatMap {
|
||||
}.flatMap { chunk ->
|
||||
try {
|
||||
insertInDb(it, roomId)
|
||||
Either.right(it)
|
||||
insertInDb(chunk, roomId)
|
||||
Either.right(chunk)
|
||||
} catch (exception: Exception) {
|
||||
Either.Left(Failure.Unknown(exception))
|
||||
}
|
||||
|
@ -67,7 +71,7 @@ class PaginationRequest(private val roomAPI: RoomAPI,
|
|||
private fun insertInDb(chunkEvent: TokenChunkEvent, roomId: String) {
|
||||
monarchy.runTransactionSync { realm ->
|
||||
val roomEntity = RoomEntity.where(realm, roomId).findFirst()
|
||||
?: return@runTransactionSync
|
||||
?: return@runTransactionSync
|
||||
|
||||
val nextChunk = ChunkEntity.findWithPrevToken(realm, roomId, chunkEvent.nextToken)
|
||||
val prevChunk = ChunkEntity.findWithNextToken(realm, roomId, chunkEvent.prevToken)
|
||||
|
@ -75,38 +79,42 @@ class PaginationRequest(private val roomAPI: RoomAPI,
|
|||
val mergedEvents = chunkEvent.chunk + chunkEvent.stateEvents
|
||||
val mergedEventIds = mergedEvents.filter { it.eventId != null }.map { it.eventId!! }
|
||||
val chunksOverlapped = ChunkEntity.findAllIncludingEvents(realm, mergedEventIds)
|
||||
val hasOverlapped = chunksOverlapped.isNotEmpty()
|
||||
|
||||
val currentChunk: ChunkEntity
|
||||
if (nextChunk != null) {
|
||||
currentChunk = nextChunk
|
||||
val currentChunk = if (nextChunk != null) {
|
||||
nextChunk
|
||||
} else {
|
||||
currentChunk = ChunkEntity()
|
||||
ChunkEntity()
|
||||
}
|
||||
|
||||
|
||||
val eventsToAdd = ArrayList<EventEntity>()
|
||||
|
||||
currentChunk.prevToken = chunkEvent.prevToken
|
||||
mergedEvents.forEach { event ->
|
||||
val eventEntity = event.asEntity().let {
|
||||
realm.copyToRealmOrUpdate(it)
|
||||
}
|
||||
if (!currentChunk.events.contains(eventEntity)) {
|
||||
currentChunk.events.add(eventEntity)
|
||||
eventsToAdd.add(0, eventEntity)
|
||||
}
|
||||
}
|
||||
|
||||
if (prevChunk != null) {
|
||||
currentChunk.events.addAll(prevChunk.events)
|
||||
eventsToAdd.addAll(0, prevChunk.events)
|
||||
roomEntity.chunks.remove(prevChunk)
|
||||
|
||||
} else if (chunksOverlapped.isNotEmpty()) {
|
||||
} else if (hasOverlapped) {
|
||||
chunksOverlapped.forEach { chunk ->
|
||||
chunk.events.forEach { event ->
|
||||
if (!currentChunk.events.contains(event)) {
|
||||
currentChunk.events.add(event)
|
||||
eventsToAdd.add(0, event)
|
||||
}
|
||||
}
|
||||
roomEntity.chunks.remove(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
currentChunk.events.addAll(0, eventsToAdd)
|
||||
if (!roomEntity.chunks.contains(currentChunk)) {
|
||||
roomEntity.chunks.add(currentChunk)
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import im.vector.matrix.android.api.failure.Failure
|
|||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.internal.database.model.ChunkEntity
|
||||
import im.vector.matrix.android.internal.database.query.findAllIncludingEvents
|
||||
import im.vector.matrix.android.internal.session.room.model.PaginationDirection
|
||||
import im.vector.matrix.android.internal.session.room.model.TokenChunkEvent
|
||||
import im.vector.matrix.android.internal.util.PagingRequestHelper
|
||||
import java.util.*
|
||||
|
@ -25,19 +26,20 @@ class TimelineBoundaryCallback(private val paginationRequest: PaginationRequest,
|
|||
}
|
||||
|
||||
override fun onItemAtEndLoaded(itemAtEnd: Event) {
|
||||
helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) {
|
||||
monarchy.doWithRealm { realm ->
|
||||
if (itemAtEnd.eventId == null) {
|
||||
return@doWithRealm
|
||||
}
|
||||
val chunkEntity = ChunkEntity.findAllIncludingEvents(realm, Collections.singletonList(itemAtEnd.eventId)).firstOrNull()
|
||||
paginationRequest.execute(roomId, chunkEntity?.prevToken, "forward", callback = createCallback(it))
|
||||
}
|
||||
}
|
||||
//Todo handle forward pagination
|
||||
}
|
||||
|
||||
override fun onItemAtFrontLoaded(itemAtFront: Event) {
|
||||
//Todo handle forward pagination
|
||||
helper.runIfNotRunning(PagingRequestHelper.RequestType.BEFORE) {
|
||||
monarchy.doWithRealm { realm ->
|
||||
if (itemAtFront.eventId == null) {
|
||||
return@doWithRealm
|
||||
}
|
||||
val chunkEntity = ChunkEntity.findAllIncludingEvents(realm, Collections.singletonList(itemAtFront.eventId)).firstOrNull()
|
||||
paginationRequest.execute(roomId, chunkEntity?.prevToken, PaginationDirection.BACKWARDS, callback = createCallback(it))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun createCallback(pagingRequestCallback: PagingRequestHelper.Request.Callback) = object : MatrixCallback<TokenChunkEvent> {
|
||||
|
|
Loading…
Reference in a new issue