/* * 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() 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(composerEditText) .with(CommandAutocompletePolicy()) .with(autocompleteCommandPresenter) .with(elevation) .with(backgroundDrawable) .with(object : AutocompleteCallback { 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(composerEditText) .with(CharPolicy('@', true)) .with(autocompleteUserPresenter) .with(elevation) .with(backgroundDrawable) .with(object : AutocompleteCallback { 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() // 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 = 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?) { 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(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() } } } } }