Merge pull request #342 from vector-im/feature/edit_history
Feature/edit history
This commit is contained in:
commit
8901a5e09a
|
@ -2,7 +2,7 @@ Changes in RiotX 0.2.1 (2019-XX-XX)
|
|||
===================================================
|
||||
|
||||
Features:
|
||||
-
|
||||
- Message Editing: View edit history
|
||||
|
||||
Improvements:
|
||||
- Handle click on redacted events: view source and create permalink
|
||||
|
|
|
@ -45,6 +45,12 @@ allprojects {
|
|||
maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
|
||||
google()
|
||||
jcenter()
|
||||
maven {
|
||||
url 'https://repo.adobe.com/nexus/content/repositories/public/'
|
||||
content {
|
||||
includeGroupByRegex "diff_match_patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
package im.vector.matrix.android.api.session.room.model.relation
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import im.vector.matrix.android.api.MatrixCallback
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
|
||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||
import im.vector.matrix.android.api.util.Cancelable
|
||||
|
@ -78,6 +80,11 @@ interface RelationService {
|
|||
newBodyAutoMarkdown: Boolean,
|
||||
compatibilityBodyText: String = "* $newBodyText"): Cancelable
|
||||
|
||||
/**
|
||||
* Get's the edit history of the given event
|
||||
*/
|
||||
fun fetchEditHistory(eventId: String, callback: MatrixCallback<List<Event>>)
|
||||
|
||||
|
||||
/**
|
||||
* Reply to an event in the timeline (must be in same room)
|
||||
|
@ -91,4 +98,6 @@ interface RelationService {
|
|||
autoMarkdown: Boolean = false): Cancelable?
|
||||
|
||||
fun getEventSummaryLive(eventId: String): LiveData<EventAnnotationsSummary>
|
||||
|
||||
|
||||
}
|
|
@ -18,7 +18,8 @@ package im.vector.matrix.android.internal.network
|
|||
|
||||
internal object NetworkConstants {
|
||||
|
||||
const val URI_API_PREFIX_PATH = "_matrix/client/"
|
||||
const val URI_API_PREFIX_PATH_R0 = "_matrix/client/r0/"
|
||||
private const val URI_API_PREFIX_PATH = "_matrix/client"
|
||||
const val URI_API_PREFIX_PATH_R0 = "$URI_API_PREFIX_PATH/r0/"
|
||||
const val URI_API_PREFIX_PATH_UNSTABLE = "$URI_API_PREFIX_PATH/unstable/"
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,10 +22,11 @@ import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
|
|||
import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse
|
||||
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsParams
|
||||
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsResponse
|
||||
import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProtocol
|
||||
import im.vector.matrix.android.internal.network.NetworkConstants
|
||||
import im.vector.matrix.android.internal.session.room.membership.RoomMembersResponse
|
||||
import im.vector.matrix.android.internal.session.room.membership.joining.InviteBody
|
||||
import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProtocol
|
||||
import im.vector.matrix.android.internal.session.room.relation.RelationsResponse
|
||||
import im.vector.matrix.android.internal.session.room.send.SendResponse
|
||||
import im.vector.matrix.android.internal.session.room.timeline.EventContextResponse
|
||||
import im.vector.matrix.android.internal.session.room.timeline.PaginationResponse
|
||||
|
@ -195,6 +196,20 @@ internal interface RoomAPI {
|
|||
@Body content: Content?
|
||||
): Call<SendResponse>
|
||||
|
||||
|
||||
/**
|
||||
* Paginate relations for event based in normal topological order
|
||||
*
|
||||
* @param relationType filter for this relation type
|
||||
* @param eventType filter for this event type
|
||||
*/
|
||||
@GET(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "rooms/{roomId}/relations/{eventId}/{relationType}/{eventType}")
|
||||
fun getRelations(@Path("roomId") roomId: String,
|
||||
@Path("eventId") eventId: String,
|
||||
@Path("relationType") relationType: String,
|
||||
@Path("eventType") eventType: String
|
||||
): Call<RelationsResponse>
|
||||
|
||||
/**
|
||||
* Join the given room.
|
||||
*
|
||||
|
|
|
@ -30,8 +30,8 @@ import im.vector.matrix.android.internal.session.room.membership.leaving.LeaveRo
|
|||
import im.vector.matrix.android.internal.session.room.read.DefaultReadService
|
||||
import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask
|
||||
import im.vector.matrix.android.internal.session.room.relation.DefaultRelationService
|
||||
import im.vector.matrix.android.internal.session.room.relation.FetchEditHistoryTask
|
||||
import im.vector.matrix.android.internal.session.room.relation.FindReactionEventForUndoTask
|
||||
import im.vector.matrix.android.internal.session.room.relation.UpdateQuickReactionTask
|
||||
import im.vector.matrix.android.internal.session.room.send.DefaultSendService
|
||||
import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
|
||||
import im.vector.matrix.android.internal.session.room.state.DefaultStateService
|
||||
|
@ -56,7 +56,7 @@ internal class RoomFactory @Inject constructor(private val context: Context,
|
|||
private val setReadMarkersTask: SetReadMarkersTask,
|
||||
private val cryptoService: CryptoService,
|
||||
private val findReactionEventForUndoTask: FindReactionEventForUndoTask,
|
||||
private val updateQuickReactionTask: UpdateQuickReactionTask,
|
||||
private val fetchEditHistoryTask: FetchEditHistoryTask,
|
||||
private val joinRoomTask: JoinRoomTask,
|
||||
private val leaveRoomTask: LeaveRoomTask) {
|
||||
|
||||
|
@ -67,7 +67,7 @@ internal class RoomFactory @Inject constructor(private val context: Context,
|
|||
val roomMembersService = DefaultMembershipService(roomId, monarchy, taskExecutor, loadRoomMembersTask, inviteTask, joinRoomTask, leaveRoomTask)
|
||||
val readService = DefaultReadService(roomId, monarchy, taskExecutor, setReadMarkersTask, credentials)
|
||||
val relationService = DefaultRelationService(context,
|
||||
credentials, roomId, eventFactory, cryptoService, findReactionEventForUndoTask, monarchy, taskExecutor)
|
||||
credentials, roomId, eventFactory, cryptoService, findReactionEventForUndoTask, fetchEditHistoryTask, monarchy, taskExecutor)
|
||||
|
||||
return DefaultRoom(
|
||||
roomId,
|
||||
|
|
|
@ -142,4 +142,7 @@ internal abstract class RoomModule {
|
|||
|
||||
@Binds
|
||||
abstract fun bindFileService(fileService: DefaultFileService): FileService
|
||||
|
||||
@Binds
|
||||
abstract fun bindFetchEditHistoryTask(editHistoryTask: DefaultFetchEditHistoryTask): FetchEditHistoryTask
|
||||
}
|
||||
|
|
|
@ -53,6 +53,7 @@ internal class DefaultRelationService @Inject constructor(private val context: C
|
|||
private val eventFactory: LocalEchoEventFactory,
|
||||
private val cryptoService: CryptoService,
|
||||
private val findReactionEventForUndoTask: FindReactionEventForUndoTask,
|
||||
private val fetchEditHistoryTask: FetchEditHistoryTask,
|
||||
private val monarchy: Monarchy,
|
||||
private val taskExecutor: TaskExecutor)
|
||||
: RelationService {
|
||||
|
@ -131,6 +132,13 @@ internal class DefaultRelationService @Inject constructor(private val context: C
|
|||
|
||||
}
|
||||
|
||||
override fun fetchEditHistory(eventId: String, callback: MatrixCallback<List<Event>>) {
|
||||
val params = FetchEditHistoryTask.Params(roomId, eventId)
|
||||
fetchEditHistoryTask.configureWith(params)
|
||||
.dispatchTo(callback)
|
||||
.executeBy(taskExecutor)
|
||||
}
|
||||
|
||||
override fun replyToMessage(eventReplied: TimelineEvent, replyText: String, autoMarkdown: Boolean): Cancelable? {
|
||||
val event = eventFactory.createReplyTextEvent(roomId, eventReplied, replyText, autoMarkdown)?.also {
|
||||
saveLocalEcho(it)
|
||||
|
@ -169,7 +177,8 @@ internal class DefaultRelationService @Inject constructor(private val context: C
|
|||
EventAnnotationsSummaryEntity.where(realm, eventId)
|
||||
}
|
||||
return Transformations.map(liveEntity) { realmResults ->
|
||||
realmResults.firstOrNull()?.asDomain() ?: EventAnnotationsSummary(eventId, emptyList(), null)
|
||||
realmResults.firstOrNull()?.asDomain()
|
||||
?: EventAnnotationsSummary(eventId, emptyList(), null)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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.matrix.android.internal.session.room.relation
|
||||
|
||||
import arrow.core.Try
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.events.model.EventType
|
||||
import im.vector.matrix.android.api.session.events.model.RelationType
|
||||
import im.vector.matrix.android.internal.network.executeRequest
|
||||
import im.vector.matrix.android.internal.session.room.RoomAPI
|
||||
import im.vector.matrix.android.internal.task.Task
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
internal interface FetchEditHistoryTask : Task<FetchEditHistoryTask.Params, List<Event>> {
|
||||
|
||||
data class Params(
|
||||
val roomId: String,
|
||||
val eventId: String
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
internal class DefaultFetchEditHistoryTask @Inject constructor(
|
||||
private val roomAPI: RoomAPI
|
||||
) : FetchEditHistoryTask {
|
||||
|
||||
override suspend fun execute(params: FetchEditHistoryTask.Params): Try<List<Event>> {
|
||||
return executeRequest<RelationsResponse> {
|
||||
apiCall = roomAPI.getRelations(params.roomId, params.eventId, RelationType.REPLACE, EventType.MESSAGE)
|
||||
}.map { resp ->
|
||||
resp.chunks
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.matrix.android.internal.session.room.relation
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
internal data class RelationsResponse(
|
||||
@Json(name = "chunk") val chunks: List<Event>,
|
||||
@Json(name = "next_batch") val nextBatch: String?,
|
||||
@Json(name = "prev_batch") val prevBatch: String?
|
||||
)
|
|
@ -254,6 +254,8 @@ dependencies {
|
|||
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
|
||||
}
|
||||
|
||||
implementation 'diff_match_patch:diff_match_patch:current'
|
||||
|
||||
// TESTS
|
||||
testImplementation 'junit:junit:4.12'
|
||||
androidTestImplementation 'androidx.test:runner:1.2.0'
|
||||
|
|
|
@ -344,6 +344,13 @@ SOFTWARE.
|
|||
<br/>
|
||||
Copyright (c) 2018, Jaisel Rahman
|
||||
</li>
|
||||
<li>
|
||||
<b>diff-match-patch</b>
|
||||
<br/>
|
||||
Copyright 2018 The diff-match-patch Authors. https://github.com/google/diff-match-patch
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
<pre>
|
||||
Apache License
|
||||
|
|
|
@ -35,10 +35,7 @@ import im.vector.riotx.features.crypto.verification.SASVerificationIncomingFragm
|
|||
import im.vector.riotx.features.home.*
|
||||
import im.vector.riotx.features.home.group.GroupListFragment
|
||||
import im.vector.riotx.features.home.room.detail.RoomDetailFragment
|
||||
import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsBottomSheet
|
||||
import im.vector.riotx.features.home.room.detail.timeline.action.MessageMenuFragment
|
||||
import im.vector.riotx.features.home.room.detail.timeline.action.QuickReactionFragment
|
||||
import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionBottomSheet
|
||||
import im.vector.riotx.features.home.room.detail.timeline.action.*
|
||||
import im.vector.riotx.features.home.room.list.RoomListFragment
|
||||
import im.vector.riotx.features.invite.VectorInviteView
|
||||
import im.vector.riotx.features.login.LoginActivity
|
||||
|
@ -93,6 +90,8 @@ interface ScreenComponent {
|
|||
|
||||
fun inject(viewReactionBottomSheet: ViewReactionBottomSheet)
|
||||
|
||||
fun inject(viewEditHistoryBottomSheet: ViewEditHistoryBottomSheet)
|
||||
|
||||
fun inject(messageMenuFragment: MessageMenuFragment)
|
||||
|
||||
fun inject(vectorSettingsActivity: VectorSettingsActivity)
|
||||
|
|
|
@ -29,11 +29,7 @@ import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsVie
|
|||
import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsViewModel_AssistedFactory
|
||||
import im.vector.riotx.features.crypto.keysbackup.setup.KeysBackupSetupSharedViewModel
|
||||
import im.vector.riotx.features.crypto.verification.SasVerificationViewModel
|
||||
import im.vector.riotx.features.home.HomeActivityViewModel
|
||||
import im.vector.riotx.features.home.HomeActivityViewModel_AssistedFactory
|
||||
import im.vector.riotx.features.home.HomeDetailViewModel
|
||||
import im.vector.riotx.features.home.HomeDetailViewModel_AssistedFactory
|
||||
import im.vector.riotx.features.home.HomeNavigationViewModel
|
||||
import im.vector.riotx.features.home.*
|
||||
import im.vector.riotx.features.home.group.GroupListViewModel
|
||||
import im.vector.riotx.features.home.group.GroupListViewModel_AssistedFactory
|
||||
import im.vector.riotx.features.home.room.detail.RoomDetailViewModel
|
||||
|
@ -59,7 +55,7 @@ import im.vector.riotx.features.workers.signout.SignOutViewModel
|
|||
|
||||
@Module
|
||||
interface ViewModelModule {
|
||||
|
||||
|
||||
|
||||
@Binds
|
||||
fun bindViewModelFactory(factory: VectorViewModelFactory): ViewModelProvider.Factory
|
||||
|
@ -156,6 +152,9 @@ interface ViewModelModule {
|
|||
@Binds
|
||||
fun bindViewReactionViewModelFactory(factory: ViewReactionViewModel_AssistedFactory): ViewReactionViewModel.Factory
|
||||
|
||||
@Binds
|
||||
fun bindViewEditHistoryViewModelFactory(factory: ViewEditHistoryViewModel_AssistedFactory): ViewEditHistoryViewModel.Factory
|
||||
|
||||
@Binds
|
||||
fun bindCreateRoomViewModelFactory(factory: CreateRoomViewModel_AssistedFactory): CreateRoomViewModel.Factory
|
||||
|
||||
|
|
|
@ -51,7 +51,7 @@ abstract class GenericItem : VectorEpoxyModel<GenericItem.Holder>() {
|
|||
var title: String? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
var description: String? = null
|
||||
var description: CharSequence? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
var style: STYLE = STYLE.NORMAL_TEXT
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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.ui.list
|
||||
|
||||
import android.widget.TextView
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
|
||||
import im.vector.riotx.core.epoxy.VectorEpoxyModel
|
||||
import im.vector.riotx.core.extensions.setTextOrHide
|
||||
|
||||
/**
|
||||
* A generic list item header left aligned with notice color.
|
||||
*/
|
||||
@EpoxyModelClass(layout = R.layout.item_generic_header)
|
||||
abstract class GenericItemHeader : VectorEpoxyModel<GenericItemHeader.Holder>() {
|
||||
|
||||
@EpoxyAttribute
|
||||
var text: String? = null
|
||||
|
||||
override fun bind(holder: Holder) {
|
||||
holder.text.setTextOrHide(text)
|
||||
}
|
||||
|
||||
class Holder : VectorEpoxyHolder() {
|
||||
val text by bind<TextView>(R.id.itemGenericHeaderText)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package im.vector.riotx.core.ui.list
|
||||
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
|
||||
import im.vector.riotx.core.epoxy.VectorEpoxyModel
|
||||
|
||||
|
||||
/**
|
||||
* A generic list item header left aligned with notice color.
|
||||
*/
|
||||
@EpoxyModelClass(layout = R.layout.item_generic_loader)
|
||||
abstract class GenericLoaderItem : VectorEpoxyModel<GenericLoaderItem.Holder>() {
|
||||
|
||||
//Maybe/Later add some style configuration, SMALL/BIG ?
|
||||
|
||||
override fun bind(holder: Holder) {}
|
||||
|
||||
class Holder : VectorEpoxyHolder()
|
||||
}
|
|
@ -32,7 +32,6 @@ sealed class RoomDetailActions {
|
|||
data class RedactAction(val targetEventId: String, val reason: String? = "") : RoomDetailActions()
|
||||
data class UndoReaction(val targetEventId: String, val key: String, val reason: String? = "") : RoomDetailActions()
|
||||
data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailActions()
|
||||
data class ShowEditHistoryAction(val event: String, val editAggregatedSummary: EditAggregatedSummary) : RoomDetailActions()
|
||||
data class NavigateToEvent(val eventId: String, val position: Int?) : RoomDetailActions()
|
||||
data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailActions()
|
||||
object AcceptInvite : RoomDetailActions()
|
||||
|
|
|
@ -85,10 +85,7 @@ import im.vector.riotx.features.home.room.detail.composer.TextComposerView
|
|||
import im.vector.riotx.features.home.room.detail.composer.TextComposerViewModel
|
||||
import im.vector.riotx.features.home.room.detail.composer.TextComposerViewState
|
||||
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
|
||||
import im.vector.riotx.features.home.room.detail.timeline.action.ActionsHandler
|
||||
import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsBottomSheet
|
||||
import im.vector.riotx.features.home.room.detail.timeline.action.MessageMenuViewModel
|
||||
import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionBottomSheet
|
||||
import im.vector.riotx.features.home.room.detail.timeline.action.*
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
|
||||
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
|
||||
import im.vector.riotx.features.html.EventHtmlRenderer
|
||||
|
@ -666,10 +663,8 @@ class RoomDetailFragment :
|
|||
}
|
||||
|
||||
override fun onEditedDecorationClicked(informationData: MessageInformationData, editAggregatedSummary: EditAggregatedSummary?) {
|
||||
editAggregatedSummary?.also {
|
||||
roomDetailViewModel.process(RoomDetailActions.ShowEditHistoryAction(informationData.eventId, it))
|
||||
}
|
||||
|
||||
ViewEditHistoryBottomSheet.newInstance(roomDetailArgs.roomId, informationData)
|
||||
.show(requireActivity().supportFragmentManager, "DISPLAY_EDITS")
|
||||
}
|
||||
// AutocompleteUserPresenter.Callback
|
||||
|
||||
|
|
|
@ -114,7 +114,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
is RoomDetailActions.RedactAction -> handleRedactEvent(action)
|
||||
is RoomDetailActions.UndoReaction -> handleUndoReact(action)
|
||||
is RoomDetailActions.UpdateQuickReactAction -> handleUpdateQuickReaction(action)
|
||||
is RoomDetailActions.ShowEditHistoryAction -> handleShowEditHistoryReaction(action)
|
||||
is RoomDetailActions.EnterEditMode -> handleEditAction(action)
|
||||
is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action)
|
||||
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
|
||||
|
@ -309,22 +308,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
return finalText
|
||||
}
|
||||
|
||||
private fun handleShowEditHistoryReaction(action: RoomDetailActions.ShowEditHistoryAction) {
|
||||
//TODO temporary implementation
|
||||
val lastReplace = action.editAggregatedSummary.sourceEvents.lastOrNull()?.let {
|
||||
room.getTimeLineEvent(it)
|
||||
} ?: return
|
||||
|
||||
val dateFormat = SimpleDateFormat("EEE, d MMM yyyy HH:mm", Locale.getDefault())
|
||||
_nonBlockingPopAlert.postValue(LiveEvent(
|
||||
Pair(R.string.last_edited_info_message, listOf(
|
||||
lastReplace.getDisambiguatedDisplayName(),
|
||||
dateFormat.format(Date(lastReplace.root.originServerTs ?: 0)))
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun handleChangeTopicSlashCommand(changeTopic: ParsedCommand.ChangeTopic) {
|
||||
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled))
|
||||
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* 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.features.home.room.detail.timeline.action
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import butterknife.BindView
|
||||
import butterknife.ButterKnife
|
||||
import com.airbnb.epoxy.EpoxyRecyclerView
|
||||
import com.airbnb.mvrx.MvRx
|
||||
import com.airbnb.mvrx.fragmentViewModel
|
||||
import com.airbnb.mvrx.withState
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.di.ScreenComponent
|
||||
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
|
||||
import im.vector.riotx.features.html.EventHtmlRenderer
|
||||
import kotlinx.android.synthetic.main.bottom_sheet_epoxylist_with_title.*
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
/**
|
||||
* Bottom sheet displaying list of edits for a given event ordered by timestamp
|
||||
*/
|
||||
class ViewEditHistoryBottomSheet : VectorBaseBottomSheetDialogFragment() {
|
||||
|
||||
private val viewModel: ViewEditHistoryViewModel by fragmentViewModel(ViewEditHistoryViewModel::class)
|
||||
|
||||
@Inject lateinit var viewEditHistoryViewModelFactory: ViewEditHistoryViewModel.Factory
|
||||
@Inject lateinit var eventHtmlRenderer: EventHtmlRenderer
|
||||
|
||||
@BindView(R.id.bottom_sheet_display_reactions_list)
|
||||
lateinit var epoxyRecyclerView: EpoxyRecyclerView
|
||||
|
||||
private val epoxyController by lazy {
|
||||
ViewEditHistoryEpoxyController(requireContext(), viewModel.timelineDateFormatter, eventHtmlRenderer)
|
||||
}
|
||||
|
||||
override fun injectWith(screenComponent: ScreenComponent) {
|
||||
screenComponent.inject(this)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val view = inflater.inflate(R.layout.bottom_sheet_epoxylist_with_title, container, false)
|
||||
ButterKnife.bind(this, view)
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
epoxyRecyclerView.setController(epoxyController)
|
||||
val dividerItemDecoration = DividerItemDecoration(epoxyRecyclerView.context,
|
||||
LinearLayout.VERTICAL)
|
||||
epoxyRecyclerView.addItemDecoration(dividerItemDecoration)
|
||||
bottomSheetTitle.text = context?.getString(R.string.message_edits)
|
||||
}
|
||||
|
||||
|
||||
override fun invalidate() = withState(viewModel) {
|
||||
epoxyController.setData(it)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(roomId: String, informationData: MessageInformationData): ViewEditHistoryBottomSheet {
|
||||
val args = Bundle()
|
||||
val parcelableArgs = TimelineEventFragmentArgs(
|
||||
informationData.eventId,
|
||||
roomId,
|
||||
informationData
|
||||
)
|
||||
args.putParcelable(MvRx.KEY_ARG, parcelableArgs)
|
||||
return ViewEditHistoryBottomSheet().apply { arguments = args }
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* 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.features.home.room.detail.timeline.action
|
||||
|
||||
import android.content.Context
|
||||
import android.text.Spannable
|
||||
import android.text.format.DateUtils
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.airbnb.epoxy.TypedEpoxyController
|
||||
import com.airbnb.mvrx.Fail
|
||||
import com.airbnb.mvrx.Incomplete
|
||||
import com.airbnb.mvrx.Success
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.events.model.toModel
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.extensions.localDateTime
|
||||
import im.vector.riotx.core.ui.list.genericFooterItem
|
||||
import im.vector.riotx.core.ui.list.genericItem
|
||||
import im.vector.riotx.core.ui.list.genericItemHeader
|
||||
import im.vector.riotx.core.ui.list.genericLoaderItem
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFormatter
|
||||
import im.vector.riotx.features.html.EventHtmlRenderer
|
||||
import me.gujun.android.span.span
|
||||
import name.fraser.neil.plaintext.diff_match_patch
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Epoxy controller for reaction event list
|
||||
*/
|
||||
class ViewEditHistoryEpoxyController(private val context: Context,
|
||||
val timelineDateFormatter: TimelineDateFormatter,
|
||||
val eventHtmlRenderer: EventHtmlRenderer) : TypedEpoxyController<ViewEditHistoryViewState>() {
|
||||
|
||||
override fun buildModels(state: ViewEditHistoryViewState) {
|
||||
when (state.editList) {
|
||||
is Incomplete -> {
|
||||
genericLoaderItem {
|
||||
id("Spinner")
|
||||
}
|
||||
}
|
||||
is Fail -> {
|
||||
genericFooterItem {
|
||||
id("failure")
|
||||
text(context.getString(R.string.unknown_error))
|
||||
}
|
||||
}
|
||||
is Success -> {
|
||||
state.editList()?.let { renderEvents(it) }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderEvents(sourceEvents: List<Event>) {
|
||||
if (sourceEvents.isEmpty()) {
|
||||
genericItem {
|
||||
id("footer")
|
||||
title(context.getString(R.string.no_message_edits_found))
|
||||
}
|
||||
} else {
|
||||
var lastDate: Calendar? = null
|
||||
sourceEvents.sortedByDescending {
|
||||
it.originServerTs ?: 0
|
||||
}.forEachIndexed { index, timelineEvent ->
|
||||
|
||||
val evDate = Calendar.getInstance().apply {
|
||||
timeInMillis = timelineEvent.originServerTs
|
||||
?: System.currentTimeMillis()
|
||||
}
|
||||
if (lastDate?.get(Calendar.DAY_OF_YEAR) != evDate.get(Calendar.DAY_OF_YEAR)) {
|
||||
//need to display header with day
|
||||
val dateString = if (DateUtils.isToday(evDate.timeInMillis)) context.getString(R.string.today)
|
||||
else timelineDateFormatter.formatMessageDay(timelineEvent.localDateTime())
|
||||
genericItemHeader {
|
||||
id(evDate.hashCode())
|
||||
text(dateString)
|
||||
}
|
||||
}
|
||||
lastDate = evDate
|
||||
val cContent = getCorrectContent(timelineEvent)
|
||||
val body = cContent.second?.let { eventHtmlRenderer.render(it) }
|
||||
?: cContent.first
|
||||
|
||||
val nextEvent = if (index + 1 <= sourceEvents.lastIndex) sourceEvents[index + 1] else null
|
||||
|
||||
var spannedDiff: Spannable? = null
|
||||
if (nextEvent != null && cContent.second == null /*No diff for html*/) {
|
||||
//compares the body
|
||||
val nContent = getCorrectContent(nextEvent)
|
||||
val nextBody = nContent.second?.let { eventHtmlRenderer.render(it) }
|
||||
?: nContent.first
|
||||
val dmp = diff_match_patch()
|
||||
val diff = dmp.diff_main(nextBody.toString(), body.toString())
|
||||
Timber.e("#### Diff: $diff")
|
||||
dmp.diff_cleanupSemantic(diff)
|
||||
Timber.e("#### Diff: $diff")
|
||||
spannedDiff = span {
|
||||
diff.map {
|
||||
when (it.operation) {
|
||||
diff_match_patch.Operation.DELETE -> {
|
||||
span {
|
||||
text = it.text
|
||||
textColor = ContextCompat.getColor(context, R.color.vector_error_color)
|
||||
textDecorationLine = "line-through"
|
||||
}
|
||||
}
|
||||
diff_match_patch.Operation.INSERT -> {
|
||||
span {
|
||||
text = it.text
|
||||
textColor = ContextCompat.getColor(context, R.color.vector_success_color)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
span {
|
||||
text = it.text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
genericItem {
|
||||
id(timelineEvent.eventId)
|
||||
title(timelineDateFormatter.formatMessageHour(timelineEvent.localDateTime()))
|
||||
description(spannedDiff ?: body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCorrectContent(event: Event): Pair<String, String?> {
|
||||
val clearContent = event.getClearContent().toModel<MessageTextContent>()
|
||||
val newContent = clearContent
|
||||
?.newContent
|
||||
?.toModel<MessageTextContent>()
|
||||
return (newContent?.body ?: clearContent?.body ?: "") to (newContent?.formattedBody
|
||||
?: clearContent?.formattedBody)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* 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.features.home.room.detail.timeline.action
|
||||
|
||||
import com.airbnb.mvrx.*
|
||||
import com.squareup.inject.assisted.Assisted
|
||||
import com.squareup.inject.assisted.AssistedInject
|
||||
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
|
||||
import im.vector.riotx.core.platform.VectorViewModel
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFormatter
|
||||
|
||||
|
||||
data class ViewEditHistoryViewState(
|
||||
val eventId: String,
|
||||
val roomId: String,
|
||||
val editList: Async<List<Event>> = Uninitialized)
|
||||
: MvRxState {
|
||||
|
||||
constructor(args: TimelineEventFragmentArgs) : this(roomId = args.roomId, eventId = args.eventId)
|
||||
|
||||
}
|
||||
|
||||
class ViewEditHistoryViewModel @AssistedInject constructor(@Assisted
|
||||
initialState: ViewEditHistoryViewState,
|
||||
val session: Session,
|
||||
val timelineDateFormatter: TimelineDateFormatter
|
||||
) : VectorViewModel<ViewEditHistoryViewState>(initialState) {
|
||||
|
||||
private val roomId = initialState.roomId
|
||||
private val eventId = initialState.eventId
|
||||
private val room = session.getRoom(roomId)
|
||||
?: throw IllegalStateException("Shouldn't use this ViewModel without a room")
|
||||
|
||||
@AssistedInject.Factory
|
||||
interface Factory {
|
||||
fun create(initialState: ViewEditHistoryViewState): ViewEditHistoryViewModel
|
||||
}
|
||||
|
||||
|
||||
companion object : MvRxViewModelFactory<ViewEditHistoryViewModel, ViewEditHistoryViewState> {
|
||||
|
||||
override fun create(viewModelContext: ViewModelContext, state: ViewEditHistoryViewState): ViewEditHistoryViewModel? {
|
||||
val fragment: ViewEditHistoryBottomSheet = (viewModelContext as FragmentViewModelContext).fragment()
|
||||
return fragment.viewEditHistoryViewModelFactory.create(state)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
init {
|
||||
loadHistory()
|
||||
}
|
||||
|
||||
private fun loadHistory() {
|
||||
setState { copy(editList = Loading()) }
|
||||
room.fetchEditHistory(eventId, object : MatrixCallback<List<Event>> {
|
||||
override fun onFailure(failure: Throwable) {
|
||||
setState {
|
||||
copy(editList = Fail(failure))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSuccess(data: List<Event>) {
|
||||
//TODO until supported by API Add original event manually
|
||||
val withOriginal = data.toMutableList()
|
||||
room.getTimeLineEvent(eventId)?.let {
|
||||
withOriginal.add(it.root)
|
||||
}
|
||||
setState {
|
||||
copy(editList = Success(withOriginal))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -21,7 +21,6 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import butterknife.BindView
|
||||
import butterknife.ButterKnife
|
||||
|
@ -33,7 +32,7 @@ import im.vector.riotx.EmojiCompatFontProvider
|
|||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.di.ScreenComponent
|
||||
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
|
||||
import kotlinx.android.synthetic.main.bottom_sheet_display_reactions.*
|
||||
import kotlinx.android.synthetic.main.bottom_sheet_epoxylist_with_title.*
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
@ -49,14 +48,16 @@ class ViewReactionBottomSheet : VectorBaseBottomSheetDialogFragment() {
|
|||
@BindView(R.id.bottom_sheet_display_reactions_list)
|
||||
lateinit var epoxyRecyclerView: EpoxyRecyclerView
|
||||
|
||||
private val epoxyController by lazy { ViewReactionsEpoxyController(emojiCompatFontProvider.typeface) }
|
||||
private val epoxyController by lazy {
|
||||
ViewReactionsEpoxyController(requireContext(), emojiCompatFontProvider.typeface)
|
||||
}
|
||||
|
||||
override fun injectWith(screenComponent: ScreenComponent) {
|
||||
screenComponent.inject(this)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val view = inflater.inflate(R.layout.bottom_sheet_display_reactions, container, false)
|
||||
val view = inflater.inflate(R.layout.bottom_sheet_epoxylist_with_title, container, false)
|
||||
ButterKnife.bind(this, view)
|
||||
return view
|
||||
}
|
||||
|
@ -67,16 +68,11 @@ class ViewReactionBottomSheet : VectorBaseBottomSheetDialogFragment() {
|
|||
val dividerItemDecoration = DividerItemDecoration(epoxyRecyclerView.context,
|
||||
LinearLayout.VERTICAL)
|
||||
epoxyRecyclerView.addItemDecoration(dividerItemDecoration)
|
||||
bottomSheetTitle.text = context?.getString(R.string.reactions)
|
||||
}
|
||||
|
||||
|
||||
override fun invalidate() = withState(viewModel) {
|
||||
if (it.mapReactionKeyToMemberList() == null) {
|
||||
bottomSheetViewReactionSpinner.isVisible = true
|
||||
bottomSheetViewReactionSpinner.animate()
|
||||
} else {
|
||||
bottomSheetViewReactionSpinner.isVisible = false
|
||||
}
|
||||
epoxyController.setData(it)
|
||||
}
|
||||
|
||||
|
|
|
@ -16,24 +16,47 @@
|
|||
|
||||
package im.vector.riotx.features.home.room.detail.timeline.action
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import com.airbnb.epoxy.TypedEpoxyController
|
||||
import com.airbnb.mvrx.Fail
|
||||
import com.airbnb.mvrx.Incomplete
|
||||
import com.airbnb.mvrx.Success
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.ui.list.genericFooterItem
|
||||
import im.vector.riotx.core.ui.list.genericLoaderItem
|
||||
|
||||
/**
|
||||
* Epoxy controller for reaction event list
|
||||
*/
|
||||
class ViewReactionsEpoxyController(private val emojiCompatTypeface: Typeface?) : TypedEpoxyController<DisplayReactionsViewState>() {
|
||||
class ViewReactionsEpoxyController(private val context: Context, private val emojiCompatTypeface: Typeface?)
|
||||
: TypedEpoxyController<DisplayReactionsViewState>() {
|
||||
|
||||
override fun buildModels(state: DisplayReactionsViewState) {
|
||||
val map = state.mapReactionKeyToMemberList() ?: return
|
||||
map.forEach {
|
||||
reactionInfoSimpleItem {
|
||||
id(it.eventId)
|
||||
emojiTypeFace(emojiCompatTypeface)
|
||||
timeStamp(it.timestamp)
|
||||
reactionKey(it.reactionKey)
|
||||
authorDisplayName(it.authorName ?: it.authorId)
|
||||
when (state.mapReactionKeyToMemberList) {
|
||||
is Incomplete -> {
|
||||
genericLoaderItem {
|
||||
id("Spinner")
|
||||
}
|
||||
}
|
||||
is Fail -> {
|
||||
genericFooterItem {
|
||||
id("failure")
|
||||
text(context.getString(R.string.unknown_error))
|
||||
}
|
||||
}
|
||||
is Success -> {
|
||||
state.mapReactionKeyToMemberList()?.forEach {
|
||||
reactionInfoSimpleItem {
|
||||
id(it.eventId)
|
||||
emojiTypeFace(emojiCompatTypeface)
|
||||
timeStamp(it.timestamp)
|
||||
reactionKey(it.reactionKey)
|
||||
authorDisplayName(it.authorName ?: it.authorId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -7,23 +7,14 @@
|
|||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bottomSheetTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="8dp"
|
||||
android:text="@string/reactions"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/bottomSheetViewReactionSpinner"
|
||||
style="?android:attr/progressBarStyleSmall"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
android:textSize="16sp"
|
||||
tools:text="@string/reactions" />
|
||||
|
||||
<com.airbnb.epoxy.EpoxyRecyclerView
|
||||
android:id="@+id/bottom_sheet_display_reactions_list"
|
|
@ -0,0 +1,14 @@
|
|||
<?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/itemGenericHeaderText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:textColor="?vctr_notice_text_color"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Today" />
|
|
@ -0,0 +1,6 @@
|
|||
<ProgressBar xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/genericProgressSpinner"
|
||||
style="?android:attr/progressBarStyleSmall"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp" />
|
|
@ -21,4 +21,8 @@
|
|||
<string name="riotx_no_registration_notice_colored_part">Use the old app</string>
|
||||
|
||||
|
||||
|
||||
<string name="message_edits">Message Edits</string>
|
||||
<string name="no_message_edits_found">No edits found</string>
|
||||
|
||||
</resources>
|
Loading…
Reference in New Issue