Merge pull request #615 from vector-im/feature/report_content

Add ability to report content
This commit is contained in:
Benoit Marty 2019-10-21 18:13:44 +02:00 committed by GitHub
commit 9d7c4abb97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1413 additions and 967 deletions

View file

@ -9,6 +9,7 @@ Improvements:
- Do not upload file too big for the homeserver (#587)
- Handle read markers (#84)
- Mark all messages as read (#396)
- Add ability to report content (#515)
Other changes:
- Accessibility improvements to read receipts in the room timeline and reactions emoji chooser

View file

@ -21,6 +21,7 @@ import im.vector.matrix.android.api.session.room.crypto.RoomCryptoService
import im.vector.matrix.android.api.session.room.members.MembershipService
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.relation.RelationService
import im.vector.matrix.android.api.session.room.reporting.ReportingService
import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.send.DraftService
import im.vector.matrix.android.api.session.room.send.SendService
@ -38,6 +39,7 @@ interface Room :
ReadService,
MembershipService,
StateService,
ReportingService,
RelationService,
RoomCryptoService {

View file

@ -0,0 +1,32 @@
/*
* 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.api.session.room.reporting
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.util.Cancelable
/**
* This interface defines methods to report content of an event.
*/
interface ReportingService {
/**
* Report content
* Ref: https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-rooms-roomid-report-eventid
*/
fun reportContent(eventId: String, score: Int, reason: String, callback: MatrixCallback<Unit>): Cancelable
}

View file

@ -24,6 +24,7 @@ import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.api.session.room.members.MembershipService
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.relation.RelationService
import im.vector.matrix.android.api.session.room.reporting.ReportingService
import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.send.DraftService
import im.vector.matrix.android.api.session.room.send.SendService
@ -44,18 +45,20 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
private val sendService: SendService,
private val draftService: DraftService,
private val stateService: StateService,
private val reportingService: ReportingService,
private val readService: ReadService,
private val cryptoService: CryptoService,
private val relationService: RelationService,
private val roomMembersService: MembershipService
) : Room,
TimelineService by timelineService,
SendService by sendService,
DraftService by draftService,
StateService by stateService,
ReadService by readService,
RelationService by relationService,
MembershipService by roomMembersService {
private val roomMembersService: MembershipService) :
Room,
TimelineService by timelineService,
SendService by sendService,
DraftService by draftService,
StateService by stateService,
ReportingService by reportingService,
ReadService by readService,
RelationService by relationService,
MembershipService by roomMembersService {
override fun getRoomSummaryLive(): LiveData<Optional<RoomSummary>> {
val liveData = monarchy.findAllMappedWithChanges(

View file

@ -27,6 +27,7 @@ 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.internal.session.room.relation.RelationsResponse
import im.vector.matrix.android.internal.session.room.reporting.ReportContentBody
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
@ -245,4 +246,16 @@ internal interface RoomAPI {
@Path("eventId") parent_id: String,
@Body reason: Map<String, String>
): Call<SendResponse>
/**
* Reports an event as inappropriate to the server, which may then notify the appropriate people.
*
* @param roomId the room id
* @param eventId the event to report content
* @param body body containing score and reason
*/
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/report/{eventId}")
fun reportContent(@Path("roomId") roomId: String,
@Path("eventId") eventId: String,
@Body body: ReportContentBody): Call<Unit>
}

View file

@ -24,6 +24,7 @@ import im.vector.matrix.android.internal.session.room.draft.DefaultDraftService
import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService
import im.vector.matrix.android.internal.session.room.read.DefaultReadService
import im.vector.matrix.android.internal.session.room.relation.DefaultRelationService
import im.vector.matrix.android.internal.session.room.reporting.DefaultReportingService
import im.vector.matrix.android.internal.session.room.send.DefaultSendService
import im.vector.matrix.android.internal.session.room.state.DefaultStateService
import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineService
@ -40,6 +41,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
private val sendServiceFactory: DefaultSendService.Factory,
private val draftServiceFactory: DefaultDraftService.Factory,
private val stateServiceFactory: DefaultStateService.Factory,
private val reportingServiceFactory: DefaultReportingService.Factory,
private val readServiceFactory: DefaultReadService.Factory,
private val relationServiceFactory: DefaultRelationService.Factory,
private val membershipServiceFactory: DefaultMembershipService.Factory) :
@ -54,6 +56,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
sendServiceFactory.create(roomId),
draftServiceFactory.create(roomId),
stateServiceFactory.create(roomId),
reportingServiceFactory.create(roomId),
readServiceFactory.create(roomId),
cryptoService,
relationServiceFactory.create(roomId),

View file

@ -45,6 +45,8 @@ import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkers
import im.vector.matrix.android.internal.session.room.read.MarkAllRoomsReadTask
import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask
import im.vector.matrix.android.internal.session.room.relation.*
import im.vector.matrix.android.internal.session.room.reporting.DefaultReportContentTask
import im.vector.matrix.android.internal.session.room.reporting.ReportContentTask
import im.vector.matrix.android.internal.session.room.state.DefaultSendStateTask
import im.vector.matrix.android.internal.session.room.state.SendStateTask
import im.vector.matrix.android.internal.session.room.timeline.*
@ -114,6 +116,9 @@ internal abstract class RoomModule {
@Binds
abstract fun bindSendStateTask(sendStateTask: DefaultSendStateTask): SendStateTask
@Binds
abstract fun bindReportContentTask(reportContentTask: DefaultReportContentTask): ReportContentTask
@Binds
abstract fun bindGetContextOfEventTask(getContextOfEventTask: DefaultGetContextOfEventTask): GetContextOfEventTask

View file

@ -0,0 +1,46 @@
/*
* 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.reporting
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.room.reporting.ReportingService
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
internal class DefaultReportingService @AssistedInject constructor(@Assisted private val roomId: String,
private val taskExecutor: TaskExecutor,
private val reportContentTask: ReportContentTask
) : ReportingService {
@AssistedInject.Factory
interface Factory {
fun create(roomId: String): ReportingService
}
override fun reportContent(eventId: String, score: Int, reason: String, callback: MatrixCallback<Unit>): Cancelable {
val params = ReportContentTask.Params(roomId, eventId, score, reason)
return reportContentTask
.configureWith(params) {
this.callback = callback
}
.executeBy(taskExecutor)
}
}

View file

@ -0,0 +1,33 @@
/*
* 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.reporting
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class ReportContentBody(
/**
* Required. The score to rate this content as where -100 is most offensive and 0 is inoffensive.
*/
@Json(name = "score") val score: Int,
/**
* Required. The reason the content is being reported. May be blank.
*/
@Json(name = "reason") val reason: String
)

View file

@ -0,0 +1,39 @@
/*
* 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.reporting
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 ReportContentTask : Task<ReportContentTask.Params, Unit> {
data class Params(
val roomId: String,
val eventId: String,
val score: Int,
val reason: String
)
}
internal class DefaultReportContentTask @Inject constructor(private val roomAPI: RoomAPI) : ReportContentTask {
override suspend fun execute(params: ReportContentTask.Params) {
return executeRequest {
apiCall = roomAPI.reportContent(params.roomId, params.eventId, ReportContentBody(params.score, params.reason))
}
}
}

View file

@ -42,6 +42,8 @@ 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.readreceipts.DisplayReadReceiptsBottomSheet
import im.vector.riotx.features.home.room.detail.timeline.action.*
import im.vector.riotx.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet
import im.vector.riotx.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet
import im.vector.riotx.features.home.room.filtered.FilteredRoomsActivity
import im.vector.riotx.features.home.room.list.RoomListFragment
import im.vector.riotx.features.invite.VectorInviteView
@ -103,12 +105,10 @@ interface ScreenComponent {
fun inject(messageActionsBottomSheet: MessageActionsBottomSheet)
fun inject(viewReactionBottomSheet: ViewReactionBottomSheet)
fun inject(viewReactionsBottomSheet: ViewReactionsBottomSheet)
fun inject(viewEditHistoryBottomSheet: ViewEditHistoryBottomSheet)
fun inject(messageMenuFragment: MessageMenuFragment)
fun inject(vectorSettingsActivity: VectorSettingsActivity)
fun inject(createRoomFragment: CreateRoomFragment)
@ -135,8 +135,6 @@ interface ScreenComponent {
fun inject(sasVerificationIncomingFragment: SASVerificationIncomingFragment)
fun inject(quickReactionFragment: QuickReactionFragment)
fun inject(emojiReactionPickerActivity: EmojiReactionPickerActivity)
fun inject(loginActivity: LoginActivity)

View file

@ -0,0 +1,27 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.core.dialogs
import androidx.annotation.ColorRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import im.vector.riotx.R
fun AlertDialog.withColoredButton(whichButton: Int, @ColorRes color: Int = R.color.vector_error_color): AlertDialog {
getButton(whichButton)?.setTextColor(ContextCompat.getColor(context, color))
return this
}

View file

@ -13,18 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.action
package im.vector.riotx.core.platform
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.os.Parcelable
import android.widget.FrameLayout
import androidx.annotation.CallSuper
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.MvRxView
import com.airbnb.mvrx.MvRxViewModelStore
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import im.vector.riotx.core.di.DaggerScreenComponent
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.core.utils.DimensionConverter
import java.util.*
/**
@ -37,10 +42,14 @@ abstract class VectorBaseBottomSheetDialogFragment : BottomSheetDialogFragment()
private lateinit var screenComponent: ScreenComponent
final override val mvrxViewId: String by lazy { mvrxPersistedViewId }
private var bottomSheetBehavior: BottomSheetBehavior<FrameLayout>? = null
val vectorBaseActivity: VectorBaseActivity by lazy {
activity as VectorBaseActivity
}
open val showExpanded = false
override fun onAttach(context: Context) {
screenComponent = DaggerScreenComponent.factory().create(vectorBaseActivity.getVectorComponent(), vectorBaseActivity)
super.onAttach(context)
@ -57,6 +66,17 @@ abstract class VectorBaseBottomSheetDialogFragment : BottomSheetDialogFragment()
super.onCreate(savedInstanceState)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return super.onCreateDialog(savedInstanceState).apply {
val dialog = this as? BottomSheetDialog
bottomSheetBehavior = dialog?.behavior
bottomSheetBehavior?.setPeekHeight(DimensionConverter(resources).dpToPx(400), false)
if (showExpanded) {
bottomSheetBehavior?.state = BottomSheetBehavior.STATE_EXPANDED
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mvrxViewModelStore.saveViewModels(outState)
@ -70,6 +90,14 @@ abstract class VectorBaseBottomSheetDialogFragment : BottomSheetDialogFragment()
postInvalidate()
}
@CallSuper
override fun invalidate() {
if (showExpanded) {
// Force the bottom sheet to be expanded
bottomSheetBehavior?.state = BottomSheetBehavior.STATE_EXPANDED
}
}
protected fun setArguments(args: Parcelable? = null) {
arguments = args?.let { Bundle().apply { putParcelable(MvRx.KEY_ARG, it) } }
}

View file

@ -49,6 +49,9 @@ sealed class RoomDetailActions {
data class ResendMessage(val eventId: String) : RoomDetailActions()
data class RemoveFailedEcho(val eventId: String) : RoomDetailActions()
data class ReportContent(val eventId: String, val reason: String, val spam: Boolean = false, val inappropriate: Boolean = false) : RoomDetailActions()
object ClearSendQueue : RoomDetailActions()
object ResendAll : RoomDetailActions()
}

View file

@ -19,6 +19,7 @@ package im.vector.riotx.features.home.room.detail
import android.annotation.SuppressLint
import android.app.Activity.RESULT_OK
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.drawable.ColorDrawable
import android.net.Uri
@ -50,6 +51,7 @@ import com.airbnb.mvrx.*
import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.ImageLoader
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText
import com.jaiselrahman.filepicker.activity.FilePickerActivity
import com.jaiselrahman.filepicker.config.Configurations
import com.jaiselrahman.filepicker.model.MediaFile
@ -68,6 +70,7 @@ import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.dialogs.withColoredButton
import im.vector.riotx.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.extensions.hideKeyboard
@ -93,8 +96,12 @@ 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.readreceipts.DisplayReadReceiptsBottomSheet
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.action.*
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.SimpleAction
import im.vector.riotx.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet
import im.vector.riotx.features.home.room.detail.timeline.item.*
import im.vector.riotx.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet
import im.vector.riotx.features.html.EventHtmlRenderer
import im.vector.riotx.features.html.PillImageSpan
import im.vector.riotx.features.invite.VectorInviteView
@ -264,6 +271,10 @@ class RoomDetailFragment :
roomDetailViewModel.selectSubscribe(RoomDetailViewState::syncState) { syncState ->
syncStateView.render(syncState)
}
roomDetailViewModel.requestLiveData.observeEvent(this) {
displayRoomDetailActionResult(it)
}
}
override fun onDestroy() {
@ -766,13 +777,77 @@ class RoomDetailFragment :
}
private fun displayCommandError(message: String) {
AlertDialog.Builder(activity!!)
AlertDialog.Builder(requireActivity())
.setTitle(R.string.command_error)
.setMessage(message)
.setPositiveButton(R.string.ok, null)
.show()
}
private fun promptReasonToReportContent(action: SimpleAction.ReportContentCustom) {
val inflater = requireActivity().layoutInflater
val layout = inflater.inflate(R.layout.dialog_report_content, null)
val input = layout.findViewById<TextInputEditText>(R.id.dialog_report_content_input)
AlertDialog.Builder(requireActivity())
.setTitle(R.string.report_content_custom_title)
.setView(layout)
.setPositiveButton(R.string.report_content_custom_submit) { _, _ ->
val reason = input.text.toString()
roomDetailViewModel.process(RoomDetailActions.ReportContent(action.eventId, reason))
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun displayRoomDetailActionResult(result: Async<RoomDetailActions>) {
when (result) {
is Fail -> {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.dialog_title_error)
.setMessage(errorFormatter.toHumanReadable(result.error))
.setPositiveButton(R.string.ok, null)
.show()
}
is Success -> {
when (val data = result.invoke()) {
is RoomDetailActions.ReportContent -> {
when {
data.spam -> {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.content_reported_as_spam_title)
.setMessage(R.string.content_reported_as_spam_content)
.setPositiveButton(R.string.ok, null)
.setNegativeButton(R.string.block_user) { _, _ -> vectorBaseActivity.notImplemented("block user") }
.show()
.withColoredButton(DialogInterface.BUTTON_NEGATIVE)
}
data.inappropriate -> {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.content_reported_as_inappropriate_title)
.setMessage(R.string.content_reported_as_inappropriate_content)
.setPositiveButton(R.string.ok, null)
.setNegativeButton(R.string.block_user) { _, _ -> vectorBaseActivity.notImplemented("block user") }
.show()
.withColoredButton(DialogInterface.BUTTON_NEGATIVE)
}
else -> {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.content_reported_title)
.setMessage(R.string.content_reported_content)
.setPositiveButton(R.string.ok, null)
.setNegativeButton(R.string.block_user) { _, _ -> vectorBaseActivity.notImplemented("block user") }
.show()
.withColoredButton(DialogInterface.BUTTON_NEGATIVE)
}
}
}
}
}
}
}
// TimelineEventController.Callback ************************************************************
override fun onUrlClicked(url: String): Boolean {
@ -917,7 +992,7 @@ class RoomDetailFragment :
}
override fun onLongClickOnReactionPill(informationData: MessageInformationData, reaction: String) {
ViewReactionBottomSheet.newInstance(roomDetailArgs.roomId, informationData)
ViewReactionsBottomSheet.newInstance(roomDetailArgs.roomId, informationData)
.show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS")
}
@ -966,23 +1041,23 @@ class RoomDetailFragment :
private fun handleActions(action: SimpleAction) {
when (action) {
is SimpleAction.AddReaction -> {
is SimpleAction.AddReaction -> {
startActivityForResult(EmojiReactionPickerActivity.intent(requireContext(), action.eventId), REACTION_SELECT_REQUEST_CODE)
}
is SimpleAction.ViewReactions -> {
ViewReactionBottomSheet.newInstance(roomDetailArgs.roomId, action.messageInformationData)
is SimpleAction.ViewReactions -> {
ViewReactionsBottomSheet.newInstance(roomDetailArgs.roomId, action.messageInformationData)
.show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS")
}
is SimpleAction.Copy -> {
is SimpleAction.Copy -> {
// I need info about the current selected message :/
copyToClipboard(requireContext(), action.content, false)
val msg = requireContext().getString(R.string.copied_to_clipboard)
showSnackWithMessage(msg, Snackbar.LENGTH_SHORT)
}
is SimpleAction.Delete -> {
is SimpleAction.Delete -> {
roomDetailViewModel.process(RoomDetailActions.RedactAction(action.eventId, context?.getString(R.string.event_redacted_by_user_reason)))
}
is SimpleAction.Share -> {
is SimpleAction.Share -> {
// TODO current data communication is too limited
// Need to now the media type
// TODO bad, just POC
@ -1010,10 +1085,10 @@ class RoomDetailFragment :
}
)
}
is SimpleAction.ViewEditHistory -> {
is SimpleAction.ViewEditHistory -> {
onEditedDecorationClicked(action.messageInformationData)
}
is SimpleAction.ViewSource -> {
is SimpleAction.ViewSource -> {
val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null)
view.findViewById<TextView>(R.id.event_content_text_view)?.let {
it.text = action.content
@ -1024,7 +1099,7 @@ class RoomDetailFragment :
.setPositiveButton(R.string.ok, null)
.show()
}
is SimpleAction.ViewDecryptedSource -> {
is SimpleAction.ViewDecryptedSource -> {
val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null)
view.findViewById<TextView>(R.id.event_content_text_view)?.let {
it.text = action.content
@ -1035,31 +1110,40 @@ class RoomDetailFragment :
.setPositiveButton(R.string.ok, null)
.show()
}
is SimpleAction.QuickReact -> {
is SimpleAction.QuickReact -> {
// eventId,ClickedOn,Add
roomDetailViewModel.process(RoomDetailActions.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
}
is SimpleAction.Edit -> {
is SimpleAction.Edit -> {
roomDetailViewModel.process(RoomDetailActions.EnterEditMode(action.eventId, composerLayout.composerEditText.text.toString()))
}
is SimpleAction.Quote -> {
is SimpleAction.Quote -> {
roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(action.eventId, composerLayout.composerEditText.text.toString()))
}
is SimpleAction.Reply -> {
is SimpleAction.Reply -> {
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(action.eventId, composerLayout.composerEditText.text.toString()))
}
is SimpleAction.CopyPermalink -> {
is SimpleAction.CopyPermalink -> {
val permalink = PermalinkFactory.createPermalink(roomDetailArgs.roomId, action.eventId)
copyToClipboard(requireContext(), permalink, false)
showSnackWithMessage(requireContext().getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT)
}
is SimpleAction.Resend -> {
is SimpleAction.Resend -> {
roomDetailViewModel.process(RoomDetailActions.ResendMessage(action.eventId))
}
is SimpleAction.Remove -> {
is SimpleAction.Remove -> {
roomDetailViewModel.process(RoomDetailActions.RemoveFailedEcho(action.eventId))
}
else -> {
is SimpleAction.ReportContentSpam -> {
roomDetailViewModel.process(RoomDetailActions.ReportContent(action.eventId, "This message is spam", spam = true))
}
is SimpleAction.ReportContentInappropriate -> {
roomDetailViewModel.process(RoomDetailActions.ReportContent(action.eventId, "This message is inappropriate", inappropriate = true))
}
is SimpleAction.ReportContentCustom -> {
promptReasonToReportContent(action)
}
else -> {
Toast.makeText(context, "Action $action is not implemented yet", Toast.LENGTH_LONG).show()
}
}

View file

@ -21,10 +21,7 @@ import android.text.TextUtils
import androidx.annotation.IdRes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.ViewModelContext
import com.airbnb.mvrx.*
import com.jakewharton.rxrelay2.BehaviorRelay
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
@ -97,6 +94,11 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
private var timeline = room.createTimeline(eventId, timelineSettings)
// Can be used for several actions, for a one shot result
private val _requestLiveData = MutableLiveData<LiveEvent<Async<RoomDetailActions>>>()
val requestLiveData: LiveData<LiveEvent<Async<RoomDetailActions>>>
get() = _requestLiveData
// Slot to keep a pending action during permission request
var pendingAction: RoomDetailActions? = null
@ -155,6 +157,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is RoomDetailActions.ResendAll -> handleResendAll()
is RoomDetailActions.SetReadMarkerAction -> handleSetReadMarkerAction(action)
is RoomDetailActions.MarkAllAsRead -> handleMarkAllAsRead()
is RoomDetailActions.ReportContent -> handleReportContent(action)
}
}
@ -708,6 +711,18 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
room.markAllAsRead(object : MatrixCallback<Any> {})
}
private fun handleReportContent(action: RoomDetailActions.ReportContent) {
room.reportContent(action.eventId, -100, action.reason, object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
_requestLiveData.postValue(LiveEvent(Success(action)))
}
override fun onFailure(failure: Throwable) {
_requestLiveData.postValue(LiveEvent(Fail(failure)))
}
})
}
private fun observeSyncState() {
session.rx()
.liveSyncState()

View file

@ -21,19 +21,18 @@ import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import butterknife.BindView
import butterknife.ButterKnife
import com.airbnb.epoxy.EpoxyRecyclerView
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.args
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.features.home.room.detail.timeline.action.VectorBaseBottomSheetDialogFragment
import im.vector.riotx.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.bottom_sheet_epoxylist_with_title.*
import kotlinx.android.synthetic.main.bottom_sheet_generic_list_with_title.*
import javax.inject.Inject
@Parcelize
@ -48,8 +47,8 @@ class DisplayReadReceiptsBottomSheet : VectorBaseBottomSheetDialogFragment() {
@Inject lateinit var epoxyController: DisplayReadReceiptsController
@BindView(R.id.bottom_sheet_display_reactions_list)
lateinit var epoxyRecyclerView: EpoxyRecyclerView
@BindView(R.id.bottomSheetRecyclerView)
lateinit var recyclerView: RecyclerView
private val displayReadReceiptArgs: DisplayReadReceiptArgs by args()
@ -58,24 +57,20 @@ class DisplayReadReceiptsBottomSheet : VectorBaseBottomSheetDialogFragment() {
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.bottom_sheet_epoxylist_with_title, container, false)
val view = inflater.inflate(R.layout.bottom_sheet_generic_list_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)
recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
recyclerView.adapter = epoxyController.adapter
bottomSheetTitle.text = getString(R.string.read_at)
epoxyController.setData(displayReadReceiptArgs.readReceipts)
}
override fun invalidate() {
// we are not using state for this one as it's static
}
// we are not using state for this one as it's static, so no need to override invalidate()
companion object {
fun newInstance(readReceipts: List<ReadReceiptData>): DisplayReadReceiptsBottomSheet {

View file

@ -0,0 +1,69 @@
/*
* 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.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.core.view.isVisible
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
/**
* A action for bottom sheet.
*/
@EpoxyModelClass(layout = R.layout.item_bottom_sheet_action)
abstract class BottomSheetItemAction : VectorEpoxyModel<BottomSheetItemAction.Holder>() {
@EpoxyAttribute
@DrawableRes
var iconRes: Int = 0
@EpoxyAttribute
var textRes: Int = 0
@EpoxyAttribute
var showExpand = false
@EpoxyAttribute
var expanded = false
@EpoxyAttribute
var subMenuItem = false
@EpoxyAttribute
lateinit var listener: View.OnClickListener
override fun bind(holder: Holder) {
holder.view.setOnClickListener {
listener.onClick(it)
}
holder.startSpace.isVisible = subMenuItem
holder.icon.setImageResource(iconRes)
holder.text.setText(textRes)
holder.expand.isVisible = showExpand
if (showExpand) {
holder.expand.setImageResource(if (expanded) R.drawable.ic_material_expand_less_black else R.drawable.ic_material_expand_more_black)
}
}
class Holder : VectorEpoxyHolder() {
val startSpace by bind<View>(R.id.action_start_space)
val icon by bind<ImageView>(R.id.action_icon)
val text by bind<TextView>(R.id.action_title)
val expand by bind<ImageView>(R.id.action_expand)
}
}

View file

@ -0,0 +1,59 @@
/*
* 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.widget.ImageView
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
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
/**
* A message preview for bottom sheet.
*/
@EpoxyModelClass(layout = R.layout.item_bottom_sheet_message_preview)
abstract class BottomSheetItemMessagePreview : VectorEpoxyModel<BottomSheetItemMessagePreview.Holder>() {
@EpoxyAttribute
lateinit var avatarRenderer: AvatarRenderer
@EpoxyAttribute
lateinit var informationData: MessageInformationData
@EpoxyAttribute
var senderName: String? = null
@EpoxyAttribute
lateinit var body: CharSequence
@EpoxyAttribute
var time: CharSequence? = null
override fun bind(holder: Holder) {
avatarRenderer.render(informationData.avatarUrl, informationData.senderId, senderName, holder.avatar)
holder.sender.setTextOrHide(senderName)
holder.body.text = body
holder.timestamp.setTextOrHide(time)
}
class Holder : VectorEpoxyHolder() {
val avatar by bind<ImageView>(R.id.bottom_sheet_message_preview_avatar)
val sender by bind<TextView>(R.id.bottom_sheet_message_preview_sender)
val body by bind<TextView>(R.id.bottom_sheet_message_preview_body)
val timestamp by bind<TextView>(R.id.bottom_sheet_message_preview_timestamp)
}
}

View file

@ -0,0 +1,80 @@
/*
* 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.graphics.Typeface
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.EmojiCompatFontProvider
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel
/**
* A quick reaction list for bottom sheet.
*/
@EpoxyModelClass(layout = R.layout.item_bottom_sheet_quick_reaction)
abstract class BottomSheetItemQuickReactions : VectorEpoxyModel<BottomSheetItemQuickReactions.Holder>() {
@EpoxyAttribute
lateinit var fontProvider: EmojiCompatFontProvider
@EpoxyAttribute
lateinit var texts: List<String>
@EpoxyAttribute
lateinit var selecteds: List<Boolean>
@EpoxyAttribute
var listener: Listener? = null
override fun bind(holder: Holder) {
holder.textViews.forEachIndexed { index, textView ->
textView.typeface = fontProvider.typeface ?: Typeface.DEFAULT
textView.text = texts[index]
textView.alpha = if (selecteds[index]) 0.2f else 1f
textView.setOnClickListener {
listener?.didSelect(texts[index], !selecteds[index])
}
}
}
class Holder : VectorEpoxyHolder() {
private val quickReaction0 by bind<TextView>(R.id.quickReaction0)
private val quickReaction1 by bind<TextView>(R.id.quickReaction1)
private val quickReaction2 by bind<TextView>(R.id.quickReaction2)
private val quickReaction3 by bind<TextView>(R.id.quickReaction3)
private val quickReaction4 by bind<TextView>(R.id.quickReaction4)
private val quickReaction5 by bind<TextView>(R.id.quickReaction5)
private val quickReaction6 by bind<TextView>(R.id.quickReaction6)
private val quickReaction7 by bind<TextView>(R.id.quickReaction7)
val textViews
get() = listOf(
quickReaction0,
quickReaction1,
quickReaction2,
quickReaction3,
quickReaction4,
quickReaction5,
quickReaction6,
quickReaction7
)
}
interface Listener {
fun didSelect(emoji: String, selected: Boolean)
}
}

View file

@ -0,0 +1,52 @@
/*
* 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.view.View
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.core.view.isVisible
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
/**
* A send state for bottom sheet.
*/
@EpoxyModelClass(layout = R.layout.item_bottom_sheet_message_status)
abstract class BottomSheetItemSendState : VectorEpoxyModel<BottomSheetItemSendState.Holder>() {
@EpoxyAttribute
var showProgress: Boolean = false
@EpoxyAttribute
lateinit var text: CharSequence
@EpoxyAttribute
@DrawableRes
var drawableStart: Int = 0
override fun bind(holder: Holder) {
holder.progress.isVisible = showProgress
holder.text.setCompoundDrawablesWithIntrinsicBounds(drawableStart, 0, 0, 0)
holder.text.text = text
}
class Holder : VectorEpoxyHolder() {
val progress by bind<View>(R.id.messageStatusProgress)
val text by bind<TextView>(R.id.messageStatusText)
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.action
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel
@EpoxyModelClass(layout = R.layout.item_bottom_sheet_divider)
abstract class BottomSheetItemSeparator : VectorEpoxyModel<BottomSheetItemSeparator.Holder>() {
class Holder : VectorEpoxyHolder()
}

View file

@ -15,62 +15,46 @@
*/
package im.vector.riotx.features.home.room.detail.timeline.action
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import butterknife.BindView
import butterknife.ButterKnife
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import kotlinx.android.synthetic.main.bottom_sheet_message_actions.*
import javax.inject.Inject
/**
* Bottom sheet fragment that shows a message preview with list of contextual actions
* (Includes fragments for quick reactions and list of actions)
*/
class MessageActionsBottomSheet : VectorBaseBottomSheetDialogFragment() {
class MessageActionsBottomSheet : VectorBaseBottomSheetDialogFragment(), MessageActionsEpoxyController.MessageActionsEpoxyControllerListener {
@Inject lateinit var messageActionViewModelFactory: MessageActionsViewModel.Factory
@Inject lateinit var messageActionsEpoxyController: MessageActionsEpoxyController
@BindView(R.id.bottomSheetRecyclerView)
lateinit var recyclerView: RecyclerView
@Inject
lateinit var messageActionViewModelFactory: MessageActionsViewModel.Factory
@Inject
lateinit var avatarRenderer: AvatarRenderer
private val viewModel: MessageActionsViewModel by fragmentViewModel(MessageActionsViewModel::class)
override val showExpanded = true
private lateinit var actionHandlerModel: ActionsHandler
@BindView(R.id.bottom_sheet_message_preview_avatar)
lateinit var senderAvatarImageView: ImageView
@BindView(R.id.bottom_sheet_message_preview_sender)
lateinit var senderNameTextView: TextView
@BindView(R.id.bottom_sheet_message_preview_timestamp)
lateinit var messageTimestampText: TextView
@BindView(R.id.bottom_sheet_message_preview_body)
lateinit var messageBodyTextView: TextView
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_message_actions, container, false)
val view = inflater.inflate(R.layout.bottom_sheet_generic_list, container, false)
ButterKnife.bind(this, view)
return view
}
@ -78,78 +62,26 @@ class MessageActionsBottomSheet : VectorBaseBottomSheetDialogFragment() {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
actionHandlerModel = ViewModelProviders.of(requireActivity()).get(ActionsHandler::class.java)
val cfm = childFragmentManager
var menuActionFragment = cfm.findFragmentByTag("MenuActionFragment") as? MessageMenuFragment
if (menuActionFragment == null) {
menuActionFragment = MessageMenuFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as TimelineEventFragmentArgs)
cfm.beginTransaction()
.replace(R.id.bottom_sheet_menu_container, menuActionFragment, "MenuActionFragment")
.commit()
}
menuActionFragment.interactionListener = object : MessageMenuFragment.InteractionListener {
override fun didSelectMenuAction(simpleAction: SimpleAction) {
actionHandlerModel.fireAction(simpleAction)
dismiss()
}
}
var quickReactionFragment = cfm.findFragmentByTag("QuickReaction") as? QuickReactionFragment
if (quickReactionFragment == null) {
quickReactionFragment = QuickReactionFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as TimelineEventFragmentArgs)
cfm.beginTransaction()
.replace(R.id.bottom_sheet_quick_reaction_container, quickReactionFragment, "QuickReaction")
.commit()
}
quickReactionFragment.interactionListener = object : QuickReactionFragment.InteractionListener {
override fun didQuickReactWith(clickedOn: String, add: Boolean, eventId: String) {
actionHandlerModel.fireAction(SimpleAction.QuickReact(eventId, clickedOn, add))
dismiss()
}
}
recyclerView.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false)
recyclerView.adapter = messageActionsEpoxyController.adapter
// Disable item animation
recyclerView.itemAnimator = null
messageActionsEpoxyController.listener = this
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
// We want to force the bottom sheet initial state to expanded
(dialog as? BottomSheetDialog)?.let { bottomSheetDialog ->
bottomSheetDialog.setOnShowListener { dialog ->
val d = dialog as BottomSheetDialog
(d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet) as? FrameLayout)?.let {
BottomSheetBehavior.from(it).state = BottomSheetBehavior.STATE_COLLAPSED
}
}
override fun didSelectMenuAction(simpleAction: SimpleAction) {
if (simpleAction is SimpleAction.ReportContent) {
// Toggle report menu
viewModel.toggleReportMenu()
} else {
actionHandlerModel.fireAction(simpleAction)
dismiss()
}
return dialog
}
override fun invalidate() = withState(viewModel) {
val body = viewModel.resolveBody(it)
if (body != null) {
bottom_sheet_message_preview.isVisible = true
senderNameTextView.text = it.senderName()
messageBodyTextView.text = body
messageTimestampText.text = it.time()
avatarRenderer.render(it.informationData.avatarUrl, it.informationData.senderId, it.senderName(), senderAvatarImageView)
} else {
bottom_sheet_message_preview.isVisible = false
}
quickReactBottomDivider.isVisible = it.canReact()
bottom_sheet_quick_reaction_container.isVisible = it.canReact()
if (it.informationData.sendState.isSending()) {
messageStatusInfo.isVisible = true
messageStatusProgress.isVisible = true
messageStatusText.text = getString(R.string.event_status_sending_message)
messageStatusText.setCompoundDrawables(null, null, null, null)
} else if (it.informationData.sendState.hasFailed()) {
messageStatusInfo.isVisible = true
messageStatusProgress.isVisible = false
messageStatusText.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_warning_small, 0, 0, 0)
messageStatusText.text = getString(R.string.unable_to_send_message)
} else {
messageStatusInfo.isVisible = false
}
return@withState
messageActionsEpoxyController.setData(it)
super.invalidate()
}
companion object {

View file

@ -0,0 +1,124 @@
/*
* 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.view.View
import com.airbnb.epoxy.TypedEpoxyController
import com.airbnb.mvrx.Success
import im.vector.riotx.EmojiCompatFontProvider
import im.vector.riotx.R
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.home.AvatarRenderer
import javax.inject.Inject
/**
* Epoxy controller for message action list
*/
class MessageActionsEpoxyController @Inject constructor(private val stringProvider: StringProvider,
private val avatarRenderer: AvatarRenderer,
private val fontProvider: EmojiCompatFontProvider) : TypedEpoxyController<MessageActionState>() {
var listener: MessageActionsEpoxyControllerListener? = null
override fun buildModels(state: MessageActionState) {
// Message preview
val body = state.messageBody
if (body != null) {
bottomSheetItemMessagePreview {
id("preview")
avatarRenderer(avatarRenderer)
informationData(state.informationData)
senderName(state.senderName())
body(body)
time(state.time())
}
}
// Send state
if (state.informationData.sendState.isSending()) {
bottomSheetItemSendState {
id("send_state")
showProgress(true)
text(stringProvider.getString(R.string.event_status_sending_message))
}
} else if (state.informationData.sendState.hasFailed()) {
bottomSheetItemSendState {
id("send_state")
showProgress(false)
text(stringProvider.getString(R.string.unable_to_send_message))
drawableStart(R.drawable.ic_warning_small)
}
}
// Quick reactions
if (state.canReact() && state.quickStates is Success) {
// Separator
bottomSheetItemSeparator {
id("reaction_separator")
}
bottomSheetItemQuickReactions {
id("quick_reaction")
fontProvider(fontProvider)
texts(state.quickStates()?.map { it.reaction }.orEmpty())
selecteds(state.quickStates.invoke().map { it.isSelected })
listener(object : BottomSheetItemQuickReactions.Listener {
override fun didSelect(emoji: String, selected: Boolean) {
listener?.didSelectMenuAction(SimpleAction.QuickReact(state.eventId, emoji, selected))
}
})
}
}
// Separator
bottomSheetItemSeparator {
id("actions_separator")
}
// Action
state.actions()?.forEachIndexed { index, action ->
bottomSheetItemAction {
id("action_$index")
iconRes(action.iconResId)
textRes(action.titleRes)
showExpand(action is SimpleAction.ReportContent)
expanded(state.expendedReportContentMenu)
listener(View.OnClickListener { listener?.didSelectMenuAction(action) })
}
if (action is SimpleAction.ReportContent && state.expendedReportContentMenu) {
// Special case for report content menu: add the submenu
listOf(
SimpleAction.ReportContentSpam(action.eventId),
SimpleAction.ReportContentInappropriate(action.eventId),
SimpleAction.ReportContentCustom(action.eventId)
).forEachIndexed { indexReport, actionReport ->
bottomSheetItemAction {
id("actionReport_$indexReport")
subMenuItem(true)
iconRes(actionReport.iconResId)
textRes(actionReport.titleRes)
listener(View.OnClickListener { listener?.didSelectMenuAction(actionReport) })
}
}
}
}
}
interface MessageActionsEpoxyControllerListener {
fun didSelectMenuAction(simpleAction: SimpleAction)
}
}

View file

@ -21,26 +21,48 @@ import com.squareup.inject.assisted.AssistedInject
import dagger.Lazy
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.isTextMessage
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.android.api.session.room.timeline.hasBeenEdited
import im.vector.matrix.android.api.util.Optional
import im.vector.matrix.rx.RxRoom
import im.vector.matrix.rx.unwrap
import im.vector.riotx.R
import im.vector.riotx.core.extensions.canReact
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.home.room.detail.timeline.format.NoticeEventFormatter
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.html.EventHtmlRenderer
import java.text.SimpleDateFormat
import java.util.*
/**
* Quick reactions state
*/
data class ToggleState(
val reaction: String,
val isSelected: Boolean
)
data class MessageActionState(
val roomId: String,
val eventId: String,
val informationData: MessageInformationData,
val timelineEvent: Async<TimelineEvent> = Uninitialized
val timelineEvent: Async<TimelineEvent> = Uninitialized,
val messageBody: CharSequence? = null,
// For quick reactions
val quickStates: Async<List<ToggleState>> = Uninitialized,
// For actions
val actions: Async<List<SimpleAction>> = Uninitialized,
val expendedReportContentMenu: Boolean = false
) : MvRxState {
constructor(args: TimelineEventFragmentArgs) : this(roomId = args.roomId, eventId = args.eventId, informationData = args.informationData)
@ -49,18 +71,101 @@ data class MessageActionState(
fun senderName(): String = informationData.memberName?.toString() ?: ""
fun time(): String? = timelineEvent()?.root?.originServerTs?.let { dateFormat.format(Date(it)) }
?: ""
fun time(): String? = timelineEvent()?.root?.originServerTs?.let { dateFormat.format(Date(it)) } ?: ""
fun canReact() = timelineEvent()?.canReact() == true
}
fun messageBody(eventHtmlRenderer: EventHtmlRenderer?, noticeEventFormatter: NoticeEventFormatter?): CharSequence? {
/**
* Information related to an event and used to display preview in contextual bottomsheet.
*/
class MessageActionsViewModel @AssistedInject constructor(@Assisted
initialState: MessageActionState,
private val eventHtmlRenderer: Lazy<EventHtmlRenderer>,
private val session: Session,
private val noticeEventFormatter: NoticeEventFormatter,
private val stringProvider: StringProvider
) : VectorViewModel<MessageActionState>(initialState) {
private val eventId = initialState.eventId
private val informationData = initialState.informationData
private val room = session.getRoom(initialState.roomId)
@AssistedInject.Factory
interface Factory {
fun create(initialState: MessageActionState): MessageActionsViewModel
}
companion object : MvRxViewModelFactory<MessageActionsViewModel, MessageActionState> {
val quickEmojis = listOf("👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀")
override fun create(viewModelContext: ViewModelContext, state: MessageActionState): MessageActionsViewModel? {
val fragment: MessageActionsBottomSheet = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.messageActionViewModelFactory.create(state)
}
}
init {
observeEvent()
observeReactions()
observeEventAction()
}
fun toggleReportMenu() = withState {
setState {
copy(
expendedReportContentMenu = it.expendedReportContentMenu.not()
)
}
}
private fun observeEvent() {
if (room == null) return
RxRoom(room)
.liveTimelineEvent(eventId)
.unwrap()
.execute {
copy(
timelineEvent = it,
messageBody = computeMessageBody(it)
)
}
}
private fun observeEventAction() {
if (room == null) return
RxRoom(room)
.liveTimelineEvent(eventId)
.map {
actionsForEvent(it)
}
.execute {
copy(actions = it)
}
}
private fun observeReactions() {
if (room == null) return
RxRoom(room)
.liveAnnotationSummary(eventId)
.map { annotations ->
quickEmojis.map { emoji ->
ToggleState(emoji, annotations.getOrNull()?.reactionsSummary?.firstOrNull { it.key == emoji }?.addedByMe ?: false)
}
}
.execute {
copy(quickStates = it)
}
}
private fun computeMessageBody(timelineEvent: Async<TimelineEvent>): CharSequence? {
return when (timelineEvent()?.root?.getClearType()) {
EventType.MESSAGE -> {
val messageContent: MessageContent? = timelineEvent()?.getLastMessageContent()
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
eventHtmlRenderer?.render(messageContent.formattedBody
?: messageContent.body)
eventHtmlRenderer.get().render(messageContent.formattedBody
?: messageContent.body)
} else {
messageContent?.body
}
@ -72,54 +177,177 @@ data class MessageActionState(
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> {
timelineEvent()?.let { noticeEventFormatter?.format(it) }
timelineEvent()?.let { noticeEventFormatter.format(it) }
}
else -> null
}
}
}
/**
* Information related to an event and used to display preview in contextual bottomsheet.
*/
class MessageActionsViewModel @AssistedInject constructor(@Assisted
initialState: MessageActionState,
private val eventHtmlRenderer: Lazy<EventHtmlRenderer>,
session: Session,
private val noticeEventFormatter: NoticeEventFormatter
) : VectorViewModel<MessageActionState>(initialState) {
private fun actionsForEvent(optionalEvent: Optional<TimelineEvent>): List<SimpleAction> {
val event = optionalEvent.getOrNull() ?: return emptyList()
private val eventId = initialState.eventId
private val room = session.getRoom(initialState.roomId)
val messageContent: MessageContent? = event.annotations?.editSummary?.aggregatedContent.toModel()
?: event.root.getClearContent().toModel()
val type = messageContent?.type
@AssistedInject.Factory
interface Factory {
fun create(initialState: MessageActionState): MessageActionsViewModel
}
return arrayListOf<SimpleAction>().apply {
if (event.root.sendState.hasFailed()) {
if (canRetry(event)) {
add(SimpleAction.Resend(eventId))
}
add(SimpleAction.Remove(eventId))
} else if (event.root.sendState.isSending()) {
// TODO is uploading attachment?
if (canCancel(event)) {
add(SimpleAction.Cancel(eventId))
}
} else {
if (!event.root.isRedacted()) {
if (canReply(event, messageContent)) {
add(SimpleAction.Reply(eventId))
}
companion object : MvRxViewModelFactory<MessageActionsViewModel, MessageActionState> {
if (canEdit(event, session.myUserId)) {
add(SimpleAction.Edit(eventId))
}
override fun create(viewModelContext: ViewModelContext, state: MessageActionState): MessageActionsViewModel? {
val fragment: MessageActionsBottomSheet = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.messageActionViewModelFactory.create(state)
if (canRedact(event, session.myUserId)) {
add(SimpleAction.Delete(eventId))
}
if (canCopy(type)) {
// TODO copy images? html? see ClipBoard
add(SimpleAction.Copy(messageContent!!.body))
}
if (event.canReact()) {
add(SimpleAction.AddReaction(eventId))
}
if (canQuote(event, messageContent)) {
add(SimpleAction.Quote(eventId))
}
if (canViewReactions(event)) {
add(SimpleAction.ViewReactions(informationData))
}
if (event.hasBeenEdited()) {
add(SimpleAction.ViewEditHistory(informationData))
}
if (canShare(type)) {
if (messageContent is MessageImageContent) {
session.contentUrlResolver().resolveFullSize(messageContent.url)?.let { url ->
add(SimpleAction.Share(url))
}
}
// TODO
}
if (event.root.sendState == SendState.SENT) {
// TODO Can be redacted
// TODO sent by me or sufficient power level
}
}
add(SimpleAction.ViewSource(event.root.toContentStringWithIndent()))
if (event.isEncrypted()) {
val decryptedContent = event.root.toClearContentStringWithIndent()
?: stringProvider.getString(R.string.encryption_information_decryption_error)
add(SimpleAction.ViewDecryptedSource(decryptedContent))
}
add(SimpleAction.CopyPermalink(eventId))
if (session.myUserId != event.root.senderId && event.root.getClearType() == EventType.MESSAGE) {
// not sent by me
add(SimpleAction.ReportContent(eventId))
}
}
}
}
init {
observeEvent()
private fun canCancel(@Suppress("UNUSED_PARAMETER") event: TimelineEvent): Boolean {
return false
}
private fun observeEvent() {
if (room == null) return
RxRoom(room)
.liveTimelineEvent(eventId)
.unwrap()
.execute {
copy(timelineEvent = it)
}
private fun canReply(event: TimelineEvent, messageContent: MessageContent?): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false
return when (messageContent?.type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_FILE -> true
else -> false
}
}
fun resolveBody(state: MessageActionState): CharSequence? {
return state.messageBody(eventHtmlRenderer.get(), noticeEventFormatter)
private fun canQuote(event: TimelineEvent, messageContent: MessageContent?): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false
return when (messageContent?.type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
MessageType.FORMAT_MATRIX_HTML,
MessageType.MSGTYPE_LOCATION -> {
true
}
else -> false
}
}
private fun canRedact(event: TimelineEvent, myUserId: String): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false
// TODO if user is admin or moderator
return event.root.senderId == myUserId
}
private fun canRetry(event: TimelineEvent): Boolean {
return event.root.sendState.hasFailed() && event.root.isTextMessage()
}
private fun canViewReactions(event: TimelineEvent): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false
// TODO if user is admin or moderator
return event.annotations?.reactionsSummary?.isNotEmpty() ?: false
}
private fun canEdit(event: TimelineEvent, myUserId: String): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false
// TODO if user is admin or moderator
val messageContent = event.root.getClearContent().toModel<MessageContent>()
return event.root.senderId == myUserId && (
messageContent?.type == MessageType.MSGTYPE_TEXT
|| messageContent?.type == MessageType.MSGTYPE_EMOTE
)
}
private fun canCopy(type: String?): Boolean {
return when (type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
MessageType.FORMAT_MATRIX_HTML,
MessageType.MSGTYPE_LOCATION -> true
else -> false
}
}
private fun canShare(type: String?): Boolean {
return when (type) {
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_VIDEO -> true
else -> false
}
}
}

View file

@ -1,104 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.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.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
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.core.platform.VectorBaseFragment
import im.vector.riotx.features.themes.ThemeUtils
import javax.inject.Inject
/**
* Fragment showing the list of available contextual action for a given message.
*/
class MessageMenuFragment : VectorBaseFragment() {
@Inject lateinit var messageMenuViewModelFactory: MessageMenuViewModel.Factory
private val viewModel: MessageMenuViewModel by fragmentViewModel(MessageMenuViewModel::class)
private var addSeparators = false
var interactionListener: InteractionListener? = null
override fun injectWith(injector: ScreenComponent) {
injector.inject(this)
}
override fun getLayoutResId() = R.layout.fragment_message_menu
override fun invalidate() = withState(viewModel) { state ->
val linearLayout = view as? LinearLayout
if (linearLayout != null) {
val inflater = LayoutInflater.from(linearLayout.context)
linearLayout.removeAllViews()
var insertIndex = 0
val actions = state.actions()
actions?.forEachIndexed { index, action ->
inflateActionView(action, inflater, linearLayout)?.let {
it.setOnClickListener {
interactionListener?.didSelectMenuAction(action)
}
linearLayout.addView(it, insertIndex)
insertIndex++
if (addSeparators) {
if (index < actions.size - 1) {
linearLayout.addView(inflateSeparatorView(), insertIndex)
insertIndex++
}
}
}
}
}
}
private fun inflateActionView(action: SimpleAction, inflater: LayoutInflater, container: ViewGroup?): View? {
return inflater.inflate(R.layout.adapter_item_action, container, false)?.apply {
findViewById<ImageView>(R.id.action_icon)?.setImageResource(action.iconResId)
findViewById<TextView>(R.id.action_title)?.setText(action.titleRes)
}
}
private fun inflateSeparatorView(): View {
val frame = FrameLayout(requireContext())
frame.setBackgroundColor(ThemeUtils.getColor(requireContext(), R.attr.vctr_list_divider_color))
frame.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, requireContext().resources.displayMetrics.density.toInt())
return frame
}
interface InteractionListener {
fun didSelectMenuAction(simpleAction: SimpleAction)
}
companion object {
fun newInstance(pa: TimelineEventFragmentArgs): MessageMenuFragment {
val args = Bundle()
args.putParcelable(MvRx.KEY_ARG, pa)
val fragment = MessageMenuFragment()
fragment.arguments = args
return fragment
}
}
}

View file

@ -1,279 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.action
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.airbnb.mvrx.*
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.isTextMessage
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.hasBeenEdited
import im.vector.matrix.android.api.util.Optional
import im.vector.matrix.rx.RxRoom
import im.vector.riotx.R
import im.vector.riotx.core.extensions.canReact
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
sealed class SimpleAction(@StringRes val titleRes: Int, @DrawableRes val iconResId: Int) {
data class AddReaction(val eventId: String) : SimpleAction(R.string.message_add_reaction, R.drawable.ic_add_reaction)
data class Copy(val content: String) : SimpleAction(R.string.copy, R.drawable.ic_copy)
data class Edit(val eventId: String) : SimpleAction(R.string.edit, R.drawable.ic_edit)
data class Quote(val eventId: String) : SimpleAction(R.string.quote, R.drawable.ic_quote)
data class Reply(val eventId: String) : SimpleAction(R.string.reply, R.drawable.ic_reply)
data class Share(val imageUrl: String) : SimpleAction(R.string.share, R.drawable.ic_share)
data class Resend(val eventId: String) : SimpleAction(R.string.global_retry, R.drawable.ic_refresh_cw)
data class Remove(val eventId: String) : SimpleAction(R.string.remove, R.drawable.ic_trash)
data class Delete(val eventId: String) : SimpleAction(R.string.delete, R.drawable.ic_delete)
data class Cancel(val eventId: String) : SimpleAction(R.string.cancel, R.drawable.ic_close_round)
data class ViewSource(val content: String) : SimpleAction(R.string.view_source, R.drawable.ic_view_source)
data class ViewDecryptedSource(val content: String) : SimpleAction(R.string.view_decrypted_source, R.drawable.ic_view_source)
data class CopyPermalink(val eventId: String) : SimpleAction(R.string.permalink, R.drawable.ic_permalink)
data class Flag(val eventId: String) : SimpleAction(R.string.report_content, R.drawable.ic_flag)
data class QuickReact(val eventId: String, val clickedOn: String, val add: Boolean) : SimpleAction(0, 0)
data class ViewReactions(val messageInformationData: MessageInformationData) : SimpleAction(R.string.message_view_reaction, R.drawable.ic_view_reactions)
data class ViewEditHistory(val messageInformationData: MessageInformationData) :
SimpleAction(R.string.message_view_edit_history, R.drawable.ic_view_edit_history)
}
data class MessageMenuState(
val roomId: String,
val eventId: String,
val informationData: MessageInformationData,
val actions: Async<List<SimpleAction>> = Uninitialized
) : MvRxState {
constructor(args: TimelineEventFragmentArgs) : this(roomId = args.roomId, eventId = args.eventId, informationData = args.informationData)
}
/**
* Manages list actions for a given message (copy / paste / forward...)
*/
class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: MessageMenuState,
private val session: Session,
private val stringProvider: StringProvider) : VectorViewModel<MessageMenuState>(initialState) {
@AssistedInject.Factory
interface Factory {
fun create(initialState: MessageMenuState): MessageMenuViewModel
}
private val room = session.getRoom(initialState.roomId)
?: throw IllegalStateException("Shouldn't use this ViewModel without a room")
private val eventId = initialState.eventId
private val informationData: MessageInformationData = initialState.informationData
companion object : MvRxViewModelFactory<MessageMenuViewModel, MessageMenuState> {
override fun create(viewModelContext: ViewModelContext, state: MessageMenuState): MessageMenuViewModel? {
val fragment: MessageMenuFragment = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.messageMenuViewModelFactory.create(state)
}
}
init {
observeEvent()
}
private fun observeEvent() {
RxRoom(room)
.liveTimelineEvent(eventId)
.map {
actionsForEvent(it)
}
.execute {
copy(actions = it)
}
}
private fun actionsForEvent(optionalEvent: Optional<TimelineEvent>): List<SimpleAction> {
val event = optionalEvent.getOrNull() ?: return emptyList()
val messageContent: MessageContent? = event.annotations?.editSummary?.aggregatedContent.toModel()
?: event.root.getClearContent().toModel()
val type = messageContent?.type
return arrayListOf<SimpleAction>().apply {
if (event.root.sendState.hasFailed()) {
if (canRetry(event)) {
add(SimpleAction.Resend(eventId))
}
add(SimpleAction.Remove(eventId))
} else if (event.root.sendState.isSending()) {
// TODO is uploading attachment?
if (canCancel(event)) {
add(SimpleAction.Cancel(eventId))
}
} else {
if (!event.root.isRedacted()) {
if (canReply(event, messageContent)) {
add(SimpleAction.Reply(eventId))
}
if (canEdit(event, session.myUserId)) {
add(SimpleAction.Edit(eventId))
}
if (canRedact(event, session.myUserId)) {
add(SimpleAction.Delete(eventId))
}
if (canCopy(type)) {
// TODO copy images? html? see ClipBoard
add(SimpleAction.Copy(messageContent!!.body))
}
if (event.canReact()) {
add(SimpleAction.AddReaction(eventId))
}
if (canQuote(event, messageContent)) {
add(SimpleAction.Quote(eventId))
}
if (canViewReactions(event)) {
add(SimpleAction.ViewReactions(informationData))
}
if (event.hasBeenEdited()) {
add(SimpleAction.ViewEditHistory(informationData))
}
if (canShare(type)) {
if (messageContent is MessageImageContent) {
session.contentUrlResolver().resolveFullSize(messageContent.url)?.let { url ->
add(SimpleAction.Share(url))
}
}
// TODO
}
if (event.root.sendState == SendState.SENT) {
// TODO Can be redacted
// TODO sent by me or sufficient power level
}
}
add(SimpleAction.ViewSource(event.root.toContentStringWithIndent()))
if (event.isEncrypted()) {
val decryptedContent = event.root.toClearContentStringWithIndent()
?: stringProvider.getString(R.string.encryption_information_decryption_error)
add(SimpleAction.ViewDecryptedSource(decryptedContent))
}
add(SimpleAction.CopyPermalink(eventId))
if (session.myUserId != event.root.senderId && event.root.getClearType() == EventType.MESSAGE) {
// not sent by me
add(SimpleAction.Flag(eventId))
}
}
}
}
private fun canCancel(@Suppress("UNUSED_PARAMETER") event: TimelineEvent): Boolean {
return false
}
private fun canReply(event: TimelineEvent, messageContent: MessageContent?): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false
return when (messageContent?.type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_FILE -> true
else -> false
}
}
private fun canQuote(event: TimelineEvent, messageContent: MessageContent?): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false
return when (messageContent?.type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
MessageType.FORMAT_MATRIX_HTML,
MessageType.MSGTYPE_LOCATION -> {
true
}
else -> false
}
}
private fun canRedact(event: TimelineEvent, myUserId: String): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false
// TODO if user is admin or moderator
return event.root.senderId == myUserId
}
private fun canRetry(event: TimelineEvent): Boolean {
return event.root.sendState.hasFailed() && event.root.isTextMessage()
}
private fun canViewReactions(event: TimelineEvent): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false
// TODO if user is admin or moderator
return event.annotations?.reactionsSummary?.isNotEmpty() ?: false
}
private fun canEdit(event: TimelineEvent, myUserId: String): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false
// TODO if user is admin or moderator
val messageContent = event.root.getClearContent().toModel<MessageContent>()
return event.root.senderId == myUserId && (
messageContent?.type == MessageType.MSGTYPE_TEXT
|| messageContent?.type == MessageType.MSGTYPE_EMOTE
)
}
private fun canCopy(type: String?): Boolean {
return when (type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
MessageType.FORMAT_MATRIX_HTML,
MessageType.MSGTYPE_LOCATION -> true
else -> false
}
}
private fun canShare(type: String?): Boolean {
return when (type) {
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_VIDEO -> true
else -> false
}
}
}

View file

@ -1,89 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.action
import android.graphics.Typeface
import android.os.Bundle
import android.view.View
import android.widget.TextView
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.riotx.EmojiCompatFontProvider
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.platform.VectorBaseFragment
import kotlinx.android.synthetic.main.adapter_item_action_quick_reaction.*
import javax.inject.Inject
/**
* Quick Reaction Fragment (agree / like reactions)
*/
class QuickReactionFragment : VectorBaseFragment() {
private val viewModel: QuickReactionViewModel by fragmentViewModel(QuickReactionViewModel::class)
var interactionListener: InteractionListener? = null
@Inject lateinit var fontProvider: EmojiCompatFontProvider
@Inject lateinit var quickReactionViewModelFactory: QuickReactionViewModel.Factory
override fun getLayoutResId() = R.layout.adapter_item_action_quick_reaction
override fun injectWith(injector: ScreenComponent) {
injector.inject(this)
}
private lateinit var textViews: List<TextView>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
textViews = listOf(quickReaction0, quickReaction1, quickReaction2, quickReaction3,
quickReaction4, quickReaction5, quickReaction6, quickReaction7)
textViews.forEachIndexed { index, textView ->
textView.typeface = fontProvider.typeface ?: Typeface.DEFAULT
textView.setOnClickListener {
viewModel.didSelect(index)
}
}
}
override fun invalidate() = withState(viewModel) {
val quickReactionsStates = it.quickStates() ?: return@withState
quickReactionsStates.forEachIndexed { index, qs ->
textViews[index].text = qs.reaction
textViews[index].alpha = if (qs.isSelected) 0.2f else 1f
}
if (it.result != null) {
interactionListener?.didQuickReactWith(it.result.reaction, it.result.isSelected, it.eventId)
}
}
interface InteractionListener {
fun didQuickReactWith(clickedOn: String, add: Boolean, eventId: String)
}
companion object {
fun newInstance(pa: TimelineEventFragmentArgs): QuickReactionFragment {
val args = Bundle()
args.putParcelable(MvRx.KEY_ARG, pa)
val fragment = QuickReactionFragment()
fragment.arguments = args
return fragment
}
}
}

