Unverified Commit 7dcb3b46 authored by Lucio Maciel's avatar Lucio Maciel Committed by GitHub

Merge branch 'release/2.0.0-beta7' into feature/member-details

parents 13161ca1 1cb3f896
......@@ -12,8 +12,8 @@ android {
applicationId "chat.rocket.android"
minSdkVersion 21
targetSdkVersion versions.targetSdk
versionCode 1009
versionName "2.0.0-dev7"
versionCode 1010
versionName "2.0.0-dev8"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
multiDexEnabled true
}
......@@ -60,6 +60,8 @@ dependencies {
implementation libraries.constraintLayout
implementation libraries.cardView
implementation libraries.androidKtx
implementation libraries.dagger
implementation libraries.daggerSupport
kapt libraries.daggerProcessor
......
......@@ -6,11 +6,15 @@ import chat.rocket.android.player.PlayerActivity
import chat.rocket.android.util.extensions.setVisible
import kotlinx.android.synthetic.main.message_attachment.view.*
class AudioAttachmentViewHolder(itemView: View) : BaseViewHolder<AudioAttachmentViewModel>(itemView) {
class AudioAttachmentViewHolder(itemView: View, listener: ActionsListener)
: BaseViewHolder<AudioAttachmentViewModel>(itemView, listener) {
init {
with(itemView) {
image_attachment.setVisible(false)
audio_video_attachment.setVisible(true)
setupActionMenu(attachment_container)
setupActionMenu(audio_video_attachment)
}
}
......
package chat.rocket.android.chatroom.adapter
import android.support.v7.widget.RecyclerView
import android.view.MenuItem
import android.view.View
import chat.rocket.android.R
import chat.rocket.android.chatroom.ui.bottomsheet.BottomSheetMenu
import chat.rocket.android.chatroom.ui.bottomsheet.adapter.ActionListAdapter
import chat.rocket.android.chatroom.viewmodel.BaseViewModel
import chat.rocket.core.model.Message
import chat.rocket.core.model.isSystemMessage
import ru.whalemare.sheetmenu.extension.inflate
import ru.whalemare.sheetmenu.extension.toList
abstract class BaseViewHolder<T>(itemView: View) : RecyclerView.ViewHolder(itemView) {
abstract class BaseViewHolder<T : BaseViewModel<*>>(
itemView: View,
private val listener: ActionsListener
) : RecyclerView.ViewHolder(itemView),
MenuItem.OnMenuItemClickListener {
var data: T? = null
init {
setupActionMenu(itemView)
}
fun bind(data: T) {
this.data = data
bindViews(data)
}
abstract fun bindViews(data: T)
interface ActionsListener {
fun isActionsEnabled(): Boolean
fun onActionSelected(item: MenuItem, message: Message)
}
val longClickListener = { view: View ->
if (data?.message?.isSystemMessage() == false) {
val menuItems = view.context.inflate(R.menu.message_actions).toList()
menuItems.find { it.itemId == R.id.action_menu_msg_pin_unpin }?.apply {
val isPinned = data?.message?.pinned ?: false
setTitle(if (isPinned) R.string.action_msg_unpin else R.string.action_msg_pin)
isChecked = isPinned
}
val adapter = ActionListAdapter(menuItems, this@BaseViewHolder)
BottomSheetMenu(adapter).show(view.context)
}
true
}
internal fun setupActionMenu(view: View) {
if (listener.isActionsEnabled()) {
view.setOnLongClickListener(longClickListener)
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
data?.let {
listener.onActionSelected(item, it.message)
}
return true
}
}
\ No newline at end of file
package chat.rocket.android.chatroom.adapter
import android.support.v7.widget.RecyclerView
import android.view.MenuItem
import android.view.ViewGroup
import chat.rocket.android.R
import chat.rocket.android.chatroom.presentation.ChatRoomPresenter
import chat.rocket.android.chatroom.viewmodel.*
import chat.rocket.android.util.extensions.inflate
import chat.rocket.core.model.Message
import timber.log.Timber
import java.security.InvalidParameterException
......@@ -26,23 +28,23 @@ class ChatRoomAdapter(
return when(viewType.toViewType()) {
BaseViewModel.ViewType.MESSAGE -> {
val view = parent.inflate(R.layout.item_message)
MessageViewHolder(view, roomName, roomType, presenter, enableActions)
MessageViewHolder(view, actionsListener)
}
BaseViewModel.ViewType.IMAGE_ATTACHMENT -> {
val view = parent.inflate(R.layout.message_attachment)
ImageAttachmentViewHolder(view)
ImageAttachmentViewHolder(view, actionsListener)
}
BaseViewModel.ViewType.AUDIO_ATTACHMENT -> {
val view = parent.inflate(R.layout.message_attachment)
AudioAttachmentViewHolder(view)
AudioAttachmentViewHolder(view, actionsListener)
}
BaseViewModel.ViewType.VIDEO_ATTACHMENT -> {
val view = parent.inflate(R.layout.message_attachment)
VideoAttachmentViewHolder(view)
VideoAttachmentViewHolder(view, actionsListener)
}
BaseViewModel.ViewType.URL_PREVIEW -> {
val view = parent.inflate(R.layout.message_url_preview)
UrlPreviewViewHolder(view)
UrlPreviewViewHolder(view, actionsListener)
}
else -> {
throw InvalidParameterException("TODO - implement for ${viewType.toViewType()}")
......@@ -108,4 +110,30 @@ class ChatRoomAdapter(
notifyItemRangeRemoved(index, oldSize - newSize)
}
}
val actionsListener = object : BaseViewHolder.ActionsListener {
override fun isActionsEnabled(): Boolean = enableActions
override fun onActionSelected(item: MenuItem, message: Message) {
message.apply {
when (item.itemId) {
R.id.action_menu_msg_delete -> presenter?.deleteMessage(roomId, id)
R.id.action_menu_msg_quote -> presenter?.citeMessage(roomType, roomName, id, false)
R.id.action_menu_msg_reply -> presenter?.citeMessage(roomType, roomName, id, true)
R.id.action_menu_msg_copy -> presenter?.copyMessage(id)
R.id.action_menu_msg_edit -> presenter?.editMessage(roomId, id, message.message)
R.id.action_menu_msg_pin_unpin -> {
with(item) {
if (!isChecked) {
presenter?.pinMessage(id)
} else {
presenter?.unpinMessage(id)
}
}
}
else -> TODO("Not implemented")
}
}
}
}
}
\ No newline at end of file
......@@ -5,7 +5,16 @@ import chat.rocket.android.chatroom.viewmodel.ImageAttachmentViewModel
import com.stfalcon.frescoimageviewer.ImageViewer
import kotlinx.android.synthetic.main.message_attachment.view.*
class ImageAttachmentViewHolder(itemView: View) : BaseViewHolder<ImageAttachmentViewModel>(itemView) {
class ImageAttachmentViewHolder(itemView: View, listener: ActionsListener)
: BaseViewHolder<ImageAttachmentViewModel>(itemView, listener) {
init {
with(itemView) {
setupActionMenu(attachment_container)
setupActionMenu(image_attachment)
}
}
override fun bindViews(data: ImageAttachmentViewModel) {
with(itemView) {
image_attachment.setImageURI(data.attachmentUrl)
......
package chat.rocket.android.chatroom.adapter
import android.text.method.LinkMovementMethod
import android.view.MenuItem
import android.view.View
import chat.rocket.android.R
import chat.rocket.android.chatroom.presentation.ChatRoomPresenter
import chat.rocket.android.chatroom.ui.bottomsheet.BottomSheetMenu
import chat.rocket.android.chatroom.ui.bottomsheet.adapter.ActionListAdapter
import chat.rocket.android.chatroom.viewmodel.MessageViewModel
import kotlinx.android.synthetic.main.avatar.view.*
import kotlinx.android.synthetic.main.item_message.view.*
import ru.whalemare.sheetmenu.extension.inflate
import ru.whalemare.sheetmenu.extension.toList
class MessageViewHolder(
itemView: View,
private val roomType: String,
private val roomName: String,
private val presenter: ChatRoomPresenter?,
enableActions: Boolean
) : BaseViewHolder<MessageViewModel>(itemView),
MenuItem.OnMenuItemClickListener {
listener: ActionsListener
) : BaseViewHolder<MessageViewModel>(itemView, listener) {
init {
itemView.text_content.movementMethod = LinkMovementMethod()
if (enableActions) {
itemView.setOnLongClickListener {
if (data?.isSystemMessage == false) {
val menuItems = it.context.inflate(R.menu.message_actions).toList()
menuItems.find { it.itemId == R.id.action_menu_msg_pin_unpin }?.apply {
val isPinned = data?.isPinned ?: false
setTitle(if (isPinned) R.string.action_msg_unpin else R.string.action_msg_pin)
isChecked = isPinned
}
val adapter = ActionListAdapter(menuItems, this@MessageViewHolder)
BottomSheetMenu(adapter).apply {
}.show(it.context)
}
true
}
with(itemView) {
text_content.movementMethod = LinkMovementMethod()
setupActionMenu(text_content)
}
}
......@@ -52,27 +26,4 @@ class MessageViewHolder(
image_avatar.setImageURI(data.avatar)
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
data?.rawData?.apply {
when (item.itemId) {
R.id.action_menu_msg_delete -> presenter?.deleteMessage(roomId, id)
R.id.action_menu_msg_quote -> presenter?.citeMessage(roomType, roomName, id, false)
R.id.action_menu_msg_reply -> presenter?.citeMessage(roomType, roomName, id, true)
R.id.action_menu_msg_copy -> presenter?.copyMessage(id)
R.id.action_menu_msg_edit -> presenter?.editMessage(roomId, id, message)
R.id.action_menu_msg_pin_unpin -> {
with(item) {
if (!isChecked) {
presenter?.pinMessage(id)
} else {
presenter?.unpinMessage(id)
}
}
}
else -> TODO("Not implemented")
}
}
return true
}
}
\ No newline at end of file
......@@ -8,7 +8,15 @@ import chat.rocket.android.util.extensions.content
import chat.rocket.android.util.extensions.setVisible
import kotlinx.android.synthetic.main.message_url_preview.view.*
class UrlPreviewViewHolder(itemView: View) : BaseViewHolder<UrlPreviewViewModel>(itemView) {
class UrlPreviewViewHolder(itemView: View, listener: ActionsListener)
: BaseViewHolder<UrlPreviewViewModel>(itemView, listener) {
init {
with(itemView) {
setupActionMenu(url_preview_layout)
}
}
override fun bindViews(data: UrlPreviewViewModel) {
with(itemView) {
if (data.thumbUrl.isNullOrEmpty()) {
......
......@@ -6,11 +6,15 @@ import chat.rocket.android.player.PlayerActivity
import chat.rocket.android.util.extensions.setVisible
import kotlinx.android.synthetic.main.message_attachment.view.*
class VideoAttachmentViewHolder(itemView: View) : BaseViewHolder<VideoAttachmentViewModel>(itemView) {
class VideoAttachmentViewHolder(itemView: View, listener: ActionsListener)
: BaseViewHolder<VideoAttachmentViewModel>(itemView, listener) {
init {
with(itemView) {
image_attachment.setVisible(false)
audio_video_attachment.setVisible(true)
setupActionMenu(attachment_container)
setupActionMenu(audio_video_attachment)
}
}
......
......@@ -6,23 +6,23 @@ import chat.rocket.android.chatroom.domain.UriInteractor
import chat.rocket.android.chatroom.viewmodel.ViewModelMapper
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.android.server.infraestructure.ConnectionManagerFactory
import chat.rocket.android.server.infraestructure.state
import chat.rocket.android.util.extensions.launchUI
import chat.rocket.common.RocketChatException
import chat.rocket.common.model.RoomType
import chat.rocket.common.model.roomTypeOf
import chat.rocket.common.util.ifNull
import chat.rocket.core.internal.realtime.State
import chat.rocket.core.internal.realtime.connect
import chat.rocket.core.internal.realtime.subscribeRoomMessages
import chat.rocket.core.internal.realtime.unsubscribe
import chat.rocket.core.internal.rest.*
import chat.rocket.core.model.Message
import chat.rocket.core.model.Value
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.channels.Channel
import kotlinx.coroutines.experimental.launch
import org.threeten.bp.Instant
import timber.log.Timber
import javax.inject.Inject
......@@ -34,14 +34,22 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
private val permissions: GetPermissionsInteractor,
private val uriInteractor: UriInteractor,
private val messagesRepository: MessagesRepository,
factory: RocketChatClientFactory,
factory: ConnectionManagerFactory,
private val mapper: ViewModelMapper) {
private val client = factory.create(serverInteractor.get()!!)
private var subId: String? = null
private val currentServer = serverInteractor.get()!!
private val manager = factory.create(currentServer)
private val client = manager.client
private var settings: Map<String, Value<Any>> = getSettingsInteractor.get(serverInteractor.get()!!)!!
private val messagesChannel = Channel<Message>()
private var chatRoomId: String? = null
private var chatRoomType: String? = null
private val stateChannel = Channel<State>()
private var lastState = manager.state
fun loadMessages(chatRoomId: String, chatRoomType: String, offset: Long = 0) {
this.chatRoomId = chatRoomId
this.chatRoomType = chatRoomType
launchUI(strategy) {
view.showLoading()
try {
......@@ -56,10 +64,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
val messagesViewModels = mapper.map(messages)
view.showMessages(messagesViewModels)
// Subscribe after getting the first page of messages from REST
if (offset == 0L) {
subscribeMessages(chatRoomId)
}
} catch (ex: Exception) {
ex.printStackTrace()
ex.message?.let {
......@@ -70,6 +75,10 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
} finally {
view.hideLoading()
}
if (offset == 0L) {
subscribeState()
}
}
}
......@@ -132,7 +141,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
}
}
fun markRoomAsRead(roomId: String) {
private fun markRoomAsRead(roomId: String) {
launchUI(strategy) {
try {
client.markAsRead(roomId)
......@@ -143,57 +152,70 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
}
}
private fun subscribeMessages(roomId: String) {
client.addStateChannel(stateChannel)
private fun subscribeState() {
Timber.d("Subscribing to Status changes")
lastState = manager.state
manager.addStatusChannel(stateChannel)
launch(CommonPool + strategy.jobs) {
for (status in stateChannel) {
Timber.d("Changing status to: $status")
when (status) {
State.Authenticating -> Timber.d("Authenticating")
State.Connected -> {
Timber.d("Connected")
subId = client.subscribeRoomMessages(roomId) {
Timber.d("subscribe messages for $roomId: $it")
for (state in stateChannel) {
Timber.d("Got new state: $state - last: $lastState")
if (state != lastState) {
launch(UI) {
view.showConnectionState(state)
}
if (state is State.Connected) {
loadMissingMessages()
}
}
lastState = state
}
}
Timber.d("Done on statusChannel")
}
when (client.state) {
State.Connected -> {
Timber.d("Already connected")
subId = client.subscribeRoomMessages(roomId) {
Timber.d("subscribe messages for $roomId: $it")
private fun subscribeMessages(roomId: String) {
manager.subscribeRoomMessages(roomId, messagesChannel)
launch(CommonPool + strategy.jobs) {
for (message in messagesChannel) {
Timber.d("New message for room ${message.roomId}")
updateMessage(message)
}
}
else -> client.connect()
}
private fun loadMissingMessages() {
launch(parent = strategy.jobs) {
if (chatRoomId != null && chatRoomType != null) {
val roomType = roomTypeOf(chatRoomType!!)
val lastMessage = messagesRepository.getByRoomId(chatRoomId!!).sortedByDescending { it.timestamp }.first()
val instant = Instant.ofEpochMilli(lastMessage.timestamp)
val messages = client.history(chatRoomId!!, roomType, count = 50,
oldest = instant.toString())
Timber.d("History: $messages")
if (messages.result.isNotEmpty()) {
val models = mapper.map(messages.result)
messagesRepository.saveAll(messages.result)
launchUI(strategy) {
listenMessages(roomId)
view.showNewMessage(models)
}
// TODO - when we have a proper service, we won't need to take care of connection, just
// subscribe and listen...
/*launchUI(strategy) {
subId = client.subscribeRoomMessages(roomId) {
Timber.d("subscribe messages for $roomId: $it")
if (messages.result.size == 50) {
// we loade at least count messages, try one more to fetch more messages
loadMissingMessages()
}
listenMessages(roomId)
}*/
}
fun unsubscribeMessages() {
launch(CommonPool) {
client.removeStateChannel(stateChannel)
subId?.let { subscriptionId ->
client.unsubscribe(subscriptionId)
}
}
}
fun unsubscribeMessages(chatRoomId: String) {
manager.removeStatusChannel(stateChannel)
manager.unsubscribeRoomMessages(chatRoomId)
}
/**
* Delete the message with the given id.
*
......@@ -320,17 +342,6 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
fun toMembersList(chatRoomId: String, chatRoomType: String) = navigator.toMembersList(chatRoomId, chatRoomType)
private suspend fun listenMessages(roomId: String) {
launch(CommonPool + strategy.jobs) {
for (message in client.messagesChannel) {
if (message.roomId != roomId) {
Timber.d("Ignoring message for room ${message.roomId}, expecting $roomId")
}
updateMessage(message)
}
}
}
private fun updateMessage(streamedMessage: Message) {
launchUI(strategy) {
val viewModelStreamedMessage = mapper.map(streamedMessage)
......
......@@ -4,6 +4,7 @@ import android.net.Uri
import chat.rocket.android.chatroom.viewmodel.BaseViewModel
import chat.rocket.android.core.behaviours.LoadingView
import chat.rocket.android.core.behaviours.MessageView
import chat.rocket.core.internal.realtime.State
interface ChatRoomView : LoadingView, MessageView {
......@@ -97,4 +98,6 @@ interface ChatRoomView : LoadingView, MessageView {
fun clearMessageComposition()
fun showInvalidFileSize(fileSize: Int, maxFileSize: Int)
fun showConnectionState(state: State)
}
\ No newline at end of file
package chat.rocket.android.chatroom.presentation
import chat.rocket.android.chatroom.viewmodel.MessageViewModel
import chat.rocket.android.chatroom.viewmodel.ViewModelMapper
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.server.domain.GetChatRoomsInteractor
......@@ -12,6 +11,7 @@ import chat.rocket.common.RocketChatException
import chat.rocket.common.util.ifNull
import chat.rocket.core.internal.rest.getRoomPinnedMessages
import chat.rocket.core.model.Value
import chat.rocket.core.model.isSystemMessage
import timber.log.Timber
import javax.inject.Inject
......@@ -42,8 +42,7 @@ class PinnedMessagesPresenter @Inject constructor(private val view: PinnedMessag
val pinnedMessages =
client.getRoomPinnedMessages(roomId, room.type, pinnedMessagesListOffset)
pinnedMessagesListOffset = pinnedMessages.offset.toInt()
val messageList = mapper.map(pinnedMessages.result)
.filter { it is MessageViewModel}.filterNot { (it as MessageViewModel).isSystemMessage }
val messageList = mapper.map(pinnedMessages.result.filterNot { it.isSystemMessage() })
view.showPinnedMessages(messageList)
view.hideLoading()
}.ifNull {
......
......@@ -6,6 +6,8 @@ import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v7.app.AppCompatActivity
import chat.rocket.android.R
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.infraestructure.ConnectionManagerFactory
import chat.rocket.android.util.extensions.addFragment
import chat.rocket.android.util.extensions.textContent
import dagger.android.AndroidInjection
......@@ -32,6 +34,11 @@ private const val INTENT_IS_CHAT_ROOM_READ_ONLY = "is_chat_room_read_only"
class ChatRoomActivity : AppCompatActivity(), HasSupportFragmentInjector {
@Inject lateinit var fragmentDispatchingAndroidInjector: DispatchingAndroidInjector<Fragment>
// TODO - workaround for now... We will move to a single activity
@Inject lateinit var serverInteractor: GetCurrentServerInteractor
@Inject lateinit var managerFactory: ConnectionManagerFactory
private lateinit var chatRoomId: String
private lateinit var chatRoomName: String
private lateinit var chatRoomType: String
......@@ -42,6 +49,9 @@ class ChatRoomActivity : AppCompatActivity(), HasSupportFragmentInjector {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_chat_room)
// Workaround for when we are coming to the app via the recents app and the app was killed.
managerFactory.create(serverInteractor.get()!!).connect()
chatRoomId = intent.getStringExtra(INTENT_CHAT_ROOM_ID)
requireNotNull(chatRoomId) { "no chat_room_id provided in Intent extras" }
......
......@@ -27,6 +27,7 @@ import chat.rocket.android.widget.emoji.ComposerEditText
import chat.rocket.android.widget.emoji.Emoji
import chat.rocket.android.widget.emoji.EmojiKeyboardPopup
import chat.rocket.android.widget.emoji.EmojiParser
import chat.rocket.core.internal.realtime.State
import dagger.android.support.AndroidSupportInjection
import io.reactivex.disposables.CompositeDisposable
import kotlinx.android.synthetic.main.fragment_chat_room.*
......@@ -114,7 +115,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardPopup.Listener {
}
override fun onDestroyView() {
presenter.unsubscribeMessages()
presenter.unsubscribeMessages(chatRoomId)
handler.removeCallbacksAndMessages(null)
unsubscribeTextMessage()
super.onDestroyView()
......@@ -225,6 +226,12 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardPopup.Listener {
actionSnackbar.title = username
actionSnackbar.text = quotedMessage
actionSnackbar.show()
KeyboardHelper.showSoftKeyboard(text_message)
if (!recycler_view.isAtBottom()) {
if (adapter.itemCount > 0) {
recycler_view.scrollToPosition(0)
}
}
}
}
......@@ -252,6 +259,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardPopup.Listener {
actionSnackbar.show()
text_message.textContent = text
editingMessageId = messageId
KeyboardHelper.showSoftKeyboard(text_message)
}
}
......@@ -288,6 +296,28 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardPopup.Listener {
showMessage(getString(R.string.max_file_size_exceeded, fileSize, maxFileSize))
}
override fun showConnectionState(state: State) {
activity?.apply {
connection_status_text.fadeIn()
handler.removeCallbacks(dismissStatus)
when (state) {
is State.Connected -> {
connection_status_text.text = getString(R.string.status_connected)
handler.postDelayed(dismissStatus, 2000)
}
is State.Disconnected -> connection_status_text.text = getString(R.string.status_disconnected)
is State.Connecting -> connection_status_text.text = getString(R.string.status_connecting)
is State.Authenticating -> connection_status_text.text = getString(R.string.status_authenticating)
is State.Disconnecting -> connection_status_text.text = getString(R.string.status_disconnecting)
is State.Waiting -> connection_status_text.text = getString(R.string.status_waiting, state.seconds)
}
}
}
private val dismissStatus = {
connection_status_text.fadeOut()
}
private fun setupRecyclerView() {
recycler_view.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
......@@ -320,6 +350,11 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardPopup.Listener {
emojiKeyboardPopup.listener = this
text_message.listener = object : ComposerEditText.ComposerEditTextListener {
override fun onKeyboardOpened() {
if (recycler_view.isAtBottom()) {
if (adapter.itemCount > 0) {
recycler_view.scrollToPosition(0)
}
}
}
override fun onKeyboardClosed() {
......@@ -395,6 +430,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardPopup.Listener {
actionSnackbar = ActionSnackbar.make(message_list_container, parser = parser)
actionSnackbar.cancelView.setOnClickListener({
clearMessageComposition()
KeyboardHelper.showSoftKeyboard(text_message)
})
}
......
package chat.rocket.android.chatroom.viewmodel
import chat.rocket.android.R
import chat.rocket.core.model.Message
import chat.rocket.core.model.attachment.AudioAttachment
data class AudioAttachmentViewModel(
override val message: Message,
override val rawData: AudioAttachment,
override val messageId: String,
override val attachmentUrl: String,
......
package chat.rocket.android.chatroom.viewmodel
import chat.rocket.core.model.Message
import java.security.InvalidParameterException
interface BaseViewModel<out T> {
val message: Message
val rawData: T
val messageId: String
val viewType: Int
......
package chat.rocket.android.chatroom.viewmodel
import chat.rocket.android.R
import chat.rocket.core.model.Message
import chat.rocket.core.model.attachment.ImageAttachment
data class ImageAttachmentViewModel(
override val message: Message,
override val rawData: ImageAttachment,
override val messageId: String,
override val attachmentUrl: String,
......
......@@ -4,14 +4,14 @@ import chat.rocket.android.R
import chat.rocket.core.model.Message
data class MessageViewModel(
override val message: Message,
override val rawData: Message,
override val messageId: String,
override val avatar: String,
override val time: CharSequence,
override val senderName: CharSequence,
override val content: CharSequence,
override val isPinned: Boolean,
val isSystemMessage: Boolean
override val isPinned: Boolean
) : BaseMessageViewModel<Message> {
override val viewType: Int
get() = BaseViewModel.ViewType.MESSAGE.viewType
......
package chat.rocket.android.chatroom.viewmodel
import chat.rocket.android.R
import chat.rocket.core.model.Message
import chat.rocket.core.model.url.Url
data class UrlPreviewViewModel(
override val message: Message,
override val rawData: Url,
override val messageId: String,
val title: CharSequence?,
......
package chat.rocket.android.chatroom.viewmodel
import chat.rocket.android.R
import chat.rocket.core.model.Message
import chat.rocket.core.model.attachment.VideoAttachment
data class VideoAttachmentViewModel(
override val message: Message,
override val rawData: VideoAttachment,
override val messageId: String,
override val attachmentUrl: String,
......
......@@ -19,11 +19,13 @@ import chat.rocket.core.model.Message
import chat.rocket.core.model.MessageType
import chat.rocket.core.model.Value
import chat.rocket.core.model.attachment.*
import chat.rocket.core.model.isSystemMessage
import chat.rocket.core.model.url.Url
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.withContext
import okhttp3.HttpUrl
import timber.log.Timber
import java.security.InvalidParameterException
import javax.inject.Inject
class ViewModelMapper @Inject constructor(private val context: Context,
......@@ -81,7 +83,7 @@ class ViewModelMapper @Inject constructor(private val context: Context,
val title = url.meta?.title
val description = url.meta?.description
return UrlPreviewViewModel(url, message.id, title, hostname, description, thumb)
return UrlPreviewViewModel(message, url, message.id, title, hostname, description, thumb)
}
private fun mapAttachment(message: Message, attachment: Attachment): BaseViewModel<*>? {
......@@ -96,12 +98,12 @@ class ViewModelMapper @Inject constructor(private val context: Context,
val attachmentTitle = attachment.title
val id = "${message.id}_${attachment.titleLink}".hashCode().toLong()
return when (attachment) {
is ImageAttachment -> ImageAttachmentViewModel(attachment, message.id, attachmentUrl,
attachmentTitle ?: "", id)
is VideoAttachment -> VideoAttachmentViewModel(attachment, message.id,
is ImageAttachment -> ImageAttachmentViewModel(message, attachment, message.id,
attachmentUrl, attachmentTitle ?: "", id)
is VideoAttachment -> VideoAttachmentViewModel(message, attachment, message.id,
attachmentUrl, attachmentTitle ?: "", id)
is AudioAttachment -> AudioAttachmentViewModel(message, attachment, message.id,
attachmentUrl, attachmentTitle ?: "", id)
is AudioAttachment -> AudioAttachmentViewModel(attachment,
message.id, attachmentUrl, attachmentTitle ?: "", id)
else -> null
}
}
......@@ -143,13 +145,16 @@ class ViewModelMapper @Inject constructor(private val context: Context,
}
val content = getContent(context, message, quote)
MessageViewModel(rawData = message, messageId = message.id,
MessageViewModel(message = message, rawData = message, messageId = message.id,
avatar = avatar!!, time = time, senderName = sender,
content = content.first, isPinned = message.pinned,
isSystemMessage = content.second)
content = content, isPinned = message.pinned)
}
private fun getSenderName(message: Message): CharSequence {
if (!message.senderAlias.isNullOrEmpty()) {
return message.senderAlias!!
}
val username = message.sender?.username
val realName = message.sender?.name
val senderName = if (settings.useRealName()) realName else username
......@@ -157,6 +162,10 @@ class ViewModelMapper @Inject constructor(private val context: Context,
}
private fun getUserAvatar(message: Message): String? {
message.avatar?.let {
return it // Always give preference for overridden avatar from message
}
val username = message.sender?.username ?: "?"
return baseUrl?.let {
UrlHelper.getAvatarUrl(baseUrl, username)
......@@ -171,28 +180,14 @@ class ViewModelMapper @Inject constructor(private val context: Context,
Timber.d("Will quote message Id: $msgIdToQuote")
return if (msgIdToQuote != null) messagesRepository.getById(msgIdToQuote) else null
}
return null
}
private suspend fun getContent(context: Context, message: Message, quote: Message?): Pair<CharSequence, Boolean> {
var systemMessage = true
val content = when (message.type) {
//TODO: Add implementation for Welcome type.
is MessageType.MessageRemoved -> getSystemMessage(context.getString(R.string.message_removed))
is MessageType.UserJoined -> getSystemMessage(context.getString(R.string.message_user_joined_channel))
is MessageType.UserLeft -> getSystemMessage(context.getString(R.string.message_user_left))
is MessageType.UserAdded -> getSystemMessage(context.getString(R.string.message_user_added_by, message.message, message.sender?.username))
is MessageType.RoomNameChanged -> getSystemMessage(context.getString(R.string.message_room_name_changed, message.message, message.sender?.username))
is MessageType.UserRemoved -> getSystemMessage(context.getString(R.string.message_user_removed_by, message.message, message.sender?.username))
is MessageType.MessagePinned -> getSystemMessage(context.getString(R.string.message_pinned))
else -> {
systemMessage = false
getNormalMessage(message, quote)
}
private suspend fun getContent(context: Context, message: Message, quote: Message?): CharSequence {
return when (message.isSystemMessage()) {
true -> getSystemMessage(message, context)
false -> getNormalMessage(message, quote)
}
return Pair(content, systemMessage)
}
private suspend fun getNormalMessage(message: Message, quote: Message?): CharSequence {
......@@ -204,7 +199,32 @@ class ViewModelMapper @Inject constructor(private val context: Context,
return parser.renderMarkdown(message.message, quoteViewModel, currentUsername)
}
private fun getSystemMessage(content: String): CharSequence {
private fun getSystemMessage(message: Message, context: Context): CharSequence {
val content = when (message.type) {
//TODO: Add implementation for Welcome type.
is MessageType.MessageRemoved -> context.getString(R.string.message_removed)
is MessageType.UserJoined -> context.getString(R.string.message_user_joined_channel)
is MessageType.UserLeft -> context.getString(R.string.message_user_left)
is MessageType.UserAdded -> context.getString(R.string.message_user_added_by, message.message, message.sender?.username)
is MessageType.RoomNameChanged -> context.getString(R.string.message_room_name_changed, message.message, message.sender?.username)
is MessageType.UserRemoved -> context.getString(R.string.message_user_removed_by, message.message, message.sender?.username)
is MessageType.MessagePinned -> {
val attachment = message.attachments?.get(0)
val pinnedSystemMessage = context.getString(R.string.message_pinned)
if (attachment != null && attachment is MessageAttachment) {
return SpannableStringBuilder(pinnedSystemMessage)
.apply {
setSpan(StyleSpan(Typeface.ITALIC), 0, length, 0)
setSpan(ForegroundColorSpan(Color.GRAY), 0, length, 0)
}
.append(quoteMessage(attachment.author!!, attachment.text!!, attachment.timestamp!!))
}
return pinnedSystemMessage
}
else -> {
throw InvalidParameterException("Invalid message type: ${message.type}")
}
}
//isSystemMessage = true
val spannableMsg = SpannableStringBuilder(content)
spannableMsg.setSpan(StyleSpan(Typeface.ITALIC), 0, spannableMsg.length,
......
package chat.rocket.android.chatrooms.presentation
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.server.domain.GetChatRoomsInteractor
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.RefreshSettingsInteractor
import chat.rocket.android.server.domain.SaveChatRoomsInteractor
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.infraestructure.ConnectionManager
import chat.rocket.android.server.infraestructure.ConnectionManagerFactory
import chat.rocket.android.server.infraestructure.chatRooms
import chat.rocket.android.server.infraestructure.state
import chat.rocket.android.util.extensions.launchUI
import chat.rocket.common.RocketChatException
import chat.rocket.core.RocketChatClient
import chat.rocket.common.model.BaseRoom
import chat.rocket.common.model.RoomType
import chat.rocket.core.internal.model.Subscription
import chat.rocket.core.internal.realtime.*
import chat.rocket.core.internal.rest.chatRooms
import chat.rocket.core.internal.realtime.State
import chat.rocket.core.internal.realtime.StreamMessage
import chat.rocket.core.internal.realtime.Type
import chat.rocket.core.model.ChatRoom
import chat.rocket.core.model.Room
import kotlinx.coroutines.experimental.*
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.channels.Channel
import timber.log.Timber
import javax.inject.Inject
......@@ -26,31 +29,47 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
private val getChatRoomsInteractor: GetChatRoomsInteractor,
private val saveChatRoomsInteractor: SaveChatRoomsInteractor,
private val refreshSettingsInteractor: RefreshSettingsInteractor,
factory: RocketChatClientFactory) {
private val client: RocketChatClient = factory.create(serverInteractor.get()!!)
settingsRepository: SettingsRepository,
factory: ConnectionManagerFactory) {
private val manager: ConnectionManager = factory.create(serverInteractor.get()!!)
private val currentServer = serverInteractor.get()!!
private var reloadJob: Deferred<List<ChatRoom>>? = null
private val settings = settingsRepository.get(currentServer)!!
private val subscriptionsChannel = Channel<StreamMessage<BaseRoom>>()
private val stateChannel = Channel<State>()
private var lastState = manager.state
fun loadChatRooms() {
refreshSettingsInteractor.refreshAsync(currentServer)
launchUI(strategy) {
view.showLoading()
subscribeStatusChange()
try {
view.updateChatRooms(loadRooms())
subscribeRoomUpdates()
} catch (e: RocketChatException) {
Timber.e(e)
view.showMessage(e.message!!)
} finally {
view.hideLoading()
}
subscribeRoomUpdates()
}
}
fun loadChatRoom(chatRoom: ChatRoom) = navigator.toChatRoom(chatRoom.id, chatRoom.name,
fun loadChatRoom(chatRoom: ChatRoom) {
val roomName = if (chatRoom.type is RoomType.DirectMessage
&& chatRoom.fullName != null
&& settings.useRealName()) {
chatRoom.fullName!!
} else {
chatRoom.name
}
navigator.toChatRoom(chatRoom.id, roomName,
chatRoom.type.toString(), chatRoom.readonly ?: false)
}
/**
* Gets a [ChatRoom] list from local repository.
......@@ -65,8 +84,9 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
}
private suspend fun loadRooms(): List<ChatRoom> {
val chatRooms = client.chatRooms().update
val chatRooms = manager.chatRooms().update
val sortedRooms = sortRooms(chatRooms)
Timber.d("Loaded rooms: ${sortedRooms.size}")
saveChatRoomsInteractor.save(currentServer, sortedRooms)
return sortedRooms
}
......@@ -77,6 +97,7 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
}
private fun updateRooms() {
Timber.d("Updating Rooms")
launch {
view.updateChatRooms(getChatRoomsInteractor.get(currentServer))
}
......@@ -92,52 +113,43 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
}
}
// TODO - Temporary stuff, remove when adding DB support
private suspend fun subscribeRoomUpdates() {
client.addStateChannel(stateChannel)
private suspend fun subscribeStatusChange() {
lastState = manager.state
launch(CommonPool + strategy.jobs) {
for (status in stateChannel) {
Timber.d("Changing status to: $status")
when (status) {
State.Authenticating -> Timber.d("Authenticating")
State.Connected -> {
Timber.d("Connected")
client.subscribeSubscriptions {
Timber.d("subscriptions: $it")
}
client.subscribeRooms {
Timber.d("rooms: $it")
}
for (state in stateChannel) {
Timber.d("Got new state: $state - last: $lastState")
if (state != lastState) {
launch(UI) {
view.showConnectionState(state)
}
if (state is State.Connected) {
reloadRooms()
updateRooms()
}
}
Timber.d("Done on statusChannel")
lastState = state
}
when (client.state) {
State.Connected -> {
Timber.d("Already connected")
}
else -> client.connect()
}
// TODO - Temporary stuff, remove when adding DB support
private suspend fun subscribeRoomUpdates() {
manager.addStatusChannel(stateChannel)
manager.addRoomsAndSubscriptionsChannel(subscriptionsChannel)
launch(CommonPool + strategy.jobs) {
for (message in client.roomsChannel) {
for (message in subscriptionsChannel) {
Timber.d("Got message: $message")
updateRoom(message)
}
when (message.data) {
is Room -> updateRoom(message as StreamMessage<Room>)
is Subscription -> updateSubscription(message as StreamMessage<Subscription>)
}
launch(CommonPool + strategy.jobs) {
for (message in client.subscriptionsChannel) {
Timber.d("Got message: $message")
updateSubscription(message)
}
}
}
private fun updateRoom(message: StreamMessage<Room>) {
launchUI(strategy) {
private suspend fun updateRoom(message: StreamMessage<Room>) {
Timber.d("Update Room: ${message.type} - ${message.data.id} - ${message.data.name}")
when (message.type) {
Type.Removed -> {
removeRoom(message.data.id)
......@@ -154,10 +166,9 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
updateRooms()
}
}
private fun updateSubscription(message: StreamMessage<Subscription>) {
launchUI(strategy) {
private suspend fun updateSubscription(message: StreamMessage<Subscription>) {
Timber.d("Update Subscription: ${message.type} - ${message.data.id} - ${message.data.name}")
when (message.type) {
Type.Removed -> {
removeRoom(message.data.roomId)
......@@ -174,22 +185,26 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
updateRooms()
}
}
private suspend fun reloadRooms() {
Timber.d("realoadRooms()")
reloadJob?.cancel()
try {
reloadJob = async(CommonPool + strategy.jobs) {
delay(1000)
Timber.d("reloading rooms after wait")
loadRooms()
}
reloadJob?.await()
} catch (ex: Exception) {
ex.printStackTrace()
}
}
// Update a ChatRoom with a Room information
private fun updateRoom(room: Room) {
Timber.d("Updating Room: ${room.id} - ${room.name}")
val chatRooms = getChatRoomsInteractor.get(currentServer).toMutableList()
val chatRoom = chatRooms.find { chatRoom -> chatRoom.id == room.id }
chatRoom?.apply {
......@@ -220,6 +235,7 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
// Update a ChatRoom with a Subscription information
private fun updateSubscription(subscription: Subscription) {
Timber.d("Updating subscrition: ${subscription.id} - ${subscription.name}")
val chatRooms = getChatRoomsInteractor.get(currentServer).toMutableList()
val chatRoom = chatRooms.find { chatRoom -> chatRoom.id == subscription.roomId }
chatRoom?.apply {
......@@ -251,6 +267,7 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
private fun removeRoom(id: String,
chatRooms: MutableList<ChatRoom> = getChatRoomsInteractor.get(currentServer).toMutableList()) {
Timber.d("Removing ROOM: $id")
synchronized(this) {
chatRooms.removeAll { chatRoom -> chatRoom.id == id }
}
......@@ -258,7 +275,7 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
}
fun disconnect() {
client.removeStateChannel(stateChannel)
client.disconnect()
manager.removeStatusChannel(stateChannel)
manager.removeRoomsAndSubscriptionsChannel(subscriptionsChannel)
}
}
\ No newline at end of file
......@@ -2,6 +2,7 @@ package chat.rocket.android.chatrooms.presentation
import chat.rocket.android.core.behaviours.LoadingView
import chat.rocket.android.core.behaviours.MessageView
import chat.rocket.core.internal.realtime.State
import chat.rocket.core.model.ChatRoom
interface ChatRoomsView : LoadingView, MessageView {
......@@ -17,4 +18,6 @@ interface ChatRoomsView : LoadingView, MessageView {
* Shows no chat rooms to display.
*/
fun showNoChatRoomsToDisplay()
fun showConnectionState(state: State)
}
\ No newline at end of file
......@@ -9,6 +9,8 @@ import android.view.ViewGroup
import android.widget.TextView
import chat.rocket.android.R
import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.server.domain.PublicSettings
import chat.rocket.android.server.domain.useRealName
import chat.rocket.android.util.extensions.content
import chat.rocket.android.util.extensions.inflate
import chat.rocket.android.util.extensions.setVisible
......@@ -21,6 +23,7 @@ import kotlinx.android.synthetic.main.item_chat.view.*
import kotlinx.android.synthetic.main.unread_messages_badge.view.*
class ChatRoomsAdapter(private val context: Context,
private val settings: PublicSettings,
private val listener: (ChatRoom) -> Unit) : RecyclerView.Adapter<ChatRoomsAdapter.ViewHolder>() {
var dataSet: MutableList<ChatRoom> = ArrayList()
......@@ -69,8 +72,12 @@ class ChatRoomsAdapter(private val context: Context,
}
private fun bindName(chatRoom: ChatRoom, textView: TextView) {
if (chatRoom.type is RoomType.DirectMessage && settings.useRealName()) {
textView.content = chatRoom.fullName ?: chatRoom.name
} else {
textView.content = chatRoom.name
}
}
private fun bindLastMessageDateTime(chatRoom: ChatRoom, textView: TextView) {
val lastMessage = chatRoom.lastMessage
......
package chat.rocket.android.chatrooms.ui
import android.os.Bundle
import android.os.Handler
import android.support.v4.app.Fragment
import android.support.v7.app.AppCompatActivity
import android.support.v7.util.DiffUtil
......@@ -11,14 +12,16 @@ import android.view.*
import chat.rocket.android.R
import chat.rocket.android.chatrooms.presentation.ChatRoomsPresenter
import chat.rocket.android.chatrooms.presentation.ChatRoomsView
import chat.rocket.android.util.extensions.inflate
import chat.rocket.android.util.extensions.setVisible
import chat.rocket.android.util.extensions.showToast
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.SettingsRepository
import chat.rocket.android.util.extensions.*
import chat.rocket.android.widget.DividerItemDecoration
import chat.rocket.core.internal.realtime.State
import chat.rocket.core.model.ChatRoom
import dagger.android.support.AndroidSupportInjection
import kotlinx.android.synthetic.main.fragment_chat_rooms.*
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.Job
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.launch
......@@ -26,7 +29,12 @@ import javax.inject.Inject
class ChatRoomsFragment : Fragment(), ChatRoomsView {
@Inject lateinit var presenter: ChatRoomsPresenter
@Inject lateinit var serverInteractor: GetCurrentServerInteractor
@Inject lateinit var settingsRepository: SettingsRepository
private var searchView: SearchView? = null
private val handler = Handler()
private var listJob: Job? = null
companion object {
fun newInstance() = ChatRoomsFragment()
......@@ -39,6 +47,7 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
}
override fun onDestroy() {
handler.removeCallbacks(dismissStatus)
presenter.disconnect()
super.onDestroy()
}
......@@ -72,17 +81,22 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
override suspend fun updateChatRooms(newDataSet: List<ChatRoom>) {
activity.apply {
launch(UI) {
listJob?.cancel()
listJob = launch(UI) {
val adapter = recycler_view.adapter as ChatRoomsAdapter
// FIXME https://fabric.io/rocketchat3/android/apps/chat.rocket.android.dev/issues/5a90d4718cb3c2fa63b3f557?time=last-seven-days
// TODO - fix this bug to reenable DiffUtil
val diff = async(CommonPool) {
DiffUtil.calculateDiff(RoomsDiffCallback(adapter.dataSet, newDataSet))
}.await()
if (isActive) {
adapter.updateRooms(newDataSet)
diff.dispatchUpdatesTo(adapter)
}
}
}
}
override fun showNoChatRoomsToDisplay() = text_no_data_to_display.setVisible(true)
......@@ -96,6 +110,28 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
override fun showGenericErrorMessage() = showMessage(getString(R.string.msg_generic_error))
override fun showConnectionState(state: State) {
activity?.apply {
connection_status_text.fadeIn()
handler.removeCallbacks(dismissStatus)
when (state) {
is State.Connected -> {
connection_status_text.text = getString(R.string.status_connected)
handler.postDelayed(dismissStatus, 2000)
}
is State.Disconnected -> connection_status_text.text = getString(R.string.status_disconnected)
is State.Connecting -> connection_status_text.text = getString(R.string.status_connecting)
is State.Authenticating -> connection_status_text.text = getString(R.string.status_authenticating)
is State.Disconnecting -> connection_status_text.text = getString(R.string.status_disconnecting)
is State.Waiting -> connection_status_text.text = getString(R.string.status_waiting, state.seconds)
}
}
}
private val dismissStatus = {
connection_status_text.fadeOut()
}
private fun setupToolbar() {
(activity as AppCompatActivity).supportActionBar?.title = getString(R.string.title_chats)
}
......@@ -107,7 +143,9 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
resources.getDimensionPixelSize(R.dimen.divider_item_decorator_bound_start),
resources.getDimensionPixelSize(R.dimen.divider_item_decorator_bound_end)))
recycler_view.itemAnimator = DefaultItemAnimator()
recycler_view.adapter = ChatRoomsAdapter(this) { chatRoom ->
// TODO - use a ViewModel Mapper instead of using settings on the adapter
recycler_view.adapter = ChatRoomsAdapter(this,
settingsRepository.get(serverInteractor.get()!!)!!) { chatRoom ->
presenter.loadChatRoom(chatRoom)
}
}
......
......@@ -13,11 +13,15 @@ import android.support.v4.content.res.ResourcesCompat
import android.text.Layout
import android.text.Spannable
import android.text.Spanned
import android.text.TextUtils
import android.text.style.*
import android.util.Patterns
import android.view.View
import chat.rocket.android.R
import chat.rocket.android.chatroom.viewmodel.MessageViewModel
import chat.rocket.android.widget.emoji.EmojiParser
import chat.rocket.android.widget.emoji.EmojiRepository
import chat.rocket.android.widget.emoji.EmojiTypefaceSpan
import org.commonmark.node.AbstractVisitor
import org.commonmark.node.BlockQuote
import org.commonmark.node.Text
......@@ -47,7 +51,7 @@ class MessageParser @Inject constructor(val context: Application, private val co
*/
fun renderMarkdown(text: String, quote: MessageViewModel? = null, selfUsername: String? = null): CharSequence {
val builder = SpannableBuilder()
val content = text
val content = EmojiRepository.shortnameToUnicode(text, true)
val parentNode = parser.parse(toLenientMarkdown(content))
parentNode.accept(QuoteMessageBodyVisitor(context, configuration, builder))
quote?.apply {
......@@ -58,6 +62,7 @@ class MessageParser @Inject constructor(val context: Application, private val co
quoteNode.accept(QuoteMessageBodyVisitor(context, configuration, builder))
}
parentNode.accept(LinkVisitor(builder))
parentNode.accept(EmojiVisitor(builder))
val result = builder.text()
applySpans(result, selfUsername)
......@@ -139,6 +144,19 @@ class MessageParser @Inject constructor(val context: Application, private val co
}
}
class EmojiVisitor(private val builder: SpannableBuilder) : AbstractVisitor() {
override fun visit(text: Text) {
val spannable = EmojiParser.parse(text.literal)
if (spannable is Spanned) {
val spans = spannable.getSpans(0, spannable.length, EmojiTypefaceSpan::class.java)
spans.forEach {
builder.setSpan(it, spannable.getSpanStart(it), spannable.getSpanEnd(it), 0)
}
}
visitChildren(text)
}
}
class LinkVisitor(private val builder: SpannableBuilder) : AbstractVisitor() {
override fun visit(text: Text) {
......
......@@ -2,6 +2,7 @@ package chat.rocket.android.main.presentation
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.infraestructure.ConnectionManagerFactory
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.common.RocketChatException
import chat.rocket.core.RocketChatClient
......@@ -13,9 +14,11 @@ import javax.inject.Inject
class MainPresenter @Inject constructor(private val navigator: MainNavigator,
private val serverInteractor: GetCurrentServerInteractor,
private val localRepository: LocalRepository,
managerFactory: ConnectionManagerFactory,
factory: RocketChatClientFactory) {
private val client: RocketChatClient = factory.create(serverInteractor.get()!!)
private val currentServer = serverInteractor.get()!!
private val manager = managerFactory.create(currentServer)
private val client: RocketChatClient = factory.create(currentServer)
fun toChatList() = navigator.toChatList()
......@@ -49,4 +52,12 @@ class MainPresenter @Inject constructor(private val navigator: MainNavigator,
}
localRepository.clearAllFromServer(currentServer)
}
fun connect() {
manager.connect()
}
fun disconnect() {
manager.disconnect()
}
}
\ No newline at end of file
......@@ -29,6 +29,7 @@ class MainActivity : AppCompatActivity(), MainView, HasSupportFragmentInjector {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
presenter.connect()
setupToolbar()
setupNavigationView()
}
......@@ -41,6 +42,13 @@ class MainActivity : AppCompatActivity(), MainView, HasSupportFragmentInjector {
}
}
override fun onDestroy() {
super.onDestroy()
if (isFinishing) {
presenter.disconnect()
}
}
override fun onLogout() {
finish()
val intent = Intent(this, AuthenticationActivity::class.java)
......
......@@ -48,30 +48,30 @@ const val ALLOW_MESSAGE_PINNING = "Message_AllowPinning"
* If you need to access a Setting, add a const val key above, add it to the filter on
* ServerPresenter.kt and a extension function to access it
*/
fun Map<String, Value<Any>>.googleEnabled(): Boolean = this[ACCOUNT_GOOGLE]?.value == true
fun Map<String, Value<Any>>.facebookEnabled(): Boolean = this[ACCOUNT_FACEBOOK]?.value == true
fun Map<String, Value<Any>>.githubEnabled(): Boolean = this[ACCOUNT_GITHUB]?.value == true
fun Map<String, Value<Any>>.linkedinEnabled(): Boolean = this[ACCOUNT_LINKEDIN]?.value == true
fun Map<String, Value<Any>>.meteorEnabled(): Boolean = this[ACCOUNT_METEOR]?.value == true
fun Map<String, Value<Any>>.twitterEnabled(): Boolean = this[ACCOUNT_TWITTER]?.value == true
fun Map<String, Value<Any>>.gitlabEnabled(): Boolean = this[ACCOUNT_GITLAB]?.value == true
fun Map<String, Value<Any>>.wordpressEnabled(): Boolean = this[ACCOUNT_WORDPRESS]?.value == true
fun PublicSettings.googleEnabled(): Boolean = this[ACCOUNT_GOOGLE]?.value == true
fun PublicSettings.facebookEnabled(): Boolean = this[ACCOUNT_FACEBOOK]?.value == true
fun PublicSettings.githubEnabled(): Boolean = this[ACCOUNT_GITHUB]?.value == true
fun PublicSettings.linkedinEnabled(): Boolean = this[ACCOUNT_LINKEDIN]?.value == true
fun PublicSettings.meteorEnabled(): Boolean = this[ACCOUNT_METEOR]?.value == true
fun PublicSettings.twitterEnabled(): Boolean = this[ACCOUNT_TWITTER]?.value == true
fun PublicSettings.gitlabEnabled(): Boolean = this[ACCOUNT_GITLAB]?.value == true
fun PublicSettings.wordpressEnabled(): Boolean = this[ACCOUNT_WORDPRESS]?.value == true
fun Map<String, Value<Any>>.useRealName(): Boolean = this[USE_REALNAME]?.value == true
fun PublicSettings.useRealName(): Boolean = this[USE_REALNAME]?.value == true
// Message settings
fun Map<String, Value<Any>>.showDeletedStatus(): Boolean = this[SHOW_DELETED_STATUS]?.value == true
fun Map<String, Value<Any>>.showEditedStatus(): Boolean = this[SHOW_EDITED_STATUS]?.value == true
fun Map<String, Value<Any>>.allowedMessagePinning(): Boolean = this[ALLOW_MESSAGE_PINNING]?.value == true
fun Map<String, Value<Any>>.allowedMessageEditing(): Boolean = this[ALLOW_MESSAGE_EDITING]?.value == true
fun Map<String, Value<Any>>.allowedMessageDeleting(): Boolean = this[ALLOW_MESSAGE_DELETING]?.value == true
fun PublicSettings.showDeletedStatus(): Boolean = this[SHOW_DELETED_STATUS]?.value == true
fun PublicSettings.showEditedStatus(): Boolean = this[SHOW_EDITED_STATUS]?.value == true
fun PublicSettings.allowedMessagePinning(): Boolean = this[ALLOW_MESSAGE_PINNING]?.value == true
fun PublicSettings.allowedMessageEditing(): Boolean = this[ALLOW_MESSAGE_EDITING]?.value == true
fun PublicSettings.allowedMessageDeleting(): Boolean = this[ALLOW_MESSAGE_DELETING]?.value == true
fun Map<String, Value<Any>>.registrationEnabled(): Boolean {
fun PublicSettings.registrationEnabled(): Boolean {
val value = this[ACCOUNT_REGISTRATION]
return value?.value == "Public"
}
fun Map<String, Value<Any>>.uploadMimeTypeFilter(): Array<String> {
fun PublicSettings.uploadMimeTypeFilter(): Array<String> {
val values = this[UPLOAD_WHITELIST_MIMETYPES]?.value
values?.let { it as String }?.split(",")?.let {
return it.mapToTypedArray { it.trim() }
......@@ -80,8 +80,8 @@ fun Map<String, Value<Any>>.uploadMimeTypeFilter(): Array<String> {
return arrayOf("*/*")
}
fun Map<String, Value<Any>>.uploadMaxFileSize(): Int {
fun PublicSettings.uploadMaxFileSize(): Int {
return this[UPLOAD_MAX_FILE_SIZE]?.value?.let { it as Int } ?: Int.MAX_VALUE
}
fun Map<String, Value<Any>>.baseUrl() : String? = this[SITE_URL]?.value as String
\ No newline at end of file
fun PublicSettings.baseUrl(): String? = this[SITE_URL]?.value as String
\ No newline at end of file
package chat.rocket.android.server.infraestructure
import chat.rocket.common.model.BaseRoom
import chat.rocket.core.RocketChatClient
import chat.rocket.core.internal.realtime.*
import chat.rocket.core.internal.rest.chatRooms
import chat.rocket.core.model.Message
import kotlinx.coroutines.experimental.Job
import kotlinx.coroutines.experimental.channels.Channel
import kotlinx.coroutines.experimental.launch
import timber.log.Timber
class ConnectionManager(internal val client: RocketChatClient) {
private val statusChannelList = ArrayList<Channel<State>>()
private val statusChannel = Channel<State>()
private var connectJob: Job? = null
private val roomAndSubscriptionChannels = ArrayList<Channel<StreamMessage<BaseRoom>>>()
private val roomMessagesChannels = LinkedHashMap<String, Channel<Message>>()
private val subscriptionIdMap = HashMap<String, String>()
private var subscriptionId: String? = null
private var roomsId: String? = null
fun connect() {
if (connectJob?.isActive == true
&& (state !is State.Disconnected)) {
Timber.d("Already connected, just returning...")
return
}
// cleanup first
client.removeStateChannel(statusChannel)
client.disconnect()
connectJob?.cancel()
// Connect and setup
client.addStateChannel(statusChannel)
connectJob = launch {
for (status in statusChannel) {
Timber.d("Changing status to: $status")
when (status) {
is State.Connected -> {
client.subscribeSubscriptions { _, id ->
Timber.d("Subscribed to subscriptions: $id")
subscriptionId = id
}
client.subscribeRooms { _, id ->
Timber.d("Subscribed to rooms: $id")
roomsId = id
}
resubscribeRooms()
}
is State.Waiting -> {
Timber.d("Connection in: ${status.seconds}")
}
}
for (channel in statusChannelList) {
channel.send(status)
}
}
}
launch(parent = connectJob) {
for (room in client.roomsChannel) {
Timber.d("GOT Room streamed")
for (channel in roomAndSubscriptionChannels) {
channel.send(room)
}
}
}
launch(parent = connectJob) {
for (subscription in client.subscriptionsChannel) {
Timber.d("GOT Subscription streamed")
for (channel in roomAndSubscriptionChannels) {
channel.send(subscription)
}
}
}
launch(parent = connectJob) {
for (message in client.messagesChannel) {
Timber.d("Received new Message for room ${message.roomId}")
val channel = roomMessagesChannels[message.roomId]
channel?.send(message)
}
}
client.connect()
// Broadcast initial state...
val state = client.state
for (channel in statusChannelList) {
channel.offer(state)
}
}
private fun resubscribeRooms() {
roomMessagesChannels.toList().map { (roomId, channel) ->
client.subscribeRoomMessages(roomId) { _, id ->
Timber.d("Subscribed to $roomId: $id")
subscriptionIdMap[roomId] = id
}
}
}
fun disconnect() {
Timber.d("ConnectionManager DISCONNECT")
client.removeStateChannel(statusChannel)
client.disconnect()
connectJob?.cancel()
}
fun addStatusChannel(channel: Channel<State>) = statusChannelList.add(channel)
fun removeStatusChannel(channel: Channel<State>) = statusChannelList.remove(channel)
fun addRoomsAndSubscriptionsChannel(channel: Channel<StreamMessage<BaseRoom>>) = roomAndSubscriptionChannels.add(channel)
fun removeRoomsAndSubscriptionsChannel(channel: Channel<StreamMessage<BaseRoom>>) = roomAndSubscriptionChannels.remove(channel)
fun subscribeRoomMessages(roomId: String, channel: Channel<Message>) {
val oldSub = roomMessagesChannels.put(roomId, channel)
if (oldSub != null) {
Timber.d("Room $roomId already subscribed...")
return
}
if (client.state is State.Connected) {
client.subscribeRoomMessages(roomId) { _, id ->
Timber.d("Subscribed to $roomId: $id")
subscriptionIdMap[roomId] = id
}
}
}
fun unsubscribeRoomMessages(roomId: String) {
val sub = roomMessagesChannels.remove(roomId)
if (sub != null) {
val id = subscriptionIdMap.remove(roomId)
id?.let { client.unsubscribe(it) }
}
}
}
suspend fun ConnectionManager.chatRooms(timestamp: Long = 0, filterCustom: Boolean = true)
= client.chatRooms(timestamp, filterCustom)
val ConnectionManager.state: State
get() = client.state
\ No newline at end of file
package chat.rocket.android.server.infraestructure
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConnectionManagerFactory @Inject constructor(private val factory: RocketChatClientFactory) {
private val cache = HashMap<String, ConnectionManager>()
fun create(url: String): ConnectionManager {
cache[url]?.let {
Timber.d("Returning CACHED Manager for: $url")
return it
}
Timber.d("Returning FRESH Manager for: $url")
val manager = ConnectionManager(factory.create(url))
cache[url] = manager
return manager
}
}
\ No newline at end of file
......@@ -28,7 +28,7 @@ class RocketChatClientFactory @Inject constructor(val okHttpClient: OkHttpClient
}
Timber.d("Returning NEW client for: $url")
cache.put(url, client)
cache[url] = client
return client
}
}
\ No newline at end of file
......@@ -2,19 +2,19 @@ package chat.rocket.android.server.infraestructure
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.infrastructure.LocalRepository.Companion.SETTINGS_KEY
import chat.rocket.android.server.domain.PublicSettings
import chat.rocket.android.server.domain.SettingsRepository
import chat.rocket.core.internal.SettingsAdapter
import chat.rocket.core.model.Value
class SharedPreferencesSettingsRepository(private val localRepository: LocalRepository) : SettingsRepository {
private val adapter = SettingsAdapter().lenient()
override fun save(url: String, settings: Map<String, Value<Any>>) {
override fun save(url: String, settings: PublicSettings) {
localRepository.save("$SETTINGS_KEY$url", adapter.toJson(settings))
}
override fun get(url: String): Map<String, Value<Any>>? {
override fun get(url: String): PublicSettings? {
val settings = localRepository.get("$SETTINGS_KEY$url")
settings?.let {
return adapter.fromJson(it)
......
......@@ -2,7 +2,6 @@ package chat.rocket.android.util.extensions
import android.view.View
import android.view.ViewAnimationUtils
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
fun View.rotateBy(value: Float, duration: Long = 100) {
......@@ -12,36 +11,59 @@ fun View.rotateBy(value: Float, duration: Long = 100) {
.start()
}
fun View.fadeIn(startValue: Float, finishValue: Float, duration: Long = 200) {
animate()
.alpha(startValue)
fun View.fadeIn(start: Float = 0f, end: Float = 1f, duration: Long = 200) {
// already at end alpha, just set visible and return
if (alpha == end) {
setVisible(true)
return
}
val animation = animate()
.alpha(end)
.setDuration(duration)
.setInterpolator(DecelerateInterpolator())
.withEndAction({
if (start != alpha) {
animate()
.alpha(finishValue)
.setDuration(duration)
.setInterpolator(AccelerateInterpolator()).start()
}).start()
.alpha(start)
.setDuration(duration / 2) // half the time, so the entire animation runs on duration
.setInterpolator(DecelerateInterpolator())
.withEndAction {
animation.setDuration(duration / 2).start()
}.start()
} else {
animation.start()
}
setVisible(true)
}
fun View.fadeOut(startValue: Float, finishValue: Float, duration: Long = 200) {
animate()
.alpha(startValue)
fun View.fadeOut(start: Float = 1f, end: Float = 0f, duration: Long = 200) {
if (alpha == end) {
setVisible(false)
return
}
val animation = animate()
.alpha(end)
.setDuration(duration)
.setInterpolator(DecelerateInterpolator())
.withEndAction({
animate()
.alpha(finishValue)
.setDuration(duration)
.setInterpolator(AccelerateInterpolator()).start()
}).start()
.withEndAction {
setVisible(false)
}
if (start != alpha) {
animate()
.alpha(start)
.setDuration(duration / 2) // half the time, so the entire animation runs on duration
.setInterpolator(DecelerateInterpolator())
.withEndAction {
animation.setDuration(duration / 2).start()
}.start()
} else {
animation.start()
}
}
fun View.circularRevealOrUnreveal(centerX: Int, centerY: Int, startRadius: Float, endRadius: Float, duration: Long = 200) {
val anim = ViewAnimationUtils.createCircularReveal(this, centerX, centerY, startRadius, endRadius)
anim.duration = duration
......
......@@ -6,6 +6,8 @@ import android.support.annotation.LayoutRes
import android.support.annotation.StringRes
import android.support.v4.app.Fragment
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
......@@ -55,3 +57,12 @@ fun Activity.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) = To
fun Fragment.showToast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT) = showToast(getString(resource), duration)
fun Fragment.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) = activity!!.showToast(message, duration)
fun RecyclerView.isAtBottom(): Boolean {
val manager: RecyclerView.LayoutManager? = layoutManager
if (manager is LinearLayoutManager) {
return manager.findFirstVisibleItemPosition() == 0
}
return false // or true??? we can't determine the first visible item.
}
\ No newline at end of file
......@@ -60,6 +60,7 @@
android:clickable="true"
android:focusable="true"
android:padding="8dp"
android:visibility="invisible"
android:src="@drawable/ic_search_gray_24px" />
<ImageView
......@@ -75,5 +76,4 @@
android:src="@drawable/ic_backspace_gray_24dp" />
</RelativeLayout>
</android.support.constraint.ConstraintLayout>
\ No newline at end of file
......@@ -53,4 +53,19 @@
android:layout_margin="5dp"
android:visibility="gone" />
<TextView
android:id="@+id/connection_status_text"
android:layout_width="match_parent"
android:layout_height="32dp"
android:background="@color/colorPrimary"
android:elevation="4dp"
android:textColor="@color/white"
android:gravity="center"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
android:visibility="gone"
android:alpha="0"
tools:alpha="1"
tools:visibility="visible"
tools:text="connected"/>
</RelativeLayout>
......@@ -29,4 +29,19 @@
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/connection_status_text"
android:layout_width="match_parent"
android:layout_height="32dp"
android:background="@color/colorPrimary"
android:elevation="4dp"
android:textColor="@color/white"
android:gravity="center"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
android:visibility="gone"
android:alpha="0"
tools:alpha="1"
tools:visibility="visible"
tools:text="connected"/>
</RelativeLayout>
\ No newline at end of file
......@@ -5,8 +5,8 @@
android:id="@+id/attachment_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="72dp"
android:layout_marginEnd="@dimen/screen_edge_left_and_right_margins"
android:paddingStart="72dp"
android:paddingEnd="@dimen/screen_edge_left_and_right_margins"
android:orientation="vertical">
<com.facebook.drawee.view.SimpleDraweeView
......
......@@ -6,8 +6,8 @@
android:id="@+id/url_preview_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="72dp"
android:layout_marginEnd="24dp">
android:paddingStart="72dp"
android:paddingEnd="24dp">
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/image_preview"
......
......@@ -88,4 +88,11 @@
<!-- Upload Messages -->
<string name="max_file_size_exceeded">Tamanho de arquivo (%1$d bytes) excedeu tamanho máximo de upload (%2$d bytes)</string>
<!-- Socket status -->
<string name="status_connected">conectado</string>
<string name="status_disconnected">desconetado</string>
<string name="status_connecting">conectando</string>
<string name="status_authenticating">autenticando</string>
<string name="status_disconnecting">desconectando</string>
<string name="status_waiting">conectando em %d segundos</string>
</resources>
\ No newline at end of file
......@@ -90,4 +90,12 @@
<!-- Upload Messages -->
<string name="max_file_size_exceeded">File size %1$d bytes exceeded max upload size of %2$d bytes</string>
<!-- Socket status -->
<string name="status_connected">connected</string>
<string name="status_disconnected">disconnected</string>
<string name="status_connecting">connecting</string>
<string name="status_authenticating">authenticating</string>
<string name="status_disconnecting">disconnecting</string>
<string name="status_waiting">connecting in %d seconds</string>
</resources>
\ No newline at end of file
......@@ -11,6 +11,7 @@ ext {
// Main dependencies
support : '27.0.2',
constraintLayout : '1.0.2',
androidKtx : '0.1',
dagger : '2.14.1',
exoPlayer : '2.6.0',
playServices : '11.8.0',
......@@ -49,6 +50,8 @@ ext {
constraintLayout : "com.android.support.constraint:constraint-layout:${versions.constraintLayout}",
cardView : "com.android.support:cardview-v7:${versions.support}",
androidKtx : "androidx.core:core-ktx:${versions.androidKtx}",
dagger : "com.google.dagger:dagger:${versions.dagger}",
daggerSupport : "com.google.dagger:dagger-android-support:${versions.dagger}",
daggerProcessor : "com.google.dagger:dagger-compiler:${versions.dagger}",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment