forked from GitHub-Mirror/riotX-android
526 lines
22 KiB
Kotlin
526 lines
22 KiB
Kotlin
/*
|
|
* Copyright 2019 New Vector Ltd
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package im.vector.riotredesign.features.home.room.detail
|
|
|
|
import android.app.Activity.RESULT_OK
|
|
import android.content.Intent
|
|
import android.graphics.Color
|
|
import android.graphics.drawable.ColorDrawable
|
|
import android.net.Uri
|
|
import android.os.Bundle
|
|
import android.os.Parcelable
|
|
import android.text.Editable
|
|
import android.text.Spannable
|
|
import android.view.HapticFeedbackConstants
|
|
import android.view.LayoutInflater
|
|
import android.view.View
|
|
import android.widget.TextView
|
|
import android.widget.Toast
|
|
import androidx.appcompat.app.AlertDialog
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.lifecycle.Observer
|
|
import androidx.lifecycle.ViewModelProviders
|
|
import androidx.recyclerview.widget.LinearLayoutManager
|
|
import androidx.recyclerview.widget.RecyclerView
|
|
import com.airbnb.epoxy.EpoxyVisibilityTracker
|
|
import com.airbnb.mvrx.MvRx
|
|
import com.airbnb.mvrx.fragmentViewModel
|
|
import com.github.piasy.biv.BigImageViewer
|
|
import com.github.piasy.biv.loader.ImageLoader
|
|
import com.google.android.material.snackbar.Snackbar
|
|
import com.jaiselrahman.filepicker.activity.FilePickerActivity
|
|
import com.jaiselrahman.filepicker.config.Configurations
|
|
import com.jaiselrahman.filepicker.model.MediaFile
|
|
import com.otaliastudios.autocomplete.Autocomplete
|
|
import com.otaliastudios.autocomplete.AutocompleteCallback
|
|
import com.otaliastudios.autocomplete.CharPolicy
|
|
import im.vector.matrix.android.api.session.Session
|
|
import im.vector.matrix.android.api.session.room.model.message.*
|
|
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
|
import im.vector.matrix.android.api.session.user.model.User
|
|
import im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity
|
|
import im.vector.riotredesign.R
|
|
import im.vector.riotredesign.core.dialogs.DialogListItem
|
|
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
|
|
import im.vector.riotredesign.core.extensions.observeEvent
|
|
import im.vector.riotredesign.core.glide.GlideApp
|
|
import im.vector.riotredesign.core.platform.ToolbarConfigurable
|
|
import im.vector.riotredesign.core.platform.VectorBaseFragment
|
|
import im.vector.riotredesign.core.utils.*
|
|
import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandPresenter
|
|
import im.vector.riotredesign.features.autocomplete.command.CommandAutocompletePolicy
|
|
import im.vector.riotredesign.features.autocomplete.user.AutocompleteUserPresenter
|
|
import im.vector.riotredesign.features.command.Command
|
|
import im.vector.riotredesign.features.home.AvatarRenderer
|
|
import im.vector.riotredesign.features.home.HomeModule
|
|
import im.vector.riotredesign.features.home.HomePermalinkHandler
|
|
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerActions
|
|
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewModel
|
|
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewState
|
|
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
|
|
import im.vector.riotredesign.features.home.room.detail.timeline.action.ActionsHandler
|
|
import im.vector.riotredesign.features.home.room.detail.timeline.action.MessageActionsBottomSheet
|
|
import im.vector.riotredesign.features.home.room.detail.timeline.action.MessageMenuViewModel
|
|
import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
|
|
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
|
|
import im.vector.riotredesign.features.html.PillImageSpan
|
|
import im.vector.riotredesign.features.media.ImageContentRenderer
|
|
import im.vector.riotredesign.features.media.ImageMediaViewerActivity
|
|
import im.vector.riotredesign.features.media.VideoContentRenderer
|
|
import im.vector.riotredesign.features.media.VideoMediaViewerActivity
|
|
import kotlinx.android.parcel.Parcelize
|
|
import kotlinx.android.synthetic.main.fragment_room_detail.*
|
|
import org.koin.android.ext.android.inject
|
|
import org.koin.android.scope.ext.android.bindScope
|
|
import org.koin.android.scope.ext.android.getOrCreateScope
|
|
import org.koin.core.parameter.parametersOf
|
|
import timber.log.Timber
|
|
import java.io.File
|
|
|
|
|
|
@Parcelize
|
|
data class RoomDetailArgs(
|
|
val roomId: String,
|
|
val eventId: String? = null
|
|
) : Parcelable
|
|
|
|
|
|
private const val CAMERA_VALUE_TITLE = "attachment"
|
|
private const val REQUEST_FILES_REQUEST_CODE = 0
|
|
private const val TAKE_IMAGE_REQUEST_CODE = 1
|
|
|
|
class RoomDetailFragment :
|
|
VectorBaseFragment(),
|
|
TimelineEventController.Callback,
|
|
AutocompleteUserPresenter.Callback {
|
|
|
|
companion object {
|
|
|
|
fun newInstance(args: RoomDetailArgs): RoomDetailFragment {
|
|
return RoomDetailFragment().apply {
|
|
setArguments(args)
|
|
}
|
|
}
|
|
}
|
|
|
|
private val session by inject<Session>()
|
|
private val glideRequests by lazy {
|
|
GlideApp.with(this)
|
|
}
|
|
|
|
private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel()
|
|
private val textComposerViewModel: TextComposerViewModel by fragmentViewModel()
|
|
private val timelineEventController: TimelineEventController by inject { parametersOf(this) }
|
|
private val autocompleteCommandPresenter: AutocompleteCommandPresenter by inject { parametersOf(this) }
|
|
private val autocompleteUserPresenter: AutocompleteUserPresenter by inject { parametersOf(this) }
|
|
private val homePermalinkHandler: HomePermalinkHandler by inject()
|
|
|
|
private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback
|
|
|
|
override fun getLayoutResId() = R.layout.fragment_room_detail
|
|
|
|
lateinit var actionViewModel: ActionsHandler
|
|
|
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
|
super.onActivityCreated(savedInstanceState)
|
|
actionViewModel = ViewModelProviders.of(requireActivity()).get(ActionsHandler::class.java)
|
|
bindScope(getOrCreateScope(HomeModule.ROOM_DETAIL_SCOPE))
|
|
setupRecyclerView()
|
|
setupToolbar()
|
|
setupComposer()
|
|
setupAttachmentButton()
|
|
roomDetailViewModel.subscribe { renderState(it) }
|
|
textComposerViewModel.subscribe { renderTextComposerState(it) }
|
|
roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) }
|
|
|
|
actionViewModel.actionCommandEvent.observe(this, Observer {
|
|
handleActions(it)
|
|
})
|
|
}
|
|
|
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
|
super.onActivityResult(requestCode, resultCode, data)
|
|
if (resultCode == RESULT_OK && data != null) {
|
|
when (requestCode) {
|
|
REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data)
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
roomDetailViewModel.process(RoomDetailActions.IsDisplayed)
|
|
}
|
|
|
|
// PRIVATE METHODS *****************************************************************************
|
|
|
|
private fun setupToolbar() {
|
|
val parentActivity = vectorBaseActivity
|
|
if (parentActivity is ToolbarConfigurable) {
|
|
parentActivity.configure(toolbar)
|
|
}
|
|
}
|
|
|
|
private fun setupRecyclerView() {
|
|
val epoxyVisibilityTracker = EpoxyVisibilityTracker()
|
|
epoxyVisibilityTracker.attach(recyclerView)
|
|
val layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true)
|
|
val stateRestorer = LayoutManagerStateRestorer(layoutManager).register()
|
|
scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager)
|
|
recyclerView.layoutManager = layoutManager
|
|
recyclerView.itemAnimator = null
|
|
recyclerView.setHasFixedSize(true)
|
|
timelineEventController.addModelBuildListener {
|
|
it.dispatchTo(stateRestorer)
|
|
it.dispatchTo(scrollOnNewMessageCallback)
|
|
}
|
|
|
|
recyclerView.addOnScrollListener(
|
|
EndlessRecyclerViewScrollListener(layoutManager, RoomDetailViewModel.PAGINATION_COUNT) { direction ->
|
|
roomDetailViewModel.process(RoomDetailActions.LoadMore(direction))
|
|
})
|
|
recyclerView.setController(timelineEventController)
|
|
timelineEventController.callback = this
|
|
}
|
|
|
|
private fun setupComposer() {
|
|
val elevation = 6f
|
|
val backgroundDrawable = ColorDrawable(Color.WHITE)
|
|
Autocomplete.on<Command>(composerEditText)
|
|
.with(CommandAutocompletePolicy())
|
|
.with(autocompleteCommandPresenter)
|
|
.with(elevation)
|
|
.with(backgroundDrawable)
|
|
.with(object : AutocompleteCallback<Command> {
|
|
override fun onPopupItemClicked(editable: Editable, item: Command): Boolean {
|
|
editable.clear()
|
|
editable
|
|
.append(item.command)
|
|
.append(" ")
|
|
return true
|
|
}
|
|
|
|
override fun onPopupVisibilityChanged(shown: Boolean) {
|
|
}
|
|
})
|
|
.build()
|
|
|
|
autocompleteUserPresenter.callback = this
|
|
Autocomplete.on<User>(composerEditText)
|
|
.with(CharPolicy('@', true))
|
|
.with(autocompleteUserPresenter)
|
|
.with(elevation)
|
|
.with(backgroundDrawable)
|
|
.with(object : AutocompleteCallback<User> {
|
|
override fun onPopupItemClicked(editable: Editable, item: User): Boolean {
|
|
// Detect last '@' and remove it
|
|
var startIndex = editable.lastIndexOf("@")
|
|
if (startIndex == -1) {
|
|
startIndex = 0
|
|
}
|
|
|
|
// Detect next word separator
|
|
var endIndex = editable.indexOf(" ", startIndex)
|
|
if (endIndex == -1) {
|
|
endIndex = editable.length
|
|
}
|
|
|
|
// Replace the word by its completion
|
|
val displayName = item.displayName ?: item.userId
|
|
|
|
// with a trailing space
|
|
editable.replace(startIndex, endIndex, "$displayName ")
|
|
|
|
// Add the span
|
|
val user = session.getUser(item.userId)
|
|
val span = PillImageSpan(glideRequests, context!!, item.userId, user)
|
|
span.bind(composerEditText)
|
|
|
|
editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
|
|
return true
|
|
}
|
|
|
|
override fun onPopupVisibilityChanged(shown: Boolean) {
|
|
}
|
|
})
|
|
.build()
|
|
|
|
sendButton.setOnClickListener {
|
|
val textMessage = composerEditText.text.toString()
|
|
if (textMessage.isNotBlank()) {
|
|
roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage))
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun setupAttachmentButton() {
|
|
attachmentButton.setOnClickListener {
|
|
val intent = Intent(requireContext(), FilePickerActivity::class.java)
|
|
intent.putExtra(FilePickerActivity.CONFIGS, Configurations.Builder()
|
|
.setCheckPermission(true)
|
|
.setShowFiles(true)
|
|
.setShowAudios(true)
|
|
.setSkipZeroSizeFiles(true)
|
|
.build())
|
|
startActivityForResult(intent, REQUEST_FILES_REQUEST_CODE)
|
|
/*
|
|
val items = ArrayList<DialogListItem>()
|
|
// Send file
|
|
items.add(DialogListItem.SendFile)
|
|
// Send voice
|
|
|
|
if (PreferencesManager.isSendVoiceFeatureEnabled(this)) {
|
|
items.add(DialogListItem.SendVoice.INSTANCE)
|
|
}
|
|
|
|
|
|
// Send sticker
|
|
//items.add(DialogListItem.SendSticker)
|
|
// Camera
|
|
|
|
//if (PreferencesManager.useNativeCamera(this)) {
|
|
items.add(DialogListItem.TakePhoto)
|
|
items.add(DialogListItem.TakeVideo)
|
|
//} else {
|
|
// items.add(DialogListItem.TakePhotoVideo.INSTANCE)
|
|
// }
|
|
val adapter = DialogSendItemAdapter(requireContext(), items)
|
|
AlertDialog.Builder(requireContext())
|
|
.setAdapter(adapter) { _, position ->
|
|
onSendChoiceClicked(items[position])
|
|
}
|
|
.setNegativeButton(R.string.cancel, null)
|
|
.show()
|
|
*/
|
|
}
|
|
}
|
|
|
|
private fun onSendChoiceClicked(dialogListItem: DialogListItem) {
|
|
Timber.v("On send choice clicked: $dialogListItem")
|
|
when (dialogListItem) {
|
|
is DialogListItem.SendFile -> {
|
|
// launchFileIntent
|
|
}
|
|
is DialogListItem.SendVoice -> {
|
|
//launchAudioRecorderIntent()
|
|
}
|
|
is DialogListItem.SendSticker -> {
|
|
//startStickerPickerActivity()
|
|
}
|
|
is DialogListItem.TakePhotoVideo ->
|
|
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
|
|
// launchCamera()
|
|
}
|
|
is DialogListItem.TakePhoto ->
|
|
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA)) {
|
|
openCamera(requireActivity(), CAMERA_VALUE_TITLE, TAKE_IMAGE_REQUEST_CODE)
|
|
}
|
|
is DialogListItem.TakeVideo ->
|
|
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA)) {
|
|
// launchNativeVideoRecorder()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun handleMediaIntent(data: Intent) {
|
|
val files: ArrayList<MediaFile> = data.getParcelableArrayListExtra(FilePickerActivity.MEDIA_FILES)
|
|
roomDetailViewModel.process(RoomDetailActions.SendMedia(files))
|
|
}
|
|
|
|
private fun renderState(state: RoomDetailViewState) {
|
|
renderRoomSummary(state)
|
|
timelineEventController.setTimeline(state.timeline)
|
|
}
|
|
|
|
private fun renderRoomSummary(state: RoomDetailViewState) {
|
|
state.asyncRoomSummary()?.let {
|
|
toolbarTitleView.text = it.displayName
|
|
AvatarRenderer.render(it, toolbarAvatarImageView)
|
|
if (it.topic.isNotEmpty()) {
|
|
toolbarSubtitleView.visibility = View.VISIBLE
|
|
toolbarSubtitleView.text = it.topic
|
|
} else {
|
|
toolbarSubtitleView.visibility = View.GONE
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun renderTextComposerState(state: TextComposerViewState) {
|
|
autocompleteUserPresenter.render(state.asyncUsers)
|
|
}
|
|
|
|
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
|
|
when (sendMessageResult) {
|
|
is SendMessageResult.MessageSent,
|
|
is SendMessageResult.SlashCommandHandled -> {
|
|
// Clear composer
|
|
composerEditText.text = null
|
|
}
|
|
is SendMessageResult.SlashCommandError -> {
|
|
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
|
|
}
|
|
is SendMessageResult.SlashCommandUnknown -> {
|
|
displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command))
|
|
}
|
|
is SendMessageResult.SlashCommandResultOk -> {
|
|
// Ignore
|
|
}
|
|
is SendMessageResult.SlashCommandResultError -> {
|
|
displayCommandError(sendMessageResult.throwable.localizedMessage)
|
|
}
|
|
is SendMessageResult.SlashCommandNotImplemented -> {
|
|
displayCommandError(getString(R.string.not_implemented))
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun displayCommandError(message: String) {
|
|
AlertDialog.Builder(activity!!)
|
|
.setTitle(R.string.command_error)
|
|
.setMessage(message)
|
|
.setPositiveButton(R.string.ok, null)
|
|
.show()
|
|
}
|
|
|
|
// TimelineEventController.Callback ************************************************************
|
|
|
|
override fun onUrlClicked(url: String) {
|
|
homePermalinkHandler.launch(url)
|
|
}
|
|
|
|
override fun onEventVisible(event: TimelineEvent) {
|
|
roomDetailViewModel.process(RoomDetailActions.EventDisplayed(event))
|
|
}
|
|
|
|
override fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View) {
|
|
val intent = ImageMediaViewerActivity.newIntent(vectorBaseActivity, mediaData)
|
|
startActivity(intent)
|
|
}
|
|
|
|
override fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) {
|
|
val intent = VideoMediaViewerActivity.newIntent(vectorBaseActivity, mediaData)
|
|
startActivity(intent)
|
|
}
|
|
|
|
override fun onFileMessageClicked(messageFileContent: MessageFileContent) {
|
|
vectorBaseActivity.notImplemented()
|
|
}
|
|
|
|
override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) {
|
|
vectorBaseActivity.notImplemented()
|
|
}
|
|
|
|
override fun onEventCellClicked(eventId: String, informationData: MessageInformationData, messageContent: MessageContent, view: View) {
|
|
val roomId = (arguments?.get(MvRx.KEY_ARG) as? RoomDetailArgs)?.roomId
|
|
if (roomId.isNullOrBlank()) {
|
|
Timber.e("Missing RoomId, cannot open bottomsheet")
|
|
return
|
|
}
|
|
MessageActionsBottomSheet
|
|
.newInstance(eventId, roomId, informationData)
|
|
.show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS")
|
|
}
|
|
|
|
// AutocompleteUserPresenter.Callback
|
|
override fun onEventLongClicked(eventId: String, informationData: MessageInformationData, messageContent: MessageContent, view: View): Boolean {
|
|
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
|
val roomId = (arguments?.get(MvRx.KEY_ARG) as? RoomDetailArgs)?.roomId
|
|
if (roomId.isNullOrBlank()) {
|
|
Timber.e("Missing RoomId, cannot open bottomsheet")
|
|
return false
|
|
}
|
|
MessageActionsBottomSheet
|
|
.newInstance(eventId, roomId, informationData)
|
|
.show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS")
|
|
return true
|
|
}
|
|
|
|
override fun onAvatarClicked(informationData: MessageInformationData) {
|
|
vectorBaseActivity.notImplemented()
|
|
}
|
|
// AutocompleteUserPresenter.Callback
|
|
|
|
override fun onQueryUsers(query: CharSequence?) {
|
|
textComposerViewModel.process(TextComposerActions.QueryUsers(query))
|
|
}
|
|
|
|
private fun handleActions(it: LiveEvent<ActionsHandler.ActionData>?) {
|
|
it?.getContentIfNotHandled()?.let { actionData ->
|
|
|
|
when (actionData.actionId) {
|
|
MessageMenuViewModel.ACTION_ADD_REACTION -> {
|
|
startActivityForResult(EmojiReactionPickerActivity.intent(requireContext()), 0)
|
|
}
|
|
MessageMenuViewModel.ACTION_COPY -> {
|
|
//I need info about the current selected message :/
|
|
copyToClipboard(requireContext(), actionData.data?.toString() ?: "", false)
|
|
val snack = Snackbar.make(view!!, requireContext().getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT)
|
|
snack.view.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.notification_accent_color))
|
|
snack.show()
|
|
}
|
|
MessageMenuViewModel.ACTION_SHARE -> {
|
|
//TODO current data communication is too limited
|
|
//Need to now the media type
|
|
actionData.data?.toString()?.let {
|
|
//TODO bad, just POC
|
|
BigImageViewer.imageLoader().loadImage(
|
|
actionData.hashCode(),
|
|
Uri.parse(it),
|
|
object : ImageLoader.Callback {
|
|
override fun onFinish() {}
|
|
|
|
override fun onSuccess(image: File?) {
|
|
if (image != null)
|
|
shareMedia(requireContext(), image!!, "image/*")
|
|
}
|
|
|
|
override fun onFail(error: Exception?) {}
|
|
|
|
override fun onCacheHit(imageType: Int, image: File?) {}
|
|
|
|
override fun onCacheMiss(imageType: Int, image: File?) {}
|
|
|
|
override fun onProgress(progress: Int) {}
|
|
|
|
override fun onStart() {}
|
|
|
|
}
|
|
|
|
)
|
|
}
|
|
}
|
|
MessageMenuViewModel.VIEW_SOURCE,
|
|
MessageMenuViewModel.VIEW_DECRYPTED_SOURCE -> {
|
|
val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null)
|
|
view.findViewById<TextView>(R.id.event_content_text_view)?.let {
|
|
it.text = actionData.data?.toString() ?: ""
|
|
}
|
|
|
|
AlertDialog.Builder(requireActivity())
|
|
.setView(view)
|
|
.setPositiveButton(R.string.ok) { dialog, id -> dialog.cancel() }
|
|
.show()
|
|
}
|
|
else -> {
|
|
Toast.makeText(context, "Action ${actionData.actionId} not implemented", Toast.LENGTH_LONG).show()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|