View file

@ -1,96 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.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.session.Session
import im.vector.matrix.rx.RxRoom
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
/**
* Quick reactions state, it's a toggle with 3rd state
*/
data class ToggleState(
val reaction: String,
val isSelected: Boolean
)
data class QuickReactionState(
val roomId: String,
val eventId: String,
val informationData: MessageInformationData,
val quickStates: Async<List<ToggleState>> = Uninitialized,
val result: ToggleState? = null
/** Pair of 'clickedOn' and current toggles state*/
) : MvRxState {
constructor(args: TimelineEventFragmentArgs) : this(roomId = args.roomId, eventId = args.eventId, informationData = args.informationData)
}
/**
* Quick reaction view model
*/
class QuickReactionViewModel @AssistedInject constructor(@Assisted initialState: QuickReactionState,
private val session: Session) : VectorViewModel<QuickReactionState>(initialState) {
@AssistedInject.Factory
interface Factory {
fun create(initialState: QuickReactionState): QuickReactionViewModel
}
private val room = session.getRoom(initialState.roomId)
private val eventId = initialState.eventId
companion object : MvRxViewModelFactory<QuickReactionViewModel, QuickReactionState> {
val quickEmojis = listOf("👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀")
override fun create(viewModelContext: ViewModelContext, state: QuickReactionState): QuickReactionViewModel? {
val fragment: QuickReactionFragment = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.quickReactionViewModelFactory.create(state)
}
}
init {
observeReactions()
}
private fun observeReactions() {
if (room == null) return
RxRoom(room)
.liveAnnotationSummary(eventId)
.map { annotations ->
quickEmojis.map { emoji ->
ToggleState(emoji, annotations.getOrNull()?.reactionsSummary?.firstOrNull { it.key == emoji }?.addedByMe
?: false)
}
}
.execute {
copy(quickStates = it)
}
}
fun didSelect(index: Int) = withState {
val selectedReaction = it.quickStates()?.get(index) ?: return@withState
val isSelected = selectedReaction.isSelected
setState {
copy(result = ToggleState(selectedReaction.reaction, !isSelected))
}
}
}

