diff --git a/CHANGES.md b/CHANGES.md index a01b74169b..e468229200 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/build.gradle b/build.gradle index 91415088e5..4fa468ab26 100644 --- a/build.gradle +++ b/build.gradle @@ -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" + } + } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt index 81d7ddd4c0..7ffbd5f1c9 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt @@ -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> + + } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt index d6d2d9cc6c..cbd4d0c674 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt @@ -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/" -} \ No newline at end of file +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt index af26397046..361a935d2f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt @@ -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. * diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt index 143ef60b46..98cf872b10 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt @@ -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, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt index 09322e6a1a..942239ea12 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt @@ -142,4 +142,7 @@ internal abstract class RoomModule { @Binds abstract fun bindFileService(fileService: DefaultFileService): FileService + + @Binds + abstract fun bindFetchEditHistoryTask(editHistoryTask: DefaultFetchEditHistoryTask): FetchEditHistoryTask } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt index 3eb1c066a8..1b487d960d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt @@ -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) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/FetchEditHistoryTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/FetchEditHistoryTask.kt new file mode 100644 index 0000000000..e891ece8da --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/FetchEditHistoryTask.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/RelationsResponse.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/RelationsResponse.kt new file mode 100644 index 0000000000..5d39e81c48 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/RelationsResponse.kt @@ -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? +) \ No newline at end of file diff --git a/vector/build.gradle b/vector/build.gradle index ace3135ae5..d7332c476c 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -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' diff --git a/vector/src/main/assets/open_source_licenses.html b/vector/src/main/assets/open_source_licenses.html index cec7c7183d..9e87d466af 100755 --- a/vector/src/main/assets/open_source_licenses.html +++ b/vector/src/main/assets/open_source_licenses.html @@ -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 diff --git a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt index 06f6512939..611ee171bd 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt @@ -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) diff --git a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt index 234d4a0caf..37abde20b8 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt @@ -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 diff --git a/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItem.kt b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItem.kt index ebaeb2d39e..f0a62ccd5a 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItem.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItem.kt @@ -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 diff --git a/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItemHeader.kt b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItemHeader.kt new file mode 100644 index 0000000000..3c9ce20de3 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItemHeader.kt @@ -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) + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/core/ui/list/GenericLoaderItem.kt b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericLoaderItem.kt new file mode 100644 index 0000000000..56daca223e --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericLoaderItem.kt @@ -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() +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt index d52b16ca04..ace0802e09 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt @@ -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() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index f611fe9973..943b6165e0 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -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 diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 2a19914a8c..36b989fe31 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -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)) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryBottomSheet.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryBottomSheet.kt new file mode 100644 index 0000000000..aefbde431a --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryBottomSheet.kt @@ -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 } + + } + } +} + diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryEpoxyController.kt new file mode 100644 index 0000000000..4ae62fbd93 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryEpoxyController.kt @@ -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) + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryViewModel.kt new file mode 100644 index 0000000000..64005c3fa3 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewEditHistoryViewModel.kt @@ -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)) + } + } + }) + } + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionBottomSheet.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionBottomSheet.kt index 760b74daf6..d7e41784ea 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionBottomSheet.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionBottomSheet.kt @@ -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) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionsEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionsEpoxyController.kt index 57c3d26528..74b3f4925f 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionsEpoxyController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/ViewReactionsEpoxyController.kt @@ -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) + } + } } } + } } \ No newline at end of file diff --git a/vector/src/main/res/layout/bottom_sheet_display_reactions.xml b/vector/src/main/res/layout/bottom_sheet_epoxylist_with_title.xml similarity index 71% rename from vector/src/main/res/layout/bottom_sheet_display_reactions.xml rename to vector/src/main/res/layout/bottom_sheet_epoxylist_with_title.xml index 0f5b63654d..9b3ffb26a3 100644 --- a/vector/src/main/res/layout/bottom_sheet_display_reactions.xml +++ b/vector/src/main/res/layout/bottom_sheet_epoxylist_with_title.xml @@ -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" diff --git a/vector/src/main/res/layout/item_generic_header.xml b/vector/src/main/res/layout/item_generic_header.xml new file mode 100644 index 0000000000..0d2cf8ebc5 --- /dev/null +++ b/vector/src/main/res/layout/item_generic_header.xml @@ -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" /> \ No newline at end of file diff --git a/vector/src/main/res/layout/item_generic_loader.xml b/vector/src/main/res/layout/item_generic_loader.xml new file mode 100644 index 0000000000..53460c4e7a --- /dev/null +++ b/vector/src/main/res/layout/item_generic_loader.xml @@ -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" /> \ No newline at end of file diff --git a/vector/src/main/res/values/strings_riotX.xml b/vector/src/main/res/values/strings_riotX.xml index 7c1d39c4df..9a20356ed1 100644 --- a/vector/src/main/res/values/strings_riotX.xml +++ b/vector/src/main/res/values/strings_riotX.xml @@ -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> \ No newline at end of file