ChatRoomFragment.kt
package chat.rocket.android.chatroom.ui
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.provider.MediaStore
import android.text.SpannableStringBuilder
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.FileProvider
import androidx.core.text.bold
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import chat.rocket.android.R
import chat.rocket.android.analytics.AnalyticsManager
import chat.rocket.android.analytics.event.ScreenViewEvent
import chat.rocket.android.chatroom.adapter.AttachmentViewHolder
import chat.rocket.android.chatroom.adapter.ChatRoomAdapter
import chat.rocket.android.chatroom.adapter.CommandSuggestionsAdapter
import chat.rocket.android.chatroom.adapter.EmojiSuggestionsAdapter
import chat.rocket.android.chatroom.adapter.MessageViewHolder
import chat.rocket.android.chatroom.adapter.PEOPLE
import chat.rocket.android.chatroom.adapter.PeopleSuggestionsAdapter
import chat.rocket.android.chatroom.adapter.RoomSuggestionsAdapter
import chat.rocket.android.chatroom.presentation.ChatRoomNavigator
import chat.rocket.android.chatroom.presentation.ChatRoomPresenter
import chat.rocket.android.chatroom.presentation.ChatRoomView
import chat.rocket.android.chatroom.ui.bottomsheet.MessageActionsBottomSheet
import chat.rocket.android.chatroom.uimodel.BaseUiModel
import chat.rocket.android.chatroom.uimodel.MessageUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.ChatRoomSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.CommandSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.EmojiSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.PeopleSuggestionUiModel
import chat.rocket.android.chatrooms.adapter.model.RoomUiModel
import chat.rocket.android.draw.main.ui.DRAWING_BYTE_ARRAY_EXTRA_DATA
import chat.rocket.android.draw.main.ui.DrawingActivity
import chat.rocket.android.emoji.ComposerEditText
import chat.rocket.android.emoji.Emoji
import chat.rocket.android.emoji.EmojiKeyboardListener
import chat.rocket.android.emoji.EmojiKeyboardPopup
import chat.rocket.android.emoji.EmojiParser
import chat.rocket.android.emoji.EmojiPickerPopup
import chat.rocket.android.emoji.EmojiReactionListener
import chat.rocket.android.emoji.internal.isCustom
import chat.rocket.android.helper.EndlessRecyclerViewScrollListener
import chat.rocket.android.helper.KeyboardHelper
import chat.rocket.android.helper.MessageParser
import chat.rocket.android.helper.AndroidPermissionsHelper
import chat.rocket.android.helper.AndroidPermissionsHelper.getCameraPermission
import chat.rocket.android.helper.AndroidPermissionsHelper.getWriteExternalStoragePermission
import chat.rocket.android.helper.AndroidPermissionsHelper.hasCameraPermission
import chat.rocket.android.helper.AndroidPermissionsHelper.hasWriteExternalStoragePermission
import chat.rocket.android.util.extension.asObservable
import chat.rocket.android.util.extension.createImageFile
import chat.rocket.android.util.extension.orFalse
import chat.rocket.android.util.extensions.circularRevealOrUnreveal
import chat.rocket.android.util.extensions.clearLightStatusBar
import chat.rocket.android.util.extensions.fadeIn
import chat.rocket.android.util.extensions.fadeOut
import chat.rocket.android.util.extensions.hideKeyboard
import chat.rocket.android.util.extensions.inflate
import chat.rocket.android.util.extensions.isNotNullNorEmpty
import chat.rocket.android.util.extensions.rotateBy
import chat.rocket.android.util.extensions.showToast
import chat.rocket.android.util.extensions.textContent
import chat.rocket.android.util.extensions.ui
import chat.rocket.common.model.RoomType
import chat.rocket.common.model.roomTypeOf
import chat.rocket.core.internal.realtime.socket.model.State
import com.bumptech.glide.Glide
import com.google.android.material.snackbar.Snackbar
import dagger.android.support.AndroidSupportInjection
import io.reactivex.Observable
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import kotlinx.android.synthetic.main.app_bar_chat_room.*
import kotlinx.android.synthetic.main.emoji_image_row_item.view.*
import kotlinx.android.synthetic.main.emoji_row_item.view.*
import kotlinx.android.synthetic.main.fragment_chat_room.*
import kotlinx.android.synthetic.main.message_attachment_options.*
import kotlinx.android.synthetic.main.message_composer.*
import kotlinx.android.synthetic.main.message_list.*
import kotlinx.android.synthetic.main.reaction_praises_list_item.view.*
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
fun newInstance(
chatRoomId: String,
chatRoomName: String,
chatRoomType: String,
isReadOnly: Boolean,
chatRoomLastSeen: Long,
isSubscribed: Boolean = true,
isCreator: Boolean = false,
isFavorite: Boolean = false,
chatRoomMessage: String? = null
): Fragment = ChatRoomFragment().apply {
arguments = Bundle(1).apply {
putString(BUNDLE_CHAT_ROOM_ID, chatRoomId)
putString(BUNDLE_CHAT_ROOM_NAME, chatRoomName)
putString(BUNDLE_CHAT_ROOM_TYPE, chatRoomType)
putBoolean(BUNDLE_IS_CHAT_ROOM_READ_ONLY, isReadOnly)
putLong(BUNDLE_CHAT_ROOM_LAST_SEEN, chatRoomLastSeen)
putBoolean(BUNDLE_CHAT_ROOM_IS_SUBSCRIBED, isSubscribed)
putBoolean(BUNDLE_CHAT_ROOM_IS_CREATOR, isCreator)
putBoolean(BUNDLE_CHAT_ROOM_IS_FAVORITE, isFavorite)
putString(BUNDLE_CHAT_ROOM_MESSAGE, chatRoomMessage)
}
}
internal const val TAG_CHAT_ROOM_FRAGMENT = "ChatRoomFragment"
private const val BUNDLE_CHAT_ROOM_ID = "chat_room_id"
private const val BUNDLE_CHAT_ROOM_NAME = "chat_room_name"
private const val BUNDLE_CHAT_ROOM_TYPE = "chat_room_type"
private const val BUNDLE_IS_CHAT_ROOM_READ_ONLY = "is_chat_room_read_only"
private const val REQUEST_CODE_FOR_PERFORM_SAF = 42
private const val REQUEST_CODE_FOR_DRAW = 101
private const val REQUEST_CODE_FOR_PERFORM_CAMERA = 102
private const val BUNDLE_CHAT_ROOM_LAST_SEEN = "chat_room_last_seen"
private const val BUNDLE_CHAT_ROOM_IS_SUBSCRIBED = "chat_room_is_subscribed"
private const val BUNDLE_CHAT_ROOM_IS_CREATOR = "chat_room_is_creator"
private const val BUNDLE_CHAT_ROOM_IS_FAVORITE = "chat_room_is_favorite"
private const val BUNDLE_CHAT_ROOM_MESSAGE = "chat_room_message"
class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiReactionListener,
ChatRoomAdapter.OnActionSelected, Drawable.Callback {
@Inject
lateinit var presenter: ChatRoomPresenter
@Inject
lateinit var parser: MessageParser
@Inject
lateinit var analyticsManager: AnalyticsManager
@Inject
lateinit var navigator: ChatRoomNavigator
private lateinit var chatRoomAdapter: ChatRoomAdapter
internal lateinit var chatRoomId: String
private lateinit var chatRoomName: String
internal lateinit var chatRoomType: String
private var newMessageCount: Int = 0
private var chatRoomMessage: String? = null
private var isSubscribed: Boolean = true
private var isReadOnly: Boolean = false
private var isCreator: Boolean = false
internal var isFavorite: Boolean = false
private var isBroadcastChannel: Boolean = false
private lateinit var emojiKeyboardPopup: EmojiKeyboardPopup
private var chatRoomLastSeen: Long = -1
private lateinit var actionSnackbar: ActionSnackbar
internal var citation: String? = null
private var editingMessageId: String? = null
private var disableMenu: Boolean = false
private val compositeDisposable = CompositeDisposable()
private var playComposeMessageButtonsAnimation = true
internal var isSearchTermQueried = false
private val dismissConnectionState by lazy { text_connection_status.fadeOut() }
// For reveal and unreveal anim.
private val hypotenuse by lazy {
Math.hypot(
root_layout.width.toDouble(),
root_layout.height.toDouble()
).toFloat()
}
private val max by lazy {
Math.max(
layout_message_attachment_options.width.toDouble(),
layout_message_attachment_options.height.toDouble()
).toFloat()
}
private val centerX by lazy { recycler_view.right }
private val centerY by lazy { recycler_view.bottom }
private val handler = Handler()
private var verticalScrollOffset = AtomicInteger(0)
private val dialogView by lazy { View.inflate(context, R.layout.file_attachments_dialog, null) }
internal val alertDialog by lazy {
activity?.let {
AlertDialog.Builder(it).setView(dialogView).create()
}
}
internal val imagePreview by lazy { dialogView.findViewById<ImageView>(R.id.image_preview) }
internal val sendButton by lazy { dialogView.findViewById<android.widget.Button>(R.id.button_send) }
internal val cancelButton by lazy { dialogView.findViewById<android.widget.Button>(R.id.button_cancel) }
internal val description by lazy { dialogView.findViewById<EditText>(R.id.text_file_description) }
internal val audioVideoAttachment by lazy { dialogView.findViewById<FrameLayout>(R.id.audio_video_attachment) }
internal val textFile by lazy { dialogView.findViewById<TextView>(R.id.text_file_name) }
private var takenPhotoUri: Uri? = null
private val layoutChangeListener =
View.OnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
val y = oldBottom - bottom
if (Math.abs(y) > 0 && isAdded) {
// if y is positive the keyboard is up else it's down
recycler_view.post {
if (y > 0 || Math.abs(verticalScrollOffset.get()) >= Math.abs(y)) {
ui { recycler_view.scrollBy(0, y) }
} else {
ui { recycler_view.scrollBy(0, verticalScrollOffset.get()) }
}
}
}
}
private lateinit var endlessRecyclerViewScrollListener: EndlessRecyclerViewScrollListener
private val onScrollListener = object : RecyclerView.OnScrollListener() {
var state = AtomicInteger(RecyclerView.SCROLL_STATE_IDLE)
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
state.compareAndSet(RecyclerView.SCROLL_STATE_IDLE, newState)
when (newState) {
RecyclerView.SCROLL_STATE_IDLE -> {
if (!state.compareAndSet(RecyclerView.SCROLL_STATE_SETTLING, newState)) {
state.compareAndSet(RecyclerView.SCROLL_STATE_DRAGGING, newState)
}
}
RecyclerView.SCROLL_STATE_DRAGGING -> {
state.compareAndSet(RecyclerView.SCROLL_STATE_IDLE, newState)
}
RecyclerView.SCROLL_STATE_SETTLING -> {
state.compareAndSet(RecyclerView.SCROLL_STATE_DRAGGING, newState)
}
}
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (state.get() != RecyclerView.SCROLL_STATE_IDLE) {
verticalScrollOffset.getAndAdd(dy)
}
}
}
private val fabScrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (!recyclerView.canScrollVertically(1)) {
text_count.isVisible = false
button_fab.hide()
newMessageCount = 0
} else {
if (dy < 0 && isAdded && !button_fab.isVisible) {
button_fab.show()
if (newMessageCount != 0) text_count.isVisible = true
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AndroidSupportInjection.inject(this)
arguments?.run {
chatRoomId = getString(BUNDLE_CHAT_ROOM_ID, "")
chatRoomName = getString(BUNDLE_CHAT_ROOM_NAME, "")
chatRoomType = getString(BUNDLE_CHAT_ROOM_TYPE, "")
isReadOnly = getBoolean(BUNDLE_IS_CHAT_ROOM_READ_ONLY)
isSubscribed = getBoolean(BUNDLE_CHAT_ROOM_IS_SUBSCRIBED)
chatRoomLastSeen = getLong(BUNDLE_CHAT_ROOM_LAST_SEEN)
isCreator = getBoolean(BUNDLE_CHAT_ROOM_IS_CREATOR)
isFavorite = getBoolean(BUNDLE_CHAT_ROOM_IS_FAVORITE)
chatRoomMessage = getString(BUNDLE_CHAT_ROOM_MESSAGE)
}
?: requireNotNull(arguments) { "no arguments supplied when the fragment was instantiated" }
chatRoomAdapter = ChatRoomAdapter(
roomId = chatRoomId,
roomType = chatRoomType,
roomName = chatRoomName,
actionSelectListener = this,
reactionListener = this,
navigator = navigator,
analyticsManager = analyticsManager
)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = container?.inflate(R.layout.fragment_chat_room)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar(chatRoomName)
presenter.setupChatRoom(chatRoomId, chatRoomName, chatRoomType, chatRoomMessage)
presenter.loadChatRoomsSuggestions()
setupRecyclerView()
setupFab()
setupSuggestionsView()
setupActionSnackbar()
with(activity as ChatRoomActivity) {
setupToolbarTitle(chatRoomName)
setupExpandMoreForToolbar {
presenter.toChatDetails(
chatRoomId,
chatRoomType,
isSubscribed,
isFavorite,
disableMenu
)
}
}
getDraftMessage()
subscribeComposeTextMessage()
analyticsManager.logScreenView(ScreenViewEvent.ChatRoom)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
text_message.addTextChangedListener(EmojiKeyboardPopup.EmojiTextWatcher(text_message))
}
override fun onDestroyView() {
recycler_view.removeOnScrollListener(endlessRecyclerViewScrollListener)
recycler_view.removeOnScrollListener(onScrollListener)
recycler_view.removeOnLayoutChangeListener(layoutChangeListener)
presenter.saveDraftMessage(text_message.text.toString())
handler.removeCallbacksAndMessages(null)
unsubscribeComposeTextMessage()
presenter.disconnect()
// Hides the keyboard (if it's opened) before going to any view.
activity?.apply { hideKeyboard() }
super.onDestroyView()
}
override fun onPause() {
super.onPause()
dismissEmojiKeyboard()
activity?.invalidateOptionsMenu()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
if (resultCode == Activity.RESULT_OK) {
when (requestCode) {
REQUEST_CODE_FOR_PERFORM_CAMERA -> takenPhotoUri?.let {
showFileAttachmentDialog(it)
}
REQUEST_CODE_FOR_PERFORM_SAF -> resultData?.data?.let {
showFileAttachmentDialog(it)
}
REQUEST_CODE_FOR_DRAW -> resultData?.getByteArrayExtra(DRAWING_BYTE_ARRAY_EXTRA_DATA)?.let {
showDrawAttachmentDialog(it)
}
}
}
}
override fun onPrepareOptionsMenu(menu: Menu) {
menu.clear()
setupMenu(menu)
super.onPrepareOptionsMenu(menu)
}
override fun openFullWebPage(roomId: String, url: String) {
presenter.openFullWebPage(roomId, url)
}
override fun openConfigurableWebPage(roomId: String, url: String, heightRatio: String) {
presenter.openConfigurableWebPage(roomId, url, heightRatio)
}
override fun showMessages(dataSet: List<BaseUiModel<*>>, clearDataSet: Boolean) {
ui {
if (clearDataSet) {
chatRoomAdapter.clearData()
}
if (dataSet.isNotEmpty()) {
var prevMsgModel = dataSet[0]
// track the message sent immediately after the current message
var prevMessageUiModel: MessageUiModel? = null
// Checking for all messages to assign true to the required showDayMaker
// Loop over received messages to determine first unread
var firstUnread = false
for (i in dataSet.indices) {
val msgModel = dataSet[i]
if (i > 0) {
prevMsgModel = dataSet[i - 1]
}
val currentDayMarkerText = msgModel.currentDayMarkerText
val previousDayMarkerText = prevMsgModel.currentDayMarkerText
if (previousDayMarkerText != currentDayMarkerText) {
prevMsgModel.showDayMarker = true
}
if (!firstUnread && msgModel is MessageUiModel) {
val msg = msgModel.rawData
if (msg.timestamp < chatRoomLastSeen) {
// This message was sent before the last seen of the room. Hence, it was seen.
// if there is a message after (below) this, mark it firstUnread.
if (prevMessageUiModel != null) {
prevMessageUiModel.isFirstUnread = true
}
// Found first unread message.
firstUnread = true
}
prevMessageUiModel = msgModel
}
}
}
val oldMessagesCount = chatRoomAdapter.itemCount
chatRoomAdapter.appendData(dataSet)
if (oldMessagesCount == 0 && dataSet.isNotEmpty()) {
recycler_view.scrollToPosition(0)
verticalScrollOffset.set(0)
}
empty_chat_view.isVisible = chatRoomAdapter.itemCount == 0
dismissEmojiKeyboard()
}
}
override fun showSearchedMessages(dataSet: List<BaseUiModel<*>>) {
recycler_view.removeOnScrollListener(endlessRecyclerViewScrollListener)
chatRoomAdapter.clearData()
chatRoomAdapter.prependData(dataSet)
empty_chat_view.isVisible = chatRoomAdapter.itemCount == 0
dismissEmojiKeyboard()
}
override fun onRoomUpdated(roomUiModel: RoomUiModel) {
// TODO: We should rely solely on the user being able to post, but we cannot guarantee
// that the "(channels|groups).getPermissionRoles" endpoint is supported by the server in use.
ui {
setupToolbar(roomUiModel.name.toString())
setupMessageComposer(roomUiModel)
isBroadcastChannel = roomUiModel.broadcast
isFavorite = roomUiModel.favorite.orFalse()
disableMenu = (roomUiModel.broadcast && !roomUiModel.canModerate)
activity?.invalidateOptionsMenu()
}
}
override fun sendMessage(text: String) {
ui {
if (!text.isBlank()) {
if (text.startsWith("/")) {
presenter.runCommand(text, chatRoomId)
} else if (text.startsWith("+")) {
presenter.reactToLastMessage(text, chatRoomId)
} else {
presenter.sendMessage(chatRoomId, text, editingMessageId)
}
}
}
}
override fun showTypingStatus(usernameList: List<String>) {
ui {
when (usernameList.size) {
1 -> text_typing_status.text =
SpannableStringBuilder()
.bold { append(usernameList[0]) }
.append(getString(R.string.msg_is_typing))
2 -> text_typing_status.text =
SpannableStringBuilder()
.bold { append(usernameList[0]) }
.append(getString(R.string.msg_and))
.bold { append(usernameList[1]) }
.append(getString(R.string.msg_are_typing))
else -> text_typing_status.text = getString(R.string.msg_several_users_are_typing)
}
text_typing_status.isVisible = true
}
}
override fun hideTypingStatusView() {
ui { text_typing_status.isVisible = false }
}
override fun showInvalidFileMessage() {
showMessage(getString(R.string.msg_invalid_file))
}
override fun disableSendMessageButton() {
ui { button_send.isEnabled = false }
}
override fun enableSendMessageButton() {
ui {
button_send.isEnabled = true
text_message.isEnabled = true
}
}
override fun clearMessageComposition(deleteMessage: Boolean) {
ui {
citation = null
editingMessageId = null
if (deleteMessage) {
text_message.textContent = ""
}
actionSnackbar.dismiss()
}
}
override fun showNewMessage(message: List<BaseUiModel<*>>, isMessageReceived: Boolean) {
ui {
chatRoomAdapter.prependData(message)
if (isMessageReceived && button_fab.isVisible) {
newMessageCount++
if (newMessageCount <= 99) {
text_count.text = newMessageCount.toString()
} else {
text_count.text = getString(R.string.msg_more_than_ninety_nine_unread_messages)
}
text_count.isVisible = true
} else if (!button_fab.isVisible) {
recycler_view.scrollToPosition(0)
}
verticalScrollOffset.set(0)
empty_chat_view.isVisible = chatRoomAdapter.itemCount == 0
dismissEmojiKeyboard()
}
}
override fun dispatchUpdateMessage(index: Int, message: List<BaseUiModel<*>>) {
ui {
// TODO - investigate WHY we get a empty list here
if (message.isEmpty()) return@ui
when (chatRoomAdapter.updateItem(message.last())) {
// FIXME: What's 0,1 and 2 means for here?
0 -> {
if (message.size > 1) {
chatRoomAdapter.prependData(listOf(message.first()))
}
}
1 -> showNewMessage(message, true)
2 -> {
// Position of new sent message is wrong because of device local time is behind server time
with(chatRoomAdapter) {
removeItem(message.last().messageId)
prependData(listOf(message.last()))
notifyDataSetChanged()
}
}
}
dismissEmojiKeyboard()
}
}
override fun dispatchDeleteMessage(msgId: String) {
ui {
chatRoomAdapter.removeItem(msgId)
}
}
override fun showReplyingAction(
username: String,
replyMarkdown: String,
quotedMessage: String
) {
ui {
citation = replyMarkdown
actionSnackbar.title = username
actionSnackbar.text = quotedMessage
actionSnackbar.show()
KeyboardHelper.showSoftKeyboard(text_message)
}
}
override fun showLoading() {
ui { view_loading.isVisible = true }
}
override fun hideLoading() {
ui { view_loading.isVisible = false }
}
override fun showMessage(message: String) {
ui { showToast(message) }
}
override fun showMessage(resId: Int) {
ui { showToast(resId) }
}
override fun showGenericErrorMessage() {
ui { showMessage(getString(R.string.msg_generic_error)) }
}
override fun populatePeopleSuggestions(members: List<PeopleSuggestionUiModel>) {
ui { suggestions_view.addItems("@", members) }
}
override fun populateRoomSuggestions(chatRooms: List<ChatRoomSuggestionUiModel>) {
ui { suggestions_view.addItems("#", chatRooms) }
}
override fun populateCommandSuggestions(commands: List<CommandSuggestionUiModel>) {
ui { suggestions_view.addItems("/", commands) }
}
override fun populateEmojiSuggestions(emojis: List<EmojiSuggestionUiModel>) {
ui { suggestions_view.addItems(":", emojis) }
}
override fun copyToClipboard(message: String) {
ui {
val clipboard = it.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.primaryClip = ClipData.newPlainText("", message)
}
}
override fun showEditingAction(roomId: String, messageId: String, text: String) {
ui {
actionSnackbar.title = getString(R.string.action_title_editing)
actionSnackbar.text = text
actionSnackbar.show()
text_message.textContent = text
editingMessageId = messageId
KeyboardHelper.showSoftKeyboard(text_message)
}
}
override fun onEmojiAdded(emoji: Emoji) {
val cursorPosition = text_message.selectionStart
if (cursorPosition > -1) {
context?.let {
val offset = if (!emoji.isCustom()) emoji.unicode.length else emoji.shortname.length
val parsed = if (emoji.isCustom()) emoji.shortname else EmojiParser.parse(
it,
emoji.shortname
)
text_message.text?.insert(cursorPosition, parsed)
text_message.setSelection(cursorPosition + offset)
}
}
}
override fun onNonEmojiKeyPressed(keyCode: Int) {
when (keyCode) {
KeyEvent.KEYCODE_BACK -> with(text_message) {
if (selectionStart > 0) text?.delete(selectionStart - 1, selectionStart)
}
else -> throw IllegalArgumentException("pressed key not expected")
}
}
override fun onReactionTouched(messageId: String, emojiShortname: String) {
presenter.react(messageId, emojiShortname)
}
override fun onReactionAdded(messageId: String, emoji: Emoji) {
presenter.react(messageId, emoji.shortname)
}
override fun onReactionLongClicked(
shortname: String,
isCustom: Boolean,
url: String?,
usernames: List<String>
) {
val layout =
LayoutInflater.from(requireContext()).inflate(R.layout.reaction_praises_list_item, null)
val dialog = AlertDialog.Builder(requireContext())
.setView(layout)
.setCancelable(true)
with(layout) {
view_flipper.displayedChild = if (isCustom) 1 else 0
if (isCustom && url != null) {
val glideRequest = if (url.endsWith("gif", true)) {
Glide.with(requireContext()).asGif()
} else {
Glide.with(requireContext()).asBitmap()
}
glideRequest.load(url).into(view_flipper.emoji_image_view)
} else {
view_flipper.emoji_view.text = EmojiParser.parse(requireContext(), shortname)
}
var listing = ""
if (usernames.size == 1) {
listing = usernames.first()
} else {
usernames.forEachIndexed { index, username ->
listing += if (index == usernames.size - 1) "|$username" else "$username, "
}
listing =
listing.replace(", |", " ${requireContext().getString(R.string.msg_and)} ")
}
text_view_usernames.text = requireContext().resources.getQuantityString(
R.plurals.msg_reacted_with_, usernames.size, listing, shortname
)
dialog.show()
}
}
override fun showReactionsPopup(messageId: String) {
ui {
val emojiPickerPopup = EmojiPickerPopup(it)
emojiPickerPopup.listener = object : EmojiKeyboardListener {
override fun onEmojiAdded(emoji: Emoji) {
onReactionAdded(messageId, emoji)
}
}
emojiPickerPopup.show()
}
}
private fun setReactionButtonIcon(@DrawableRes drawableId: Int) {
button_add_reaction_or_show_keyboard.setImageResource(drawableId)
button_add_reaction_or_show_keyboard.tag = drawableId
}
override fun showFileSelection(filter: Array<String>?) {
ui {
val intent = Intent(Intent.ACTION_GET_CONTENT)
// Must set a type otherwise the intent won't resolve
intent.type = "*/*"
intent.addCategory(Intent.CATEGORY_OPENABLE)
// Filter selectable files to those that match the whitelist for this particular server
if (filter != null) {
intent.putExtra(Intent.EXTRA_MIME_TYPES, filter)
}
startActivityForResult(intent, REQUEST_CODE_FOR_PERFORM_SAF)
}
}
override fun showInvalidFileSize(fileSize: Int, maxFileSize: Int) {
showMessage(getString(R.string.max_file_size_exceeded, fileSize, maxFileSize))
}
override fun showConnectionState(state: State) {
ui {
text_connection_status.fadeIn()
handler.removeCallbacks { dismissConnectionState }
text_connection_status.text = when (state) {
is State.Connected -> {
handler.postDelayed({ dismissConnectionState }, 2000)
getString(R.string.status_connected)
}
is State.Disconnected -> getString(R.string.status_disconnected)
is State.Connecting -> getString(R.string.status_connecting)
is State.Authenticating -> getString(R.string.status_authenticating)
is State.Disconnecting -> getString(R.string.status_disconnecting)
is State.Waiting -> getString(R.string.status_waiting, state.seconds)
else -> "" // Show nothing
}
}
}
override fun onJoined(roomUiModel: RoomUiModel) {
ui {
input_container.isVisible = true
button_join_chat.isVisible = false
isSubscribed = true
}
}
private fun setupRecyclerView() {
// Initialize the endlessRecyclerViewScrollListener so we don't NPE at onDestroyView
val linearLayoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true)
linearLayoutManager.stackFromEnd = true
recycler_view.layoutManager = linearLayoutManager
recycler_view.itemAnimator = DefaultItemAnimator()
endlessRecyclerViewScrollListener = object :
EndlessRecyclerViewScrollListener(recycler_view.layoutManager as LinearLayoutManager) {
override fun onLoadMore(page: Int, totalItemsCount: Int, recyclerView: RecyclerView) {
presenter.loadMessages(chatRoomId, chatRoomType, page * 30L)
}
}
with (recycler_view) {
adapter = chatRoomAdapter
addOnScrollListener(endlessRecyclerViewScrollListener)
addOnLayoutChangeListener(layoutChangeListener)
addOnScrollListener(onScrollListener)
addOnScrollListener(fabScrollListener)
}
if (!isReadOnly) {
val touchCallback: ItemTouchHelper.SimpleCallback =
object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
var replyId: String? = null
when (viewHolder) {
is MessageViewHolder -> replyId = viewHolder.data?.messageId
is AttachmentViewHolder -> replyId = viewHolder.data?.messageId
}
replyId?.let {
citeMessage(chatRoomName, chatRoomType, it, true)
}
chatRoomAdapter.notifyItemChanged(viewHolder.adapterPosition)
}
override fun getSwipeDirs(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
// Currently enable swipes for text and attachment messages only
if (viewHolder is MessageViewHolder || viewHolder is AttachmentViewHolder) {
return super.getSwipeDirs(recyclerView, viewHolder)
}
return 0
}
}
ItemTouchHelper(touchCallback).attachToRecyclerView(recycler_view)
}
}
private fun setupFab() {
button_fab.setOnClickListener {
recycler_view.scrollToPosition(0)
verticalScrollOffset.set(0)
text_count.isVisible = false
button_fab.hide()
newMessageCount = 0
}
}
private fun setupMessageComposer(roomUiModel: RoomUiModel) {
if (isReadOnly || !roomUiModel.writable) {
text_room_is_read_only.isVisible = true
input_container.isVisible = false
text_room_is_read_only.setText(
if (isReadOnly) {
R.string.msg_this_room_is_read_only
} else {
// Not a read-only channel but user has been muted.
R.string.msg_muted_on_this_channel
}
)
} else if (!isSubscribed && roomTypeOf(chatRoomType) !is RoomType.DirectMessage) {
input_container.isVisible = false
button_join_chat.isVisible = true
button_join_chat.setOnClickListener { presenter.joinChat(chatRoomId) }
} else {
input_container.isVisible = true
text_room_is_read_only.isVisible = false
button_show_attachment_options.alpha = 1f
activity?.supportFragmentManager?.registerFragmentLifecycleCallbacks(
object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentAttached(
fm: FragmentManager,
f: Fragment,
context: Context
) {
if (f is MessageActionsBottomSheet) {
dismissEmojiKeyboard()
}
}
},
true
)
emojiKeyboardPopup =
EmojiKeyboardPopup(activity!!, activity!!.findViewById(R.id.fragment_container))
emojiKeyboardPopup.listener = this
text_message.listener = object : ComposerEditText.ComposerEditTextListener {
override fun onKeyboardOpened() {
KeyboardHelper.showSoftKeyboard(text_message)
}
override fun onKeyboardClosed() {
activity?.let {
if (!emojiKeyboardPopup.isKeyboardOpen) {
it.onBackPressed()
}
KeyboardHelper.hideSoftKeyboard(it)
dismissEmojiKeyboard()
}
}
}
button_send.setOnClickListener {
var textMessage = citation ?: ""
textMessage += text_message.textContent
sendMessage(textMessage)
}
button_show_attachment_options.setOnClickListener {
if (layout_message_attachment_options.isShown) {
hideAttachmentOptions()
} else {
showAttachmentOptions()
}
}
view_dim.setOnClickListener {
hideAttachmentOptions()
}
button_add_reaction_or_show_keyboard.setOnClickListener { toggleKeyboard() }
button_take_a_photo.setOnClickListener {
// Check for camera permission
context?.let {
if (hasCameraPermission(it)) {
dispatchTakePictureIntent()
} else {
getCameraPermission(this)
}
}
handler.postDelayed({
hideAttachmentOptions()
}, 400)
}
button_attach_a_file.setOnClickListener {
handler.postDelayed({
presenter.selectFile()
}, 200)
handler.postDelayed({
hideAttachmentOptions()
}, 400)
}
button_drawing.setOnClickListener {
activity?.let { fragmentActivity ->
if (!hasWriteExternalStoragePermission(fragmentActivity)) {
getWriteExternalStoragePermission(this)
} else {
dispatchDrawingIntent()
}
}
handler.postDelayed({
hideAttachmentOptions()
}, 400)
}
}
}
private fun dispatchDrawingIntent() {
val intent = Intent(activity, DrawingActivity::class.java)
startActivityForResult(intent, REQUEST_CODE_FOR_DRAW)
}
private fun dispatchTakePictureIntent() {
Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { takePictureIntent ->
// Create the File where the photo should go
val photoFile: File? = try {
activity?.createImageFile()
} catch (ex: IOException) {
Timber.e(ex)
null
}
// Continue only if the File was successfully created
photoFile?.also {
takenPhotoUri = FileProvider.getUriForFile(
requireContext(), "chat.rocket.android.fileprovider", it
)
takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, takenPhotoUri)
startActivityForResult(takePictureIntent, REQUEST_CODE_FOR_PERFORM_CAMERA)
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
AndroidPermissionsHelper.CAMERA_CODE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// permission was granted
dispatchTakePictureIntent()
} else {
// permission denied
Snackbar.make(
root_layout,
R.string.msg_camera_permission_denied,
Snackbar.LENGTH_SHORT
).show()
}
return
}
AndroidPermissionsHelper.WRITE_EXTERNAL_STORAGE_CODE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// permission was granted
dispatchDrawingIntent()
} else {
// permission denied
Snackbar.make(
root_layout,
R.string.msg_storage_permission_denied,
Snackbar.LENGTH_SHORT
).show()
}
return
}
}
}
private fun getDraftMessage() {
val unfinishedMessage = presenter.getDraftUnfinishedMessage()
if (unfinishedMessage.isNotNullNorEmpty()) {
text_message.setText(unfinishedMessage)
}
}
private fun setupSuggestionsView() {
suggestions_view.anchorTo(text_message)
.setMaximumHeight(resources.getDimensionPixelSize(R.dimen.suggestions_box_max_height))
.addTokenAdapter(PeopleSuggestionsAdapter(context!!))
.addTokenAdapter(CommandSuggestionsAdapter())
.addTokenAdapter(RoomSuggestionsAdapter())
.addTokenAdapter(EmojiSuggestionsAdapter())
.addSuggestionProviderAction("@") { query ->
if (query.isNotEmpty()) {
presenter.spotlight(query, PEOPLE, true)
}
}
.addSuggestionProviderAction("#") { query ->
if (query.isNotEmpty()) {
presenter.loadChatRoomsSuggestions()
}
}
.addSuggestionProviderAction("/") {
presenter.loadCommands()
}
.addSuggestionProviderAction(":") {
presenter.loadEmojis()
}
presenter.loadEmojis()
presenter.loadCommands()
}
// Shows the emoji or the system keyboard.
private fun toggleKeyboard() {
if (!emojiKeyboardPopup.isShowing) {
openEmojiKeyboard()
} else {
// If popup is showing, simply dismiss it to show the underlying text keyboard
dismissEmojiKeyboard()
}
}
private fun setupActionSnackbar() {
actionSnackbar = ActionSnackbar.make(message_list_container, parser = parser)
actionSnackbar.cancelView.setOnClickListener {
clearMessageComposition(false)
if (text_message.textContent.isEmpty()) {
KeyboardHelper.showSoftKeyboard(text_message)
}
}
}
private fun subscribeComposeTextMessage() {
text_message.asObservable().let {
compositeDisposable.addAll(
subscribeComposeButtons(it),
subscribeComposeTypingStatus(it)
)
}
}
private fun unsubscribeComposeTextMessage() {
compositeDisposable.clear()
}
private fun subscribeComposeButtons(observable: Observable<CharSequence>): Disposable {
return observable.subscribe { t -> setupComposeButtons(t) }
}
private fun subscribeComposeTypingStatus(observable: Observable<CharSequence>): Disposable {
return observable.debounce(300, TimeUnit.MILLISECONDS)
.skip(1)
.subscribe { t -> sendTypingStatus(t) }
}
private fun setupComposeButtons(charSequence: CharSequence) {
if (charSequence.isNotEmpty() && playComposeMessageButtonsAnimation) {
button_show_attachment_options.isVisible = false
button_send.isVisible = true
playComposeMessageButtonsAnimation = false
}
if (charSequence.isEmpty()) {
button_send.isVisible = false
button_show_attachment_options.isVisible = true
playComposeMessageButtonsAnimation = true
}
}
private fun sendTypingStatus(charSequence: CharSequence) {
if (charSequence.isNotBlank()) {
presenter.sendTyping()
} else {
presenter.sendNotTyping()
}
}
private fun showAttachmentOptions() {
view_dim.isVisible = true
// Play anim.
button_show_attachment_options.rotateBy(45F)
layout_message_attachment_options.circularRevealOrUnreveal(centerX, centerY, 0F, hypotenuse)
}
private fun hideAttachmentOptions() {
// Play anim.
button_show_attachment_options.rotateBy(-45F)
layout_message_attachment_options.circularRevealOrUnreveal(centerX, centerY, max, 0F)
view_dim.isVisible = false
}
private fun setupToolbar(toolbarTitle: String) {
with(activity as ChatRoomActivity) {
this.clearLightStatusBar()
this.setupToolbarTitle(toolbarTitle)
toolbar.isVisible = true
}
}
override fun unscheduleDrawable(who: Drawable?, what: Runnable?) {
text_message?.removeCallbacks(what)
}
override fun invalidateDrawable(who: Drawable?) {
text_message?.invalidate()
}
override fun scheduleDrawable(who: Drawable?, what: Runnable?, `when`: Long) {
text_message?.postDelayed(what, `when`)
}
override fun showMessageInfo(id: String) {
presenter.messageInfo(id)
}
override fun citeMessage(
roomName: String,
roomType: String,
messageId: String,
mentionAuthor: Boolean
) {
presenter.citeMessage(roomName, roomType, messageId, mentionAuthor)
}
override fun copyMessage(id: String) {
presenter.copyMessage(id)
}
override fun editMessage(roomId: String, messageId: String, text: String) {
presenter.editMessage(roomId, messageId, text)
}
override fun toggleStar(id: String, star: Boolean) {
if (star) {
presenter.starMessage(id)
} else {
presenter.unstarMessage(id)
}
}
override fun togglePin(id: String, pin: Boolean) {
if (pin) {
presenter.pinMessage(id)
} else {
presenter.unpinMessage(id)
}
}
override fun deleteMessage(roomId: String, id: String) {
ui {
val builder = AlertDialog.Builder(it)
builder.setTitle(it.getString(R.string.msg_delete_message))
.setMessage(it.getString(R.string.msg_delete_description))
.setPositiveButton(it.getString(android.R.string.ok)) { _, _ ->
presenter.deleteMessage(
roomId,
id
)
}
.setNegativeButton(it.getString(android.R.string.cancel)) { _, _ -> }
.show()
}
}
override fun copyPermalink(id: String) {
presenter.copyPermalink(id)
}
override fun showReactions(id: String) {
presenter.showReactions(id)
}
override fun openDirectMessage(roomName: String, message: String) {
presenter.openDirectMessage(roomName, message)
}
override fun sendMessage(chatRoomId: String, text: String) {
presenter.sendMessage(chatRoomId, text, null)
}
override fun reportMessage(id: String) {
presenter.reportMessage(
messageId = id,
description = "This message was reported by a user from the Android app"
)
}
fun openEmojiKeyboard() {
// If keyboard is visible, simply show the popup
if (emojiKeyboardPopup.isKeyboardOpen) {
emojiKeyboardPopup.showAtBottom()
} else {
// Open the text keyboard first and immediately after that show the emoji popup
text_message.isFocusableInTouchMode = true
text_message.requestFocus()
emojiKeyboardPopup.showAtBottomPending()
KeyboardHelper.showSoftKeyboard(text_message)
}
setReactionButtonIcon(R.drawable.ic_keyboard_black_24dp)
}
fun dismissEmojiKeyboard() {
// Check if the keyboard was ever initialized.
// It may be the case when you are looking a not joined room
if (::emojiKeyboardPopup.isInitialized) {
emojiKeyboardPopup.dismiss()
setReactionButtonIcon(R.drawable.ic_reaction_24dp)
}
}
}