View file

@ -0,0 +1,46 @@
/*
* 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 androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import im.vector.riotx.R
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
sealed class SimpleAction(@StringRes val titleRes: Int, @DrawableRes val iconResId: Int) {
data class AddReaction(val eventId: String) : SimpleAction(R.string.message_add_reaction, R.drawable.ic_add_reaction)
data class Copy(val content: String) : SimpleAction(R.string.copy, R.drawable.ic_copy)
data class Edit(val eventId: String) : SimpleAction(R.string.edit, R.drawable.ic_edit)
data class Quote(val eventId: String) : SimpleAction(R.string.quote, R.drawable.ic_quote)
data class Reply(val eventId: String) : SimpleAction(R.string.reply, R.drawable.ic_reply)
data class Share(val imageUrl: String) : SimpleAction(R.string.share, R.drawable.ic_share)
data class Resend(val eventId: String) : SimpleAction(R.string.global_retry, R.drawable.ic_refresh_cw)
data class Remove(val eventId: String) : SimpleAction(R.string.remove, R.drawable.ic_trash)
data class Delete(val eventId: String) : SimpleAction(R.string.delete, R.drawable.ic_delete)
data class Cancel(val eventId: String) : SimpleAction(R.string.cancel, R.drawable.ic_close_round)
data class ViewSource(val content: String) : SimpleAction(R.string.view_source, R.drawable.ic_view_source)
data class ViewDecryptedSource(val content: String) : SimpleAction(R.string.view_decrypted_source, R.drawable.ic_view_source)
data class CopyPermalink(val eventId: String) : SimpleAction(R.string.permalink, R.drawable.ic_permalink)
data class ReportContent(val eventId: String) : SimpleAction(R.string.report_content, R.drawable.ic_flag)
data class ReportContentSpam(val eventId: String) : SimpleAction(R.string.report_content_spam, R.drawable.ic_report_spam)
data class ReportContentInappropriate(val eventId: String) : SimpleAction(R.string.report_content_inappropriate, R.drawable.ic_report_inappropriate)
data class ReportContentCustom(val eventId: String) : SimpleAction(R.string.report_content_custom, R.drawable.ic_report_custom)
data class QuickReact(val eventId: String, val clickedOn: String, val add: Boolean) : SimpleAction(0, 0)
data class ViewReactions(val messageInformationData: MessageInformationData) : SimpleAction(R.string.message_view_reaction, R.drawable.ic_view_reactions)
data class ViewEditHistory(val messageInformationData: MessageInformationData) :
SimpleAction(R.string.message_view_edit_history, R.drawable.ic_view_edit_history)
}

View file

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.action
package im.vector.riotx.features.home.room.detail.timeline.edithistory
import android.os.Bundle
import android.view.LayoutInflater
@ -21,17 +21,20 @@ import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
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.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.riotx.features.home.room.detail.timeline.action.TimelineEventFragmentArgs
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 kotlinx.android.synthetic.main.bottom_sheet_generic_list_with_title.*
import javax.inject.Inject
/**
@ -44,8 +47,8 @@ class ViewEditHistoryBottomSheet : VectorBaseBottomSheetDialogFragment() {
@Inject lateinit var viewEditHistoryViewModelFactory: ViewEditHistoryViewModel.Factory
@Inject lateinit var eventHtmlRenderer: EventHtmlRenderer
@BindView(R.id.bottom_sheet_display_reactions_list)
lateinit var epoxyRecyclerView: EpoxyRecyclerView
@BindView(R.id.bottomSheetRecyclerView)
lateinit var recyclerView: RecyclerView
private val epoxyController by lazy {
ViewEditHistoryEpoxyController(requireContext(), viewModel.dateFormatter, eventHtmlRenderer)
@ -56,22 +59,23 @@ class ViewEditHistoryBottomSheet : VectorBaseBottomSheetDialogFragment() {
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.bottom_sheet_epoxylist_with_title, container, false)
val view = inflater.inflate(R.layout.bottom_sheet_generic_list_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)
recyclerView.adapter = epoxyController.adapter
recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
val dividerItemDecoration = DividerItemDecoration(requireContext(), LinearLayout.VERTICAL)
recyclerView.addItemDecoration(dividerItemDecoration)
bottomSheetTitle.text = context?.getString(R.string.message_edits)
}
override fun invalidate() = withState(viewModel) {
epoxyController.setData(it)
super.invalidate()
}
companion object {

View file

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.action
package im.vector.riotx.features.home.room.detail.timeline.edithistory
import android.content.Context
import android.text.Spannable
@ -41,7 +41,7 @@ import timber.log.Timber
import java.util.*
/**
* Epoxy controller for reaction event list
* Epoxy controller for edit history list
*/
class ViewEditHistoryEpoxyController(private val context: Context,
val dateFormatter: VectorDateFormatter,

View file

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.action
package im.vector.riotx.features.home.room.detail.timeline.edithistory
import com.airbnb.mvrx.*
import com.squareup.inject.assisted.Assisted
@ -28,6 +28,7 @@ import im.vector.matrix.android.api.session.room.model.message.isReply
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.date.VectorDateFormatter
import im.vector.riotx.features.home.room.detail.timeline.action.TimelineEventFragmentArgs
import timber.log.Timber
import java.util.*

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.action
package im.vector.riotx.features.home.room.detail.timeline.reactions
import android.widget.TextView
import androidx.core.view.isVisible

View file

@ -14,37 +14,38 @@
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.action
package im.vector.riotx.features.home.room.detail.timeline.reactions
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 androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
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.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.riotx.features.home.room.detail.timeline.action.TimelineEventFragmentArgs
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import kotlinx.android.synthetic.main.bottom_sheet_epoxylist_with_title.*
import kotlinx.android.synthetic.main.bottom_sheet_generic_list_with_title.*
import javax.inject.Inject
/**
* Bottom sheet displaying list of reactions for a given event ordered by timestamp
*/
class ViewReactionBottomSheet : VectorBaseBottomSheetDialogFragment() {
class ViewReactionsBottomSheet : VectorBaseBottomSheetDialogFragment() {
private val viewModel: ViewReactionViewModel by fragmentViewModel(ViewReactionViewModel::class)
private val viewModel: ViewReactionsViewModel by fragmentViewModel(ViewReactionsViewModel::class)
@Inject lateinit var viewReactionViewModelFactory: ViewReactionViewModel.Factory
@Inject lateinit var viewReactionsViewModelFactory: ViewReactionsViewModel.Factory
@BindView(R.id.bottom_sheet_display_reactions_list)
lateinit var epoxyRecyclerView: EpoxyRecyclerView
@BindView(R.id.bottomSheetRecyclerView)
lateinit var recyclerView: RecyclerView
@Inject lateinit var epoxyController: ViewReactionsEpoxyController
@ -53,26 +54,25 @@ class ViewReactionBottomSheet : VectorBaseBottomSheetDialogFragment() {
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.bottom_sheet_epoxylist_with_title, container, false)
val view = inflater.inflate(R.layout.bottom_sheet_generic_list_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)
recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
recyclerView.adapter = epoxyController.adapter
bottomSheetTitle.text = context?.getString(R.string.reactions)
}
override fun invalidate() = withState(viewModel) {
epoxyController.setData(it)
super.invalidate()
}
companion object {
fun newInstance(roomId: String, informationData: MessageInformationData): ViewReactionBottomSheet {
fun newInstance(roomId: String, informationData: MessageInformationData): ViewReactionsBottomSheet {
val args = Bundle()
val parcelableArgs = TimelineEventFragmentArgs(
informationData.eventId,
@ -80,7 +80,7 @@ class ViewReactionBottomSheet : VectorBaseBottomSheetDialogFragment() {
informationData
)
args.putParcelable(MvRx.KEY_ARG, parcelableArgs)
return ViewReactionBottomSheet().apply { arguments = args }
return ViewReactionsBottomSheet().apply { arguments = args }
}
}
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.action
package im.vector.riotx.features.home.room.detail.timeline.reactions
import com.airbnb.epoxy.TypedEpoxyController
import com.airbnb.mvrx.Fail

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.action
package im.vector.riotx.features.home.room.detail.timeline.reactions
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.FragmentViewModelContext
@ -30,6 +30,7 @@ import im.vector.matrix.rx.RxRoom
import im.vector.matrix.rx.unwrap
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.date.VectorDateFormatter
import im.vector.riotx.features.home.room.detail.timeline.action.TimelineEventFragmentArgs
import io.reactivex.Observable
import io.reactivex.Single
@ -53,10 +54,10 @@ data class ReactionInfo(
/**
* Used to display the list of members that reacted to a given event
*/
class ViewReactionViewModel @AssistedInject constructor(@Assisted
class ViewReactionsViewModel @AssistedInject constructor(@Assisted
initialState: DisplayReactionsViewState,
private val session: Session,
private val dateFormatter: VectorDateFormatter
private val session: Session,
private val dateFormatter: VectorDateFormatter
) : VectorViewModel<DisplayReactionsViewState>(initialState) {
private val roomId = initialState.roomId
@ -66,14 +67,14 @@ class ViewReactionViewModel @AssistedInject constructor(@Assisted
@AssistedInject.Factory
interface Factory {
fun create(initialState: DisplayReactionsViewState): ViewReactionViewModel
fun create(initialState: DisplayReactionsViewState): ViewReactionsViewModel
}
companion object : MvRxViewModelFactory<ViewReactionViewModel, DisplayReactionsViewState> {
companion object : MvRxViewModelFactory<ViewReactionsViewModel, DisplayReactionsViewState> {
override fun create(viewModelContext: ViewModelContext, state: DisplayReactionsViewState): ViewReactionViewModel? {
val fragment: ViewReactionBottomSheet = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.viewReactionViewModelFactory.create(state)
override fun create(viewModelContext: ViewModelContext, state: DisplayReactionsViewState): ViewReactionsViewModel? {
val fragment: ViewReactionsBottomSheet = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.viewReactionsViewModelFactory.create(state)
}
}

View file

@ -0,0 +1,8 @@
<vector android:autoMirrored="true" android:height="22dp"
android:viewportHeight="22" android:viewportWidth="22"
android:width="22dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000" android:fillType="evenOdd"
android:pathData="M21,1L10,12M21,1l-7,20 -4,-9 -9,-4z"
android:strokeColor="#9E9E9E" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector android:autoMirrored="true" android:height="22dp"
android:viewportHeight="22" android:viewportWidth="22"
android:width="22dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000" android:fillType="evenOdd"
android:pathData="M11,11m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"
android:strokeColor="#9E9E9E" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:fillType="evenOdd"
android:pathData="M3.93,3.93l14.14,14.14"
android:strokeColor="#9E9E9E" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>

View file

@ -0,0 +1,8 @@
<vector android:autoMirrored="true" android:height="22dp"
android:viewportHeight="22" android:viewportWidth="18"
android:width="18dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000" android:fillType="evenOdd"
android:pathData="M1,14s1,-1 4,-1 5,2 8,2 4,-1 4,-1V2s-1,1 -4,1 -5,-2 -8,-2 -4,1 -4,1v12zM1,21v-7"
android:strokeColor="#9E9E9E" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/bottomSheetRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fadeScrollbars="false"
android:scrollbars="vertical"
tools:itemCount="5"
tools:listitem="@layout/item_bottom_sheet_action" />

View file

@ -3,29 +3,25 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="400dp"
android:orientation="vertical">
<TextView
android:id="@+id/bottomSheetTitle"
android:layout_width="match_parent"
android:layout_height="44dp"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:padding="8dp"
android:textColor="?android:textColorSecondary"
android:textSize="16sp"
tools:text="@string/reactions" />
<com.airbnb.epoxy.EpoxyRecyclerView
android:id="@+id/bottom_sheet_display_reactions_list"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/bottomSheetRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_height="match_parent"
android:fadeScrollbars="false"
android:orientation="vertical"
android:scrollbars="vertical"
tools:itemCount="15"
tools:listitem="@layout/item_simple_reaction_info">
tools:listitem="@layout/item_simple_reaction_info" />
</com.airbnb.epoxy.EpoxyRecyclerView>
</LinearLayout>

View file

@ -1,149 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/bottom_sheet_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/bottom_sheet_message_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/bottom_sheet_message_preview_avatar"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_margin="@dimen/layout_horizontal_margin"
android:adjustViewBounds="true"
android:background="@drawable/circle"
android:contentDescription="@string/avatar"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/bottom_sheet_message_preview_sender"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:layout_marginRight="@dimen/layout_horizontal_margin"
android:ellipsize="end"
android:fontFamily="sans-serif-bold"
android:singleLine="true"
android:textColor="?riotx_text_primary"
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar"
app:layout_constraintTop_toTopOf="@id/bottom_sheet_message_preview_avatar"
tools:text="@tools:sample/full_names" />
<TextView
android:id="@+id/bottom_sheet_message_preview_body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:layout_marginRight="@dimen/layout_horizontal_margin"
android:layout_marginBottom="4dp"
android:ellipsize="end"
android:maxLines="3"
android:textColor="?riotx_text_secondary"
android:textIsSelectable="false"
android:textSize="14sp"
app:layout_constraintBottom_toTopOf="@id/bottom_sheet_message_preview_timestamp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_sender"
tools:text="Quis harum id autem cumque consequatur laboriosam aliquam sed. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. " />
<TextView
android:id="@+id/bottom_sheet_message_preview_timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:layout_marginRight="@dimen/layout_horizontal_margin"
android:layout_marginBottom="8dp"
android:textColor="?riotx_text_secondary"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_body"
tools:text="Friday 8pm" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:id="@+id/messageStatusInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginBottom="4dp"
android:layout_marginEnd="16dp">
<ProgressBar
android:id="@+id/messageStatusProgress"
style="?android:attr/progressBarStyleSmall"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_gravity="center"
android:layout_marginEnd="4dp"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/messageStatusText"
android:textColor="?riotx_text_secondary"
android:textStyle="bold"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:drawableStart="@drawable/ic_warning_small"
android:drawablePadding="4dp"
tools:text="@string/unable_to_send_message" />
</LinearLayout>
<View
android:id="@+id/quickReactTopDivider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/vctr_list_divider_color" />
<FrameLayout
android:id="@+id/bottom_sheet_quick_reaction_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:background="@android:color/holo_green_light"
tools:layout_height="180dp" />
<View
android:id="@+id/quickReactBottomDivider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/vctr_list_divider_color" />
<FrameLayout
android:id="@+id/bottom_sheet_menu_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:background="@android:color/holo_blue_dark"
tools:layout_height="250dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/layout_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="?dialogPreferredPadding"
android:paddingLeft="?dialogPreferredPadding"
android:paddingTop="12dp"
android:paddingEnd="?dialogPreferredPadding"
android:paddingRight="?dialogPreferredPadding">
<com.google.android.material.textfield.TextInputLayout
style="@style/VectorTextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/report_content_custom_hint"
app:counterEnabled="true"
app:counterMaxLength="240">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/dialog_report_content_input"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="wrap_content"/>

View file

@ -2,23 +2,29 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="50dp"
android:orientation="horizontal"
android:paddingLeft="@dimen/layout_horizontal_margin"
android:paddingTop="8dp"
android:paddingRight="@dimen/layout_horizontal_margin"
android:paddingBottom="8dp"
tools:layout_height="50dp">
android:paddingBottom="8dp">
<Space
android:id="@+id/action_start_space"
android:layout_width="32dp"
android:layout_height="wrap_content"
android:visibility="gone"
tools:visibility="visible" />
<ImageView
android:id="@+id/action_icon"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="center_vertical"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:tint="?riotx_text_secondary"
@ -28,11 +34,19 @@
android:id="@+id/action_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:textColor="?riotx_text_secondary"
android:textSize="17sp"
tools:text="@string/delete" />
<ImageView
android:id="@+id/action_expand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:tint="?riotx_text_secondary"
android:visibility="gone"
tools:src="@drawable/ic_material_expand_more_black"
tools:visibility="visible" />
</LinearLayout>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/quickReactTopDivider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/vctr_list_divider_color" />

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/bottom_sheet_message_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/bottom_sheet_message_preview_avatar"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_margin="@dimen/layout_horizontal_margin"
android:adjustViewBounds="true"
android:background="@drawable/circle"
android:contentDescription="@string/avatar"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/bottom_sheet_message_preview_sender"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:layout_marginRight="@dimen/layout_horizontal_margin"
android:ellipsize="end"
android:fontFamily="sans-serif-bold"
android:singleLine="true"
android:textColor="?riotx_text_primary"
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar"
app:layout_constraintTop_toTopOf="@id/bottom_sheet_message_preview_avatar"
tools:text="@tools:sample/full_names" />
<TextView
android:id="@+id/bottom_sheet_message_preview_body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:layout_marginRight="@dimen/layout_horizontal_margin"
android:layout_marginBottom="4dp"
android:ellipsize="end"
android:maxLines="3"
android:textColor="?riotx_text_secondary"
android:textIsSelectable="false"
android:textSize="14sp"
app:layout_constraintBottom_toTopOf="@id/bottom_sheet_message_preview_timestamp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_sender"
tools:text="Quis harum id autem cumque consequatur laboriosam aliquam sed. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. " />
<TextView
android:id="@+id/bottom_sheet_message_preview_timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:layout_marginRight="@dimen/layout_horizontal_margin"
android:layout_marginBottom="8dp"
android:textColor="?riotx_text_secondary"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_body"
tools:text="Friday 8pm" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/messageStatusInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="4dp">
<ProgressBar
android:id="@+id/messageStatusProgress"
style="?android:attr/progressBarStyleSmall"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_gravity="center"
android:layout_marginEnd="4dp"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/messageStatusText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:drawableStart="@drawable/ic_warning_small"
android:drawablePadding="4dp"
android:textColor="?riotx_text_secondary"
android:textStyle="bold"
tools:text="@string/unable_to_send_message" />
</LinearLayout>

View file

@ -117,7 +117,16 @@
android:layout_marginBottom="4dp"
app:dividerDrawable="@drawable/reaction_divider"
app:flexWrap="wrap"
app:showDivider="middle" />
app:showDivider="middle"
tools:background="#F0E0F0"
tools:layout_height="40dp">
<!-- ReactionButtons will be added here in the code -->
<!--im.vector.riotx.features.reactions.widget.ReactionButton
android:layout_width="wrap_content"
android:layout_height="wrap_content" /-->
</com.google.android.flexbox.FlexboxLayout>
<im.vector.riotx.core.ui.views.ReadReceiptsView
android:id="@+id/readReceiptsView"

View file

@ -37,4 +37,19 @@
<string name="error_file_too_big">"The file '%1$s' (%2$s) is too large to upload. The limit is %3$s."</string>
<string name="report_content_spam">"It's spam"</string>
<string name="report_content_inappropriate">"It's inappropriate"</string>
<string name="report_content_custom">"Custom report"</string>
<string name="report_content_custom_title">"Report this content"</string>
<string name="report_content_custom_hint">"Reason for reporting this content"</string>
<string name="report_content_custom_submit">"REPORT"</string>
<string name="block_user">"BLOCK USER"</string>
<string name="content_reported_title">"Content reported"</string>
<string name="content_reported_content">"This content was reported.\n\nIf you don't want to see any more content from this user, you can block him to hide his messages"</string>
<string name="content_reported_as_spam_title">"Reported as spam"</string>
<string name="content_reported_as_spam_content">"This content was reported as spam.\n\nIf you don't want to see any more content from this user, you can block him to hide his messages"</string>
<string name="content_reported_as_inappropriate_title">"Reported as inappropriate"</string>
<string name="content_reported_as_inappropriate_content">"This content was reported as inappropriate.\n\nIf you don't want to see any more content from this user, you can block him to hide his messages"</string>
</resources>