ChatRoomPresenter.kt 42.7 KB
Newer Older
1 2
package chat.rocket.android.chatroom.presentation

3
import android.net.Uri
4
import chat.rocket.android.R
5 6 7
import chat.rocket.android.chatroom.adapter.AutoCompleteType
import chat.rocket.android.chatroom.adapter.PEOPLE
import chat.rocket.android.chatroom.adapter.ROOMS
8
import chat.rocket.android.chatroom.domain.UriInteractor
9 10 11 12 13
import chat.rocket.android.chatroom.uimodel.RoomUiModel
import chat.rocket.android.chatroom.uimodel.UiModelMapper
import chat.rocket.android.chatroom.uimodel.suggestion.ChatRoomSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.CommandSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.PeopleSuggestionUiModel
14
import chat.rocket.android.core.behaviours.showMessage
15
import chat.rocket.android.core.lifecycle.CancelStrategy
16
import chat.rocket.android.db.DatabaseManager
17
import chat.rocket.android.helper.MessageHelper
18
import chat.rocket.android.helper.UserHelper
19
import chat.rocket.android.infrastructure.LocalRepository
20 21 22 23 24 25 26 27 28 29 30
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.GetSettingsInteractor
import chat.rocket.android.server.domain.JobSchedulerInteractor
import chat.rocket.android.server.domain.MessagesRepository
import chat.rocket.android.server.domain.PermissionsInteractor
import chat.rocket.android.server.domain.PublicSettings
import chat.rocket.android.server.domain.RoomRepository
import chat.rocket.android.server.domain.UsersRepository
import chat.rocket.android.server.domain.uploadMaxFileSize
import chat.rocket.android.server.domain.uploadMimeTypeFilter
import chat.rocket.android.server.domain.useRealName
31 32
import chat.rocket.android.server.infraestructure.ConnectionManagerFactory
import chat.rocket.android.server.infraestructure.state
33 34
import chat.rocket.android.util.extension.compressImageAndGetInputStream
import chat.rocket.android.util.extension.launchUI
35
import chat.rocket.android.util.extensions.avatarUrl
36
import chat.rocket.android.util.retryIO
37
import chat.rocket.common.RocketChatException
38
import chat.rocket.common.model.RoomType
39
import chat.rocket.common.model.SimpleUser
40
import chat.rocket.common.model.UserStatus
41
import chat.rocket.common.model.roomTypeOf
42
import chat.rocket.common.util.ifNull
43
import chat.rocket.core.internal.realtime.setTypingStatus
44
import chat.rocket.core.internal.realtime.socket.model.State
45 46
import chat.rocket.core.internal.realtime.subscribeTypingStatus
import chat.rocket.core.internal.realtime.unsubscribe
47 48 49
import chat.rocket.core.internal.rest.chatRoomRoles
import chat.rocket.core.internal.rest.commands
import chat.rocket.core.internal.rest.deleteMessage
50
import chat.rocket.core.internal.rest.favorite
51 52 53 54 55 56 57
import chat.rocket.core.internal.rest.getMembers
import chat.rocket.core.internal.rest.history
import chat.rocket.core.internal.rest.joinChat
import chat.rocket.core.internal.rest.markAsRead
import chat.rocket.core.internal.rest.messages
import chat.rocket.core.internal.rest.pinMessage
import chat.rocket.core.internal.rest.runCommand
58
import chat.rocket.core.internal.rest.searchMessages
59 60 61 62 63 64 65 66
import chat.rocket.core.internal.rest.sendMessage
import chat.rocket.core.internal.rest.spotlight
import chat.rocket.core.internal.rest.starMessage
import chat.rocket.core.internal.rest.toggleReaction
import chat.rocket.core.internal.rest.unpinMessage
import chat.rocket.core.internal.rest.unstarMessage
import chat.rocket.core.internal.rest.updateMessage
import chat.rocket.core.internal.rest.uploadFile
67
import chat.rocket.core.model.ChatRoom
68
import chat.rocket.core.model.ChatRoomRole
69
import chat.rocket.core.model.Command
Lucio Maciel's avatar
Lucio Maciel committed
70 71
import chat.rocket.core.model.Message
import kotlinx.coroutines.experimental.CommonPool
72
import kotlinx.coroutines.experimental.DefaultDispatcher
73
import kotlinx.coroutines.experimental.android.UI
Lucio Maciel's avatar
Lucio Maciel committed
74
import kotlinx.coroutines.experimental.channels.Channel
Lucio Maciel's avatar
Lucio Maciel committed
75
import kotlinx.coroutines.experimental.launch
76
import kotlinx.coroutines.experimental.withContext
77
import org.threeten.bp.Instant
Lucio Maciel's avatar
Lucio Maciel committed
78
import timber.log.Timber
79
import java.io.InputStream
80
import java.util.*
81 82
import javax.inject.Inject

83 84 85 86
class ChatRoomPresenter @Inject constructor(
    private val view: ChatRoomView,
    private val navigator: ChatRoomNavigator,
    private val strategy: CancelStrategy,
87
    private val permissions: PermissionsInteractor,
88 89 90 91 92
    private val uriInteractor: UriInteractor,
    private val messagesRepository: MessagesRepository,
    private val usersRepository: UsersRepository,
    private val roomsRepository: RoomRepository,
    private val localRepository: LocalRepository,
93
    private val userHelper: UserHelper,
94
    private val mapper: UiModelMapper,
95
    private val jobSchedulerInteractor: JobSchedulerInteractor,
96
    private val messageHelper: MessageHelper,
97
    private val dbManager: DatabaseManager,
98 99 100
    getSettingsInteractor: GetSettingsInteractor,
    serverInteractor: GetCurrentServerInteractor,
    factory: ConnectionManagerFactory
101
) {
102 103 104
    private val currentServer = serverInteractor.get()!!
    private val manager = factory.create(currentServer)
    private val client = manager.client
105
    private var settings: PublicSettings = getSettingsInteractor.get(serverInteractor.get()!!)
106
    private val currentLoggedUsername = userHelper.username()
107 108 109 110
    private val messagesChannel = Channel<Message>()

    private var chatRoomId: String? = null
    private var chatRoomType: String? = null
111
    private var chatIsBroadcast: Boolean = false
112
    private var chatRoles = emptyList<ChatRoomRole>()
Lucio Maciel's avatar
Lucio Maciel committed
113
    private val stateChannel = Channel<State>()
114
    private var typingStatusSubscriptionId: String? = null
115
    private var lastState = manager.state
116
    private var typingStatusList = arrayListOf<String>()
Lucio Maciel's avatar
Lucio Maciel committed
117

118 119 120 121 122 123
    fun setupChatRoom(
        roomId: String,
        roomName: String,
        roomType: String,
        chatRoomMessage: String? = null
    ) {
124
        launchUI(strategy) {
125 126 127 128 129 130 131 132 133 134 135 136
            try {
                chatRoles = if (roomTypeOf(roomType) !is RoomType.DirectMessage) {
                    client.chatRoomRoles(roomType = roomTypeOf(roomType), roomName = roomName)
                } else emptyList()
            } catch (ex: RocketChatException) {
                Timber.e(ex)
                chatRoles = emptyList()
            } finally {
                // User has at least an 'owner' or 'moderator' role.
                val userCanMod = isOwnerOrMod()
                // Can post anyway if has the 'post-readonly' permission on server.
                val userCanPost = userCanMod || permissions.canPostToReadOnlyChannels()
137
                chatIsBroadcast = dbManager.getRoom(roomId)?.chatRoom?.run {
138 139 140 141
                    broadcast
                } ?: false
                view.onRoomUpdated(userCanPost, chatIsBroadcast, userCanMod)
                loadMessages(roomId, roomType)
142 143 144 145 146 147 148 149 150 151
                chatRoomMessage?.let { messageHelper.messageIdFromPermalink(it) }
                    ?.let { messageId ->
                        val name = messageHelper.roomNameFromPermalink(chatRoomMessage)
                        citeMessage(
                            name!!,
                            messageHelper.roomTypeFromPermalink(chatRoomMessage)!!,
                            messageId,
                            true
                        )
                    }
152
            }
153 154 155
        }
    }

156
    private fun isOwnerOrMod(): Boolean {
157
        return chatRoles.firstOrNull { it.user.username == currentLoggedUsername }?.roles?.any {
158
            it == "owner" || it == "moderator"
159
        } ?: false
160 161
    }

162 163 164 165 166 167
    fun loadMessages(
        chatRoomId: String,
        chatRoomType: String,
        offset: Long = 0,
        clearDataSet: Boolean = false
    ) {
168 169
        this.chatRoomId = chatRoomId
        this.chatRoomType = chatRoomType
170 171 172
        launchUI(strategy) {
            view.showLoading()
            try {
173 174
                if (offset == 0L) {
                    val localMessages = messagesRepository.getByRoomId(chatRoomId)
175 176 177
                    val oldMessages = mapper.map(
                        localMessages, RoomUiModel(
                            roles = chatRoles,
178
                            // FIXME: Why are we fixing isRoom attribute to true here?
179 180 181
                            isBroadcast = chatIsBroadcast, isRoom = true
                        )
                    )
182
                    if (oldMessages.isNotEmpty()) {
183
                        view.showMessages(oldMessages, clearDataSet)
184 185
                        loadMissingMessages()
                    } else {
186
                        loadAndShowMessages(chatRoomId, chatRoomType, offset, clearDataSet)
187
                    }
188
                } else {
189
                    loadAndShowMessages(chatRoomId, chatRoomType, offset, clearDataSet)
190
                }
191

192 193 194
                // TODO: For now we are marking the room as read if we can get the messages (I mean, no exception occurs)
                // but should mark only when the user see the first unread message.
                markRoomAsRead(chatRoomId)
195

196
                subscribeMessages(chatRoomId)
197
            } catch (ex: Exception) {
198
                Timber.e(ex)
199 200 201
                ex.message?.let {
                    view.showMessage(it)
                }.ifNull {
202 203
                    view.showGenericErrorMessage()
                }
204 205 206
            } finally {
                view.hideLoading()
            }
207

208
            subscribeTypingStatus()
209
            subscribeState()
210 211
        }
    }
212

213 214 215
    private suspend fun loadAndShowMessages(
        chatRoomId: String,
        chatRoomType: String,
216 217
        offset: Long = 0,
        clearDataSet: Boolean
218
    ) {
219
        val messages =
220
            retryIO("loadAndShowMessages($chatRoomId, $chatRoomType, $offset") {
221 222 223
                client.messages(chatRoomId, roomTypeOf(chatRoomType), offset, 30).result
            }
        messagesRepository.saveAll(messages)
224
        view.showMessages(
225 226 227 228 229
            mapper.map(
                messages,
                RoomUiModel(roles = chatRoles, isBroadcast = chatIsBroadcast, isRoom = true)
            ),
            clearDataSet
230
        )
231 232
    }

233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258
    fun searchMessages(chatRoomId: String, searchText: String) {
        launchUI(strategy) {
            try {
                view.showLoading()
                val messages = retryIO("searchMessages($chatRoomId, $searchText)") {
                    client.searchMessages(chatRoomId, searchText).result
                }
                view.showSearchedMessages(
                    mapper.map(
                        messages,
                        RoomUiModel(chatRoles, chatIsBroadcast, true)
                    )
                )
            } catch (ex: Exception) {
                Timber.e(ex)
                ex.message?.let {
                    view.showMessage(it)
                }.ifNull {
                    view.showGenericErrorMessage()
                }
            } finally {
                view.hideLoading()
            }
        }
    }

259
    fun sendMessage(chatRoomId: String, text: String, messageId: String?) {
260 261
        launchUI(strategy) {
            try {
262
                // ignore message for now, will receive it on the stream
263
                val id = UUID.randomUUID().toString()
264
                val message = if (messageId == null) {
265
                    val username = userHelper.username()
266
                    val newMessage = Message(
267 268 269 270 271 272 273 274 275 276 277 278 279
                        id = id,
                        roomId = chatRoomId,
                        message = text,
                        timestamp = Instant.now().toEpochMilli(),
                        sender = SimpleUser(null, username, username),
                        attachments = null,
                        avatar = currentServer.avatarUrl(username!!),
                        channels = null,
                        editedAt = null,
                        editedBy = null,
                        groupable = false,
                        parseUrls = false,
                        pinned = false,
280
                        starred = emptyList(),
281 282 283 284 285 286
                        mentions = emptyList(),
                        reactions = null,
                        senderAlias = null,
                        type = null,
                        updatedAt = null,
                        urls = null,
287 288
                        isTemporary = true,
                        unread = true
289
                    )
290 291
                    try {
                        messagesRepository.save(newMessage)
292 293
                        view.showNewMessage(
                            mapper.map(
294 295
                                newMessage, 
                                RoomUiModel(roles = chatRoles, isBroadcast = chatIsBroadcast)
296
                            ), false
297
                        )
298
                        client.sendMessage(id, chatRoomId, text)
299 300 301 302 303 304 305 306 307 308 309 310 311
                    } catch (ex: Exception) {
                        // Ok, not very beautiful, but the backend sends us a not valid response
                        // When someone sends a message on a read-only channel, so we just ignore it
                        // and show a generic error message
                        // TODO - remove the generic message when we implement :userId:/message subscription
                        if (ex is IllegalStateException) {
                            Timber.d(ex, "Probably a read-only problem...")
                            view.showGenericErrorMessage()
                        } else {
                            // some other error, just rethrow it...
                            throw ex
                        }
                    }
312 313
                } else {
                    client.updateMessage(chatRoomId, messageId, text)
314
                }
315

316
                view.enableSendMessageButton()
317
            } catch (ex: Exception) {
318
                Timber.d(ex, "Error sending message...")
319
                jobSchedulerInteractor.scheduleSendingMessages()
320 321
            } finally {
                view.enableSendMessageButton()
322 323 324
            }
        }
    }
Lucio Maciel's avatar
Lucio Maciel committed
325

326 327 328 329
    fun selectFile() {
        view.showFileSelection(settings.uploadMimeTypeFilter())
    }

330
    fun uploadFile(roomId: String, uri: Uri, msg: String) {
331 332 333
        launchUI(strategy) {
            view.showLoading()
            try {
334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350
                withContext(DefaultDispatcher) {
                    val fileName = uriInteractor.getFileName(uri) ?: uri.toString()
                    val fileSize = uriInteractor.getFileSize(uri)
                    val mimeType = uriInteractor.getMimeType(uri)
                    val maxFileSizeAllowed = settings.uploadMaxFileSize()

                    when {
                        fileName.isEmpty() -> {
                            view.showInvalidFileMessage()
                        }
                        fileSize > maxFileSizeAllowed -> {
                            view.showInvalidFileSize(fileSize, maxFileSizeAllowed)
                        }
                        else -> {
                            var inputStream: InputStream? = uriInteractor.getInputStream(uri)

                            if (mimeType.contains("image")) {
351 352 353 354
                                uriInteractor.getBitmap(uri)?.let {
                                    it.compressImageAndGetInputStream(mimeType)?.let {
                                        inputStream = it
                                    }
355 356
                                }
                            }
357

358 359 360 361 362 363 364 365 366 367
                            retryIO("uploadFile($roomId, $fileName, $mimeType") {
                                client.uploadFile(
                                    roomId,
                                    fileName,
                                    mimeType,
                                    msg,
                                    description = fileName
                                ) {
                                    inputStream
                                }
368
                            }
369 370
                        }
                    }
371
                }
372 373
            } catch (ex: Exception) {
                Timber.d(ex, "Error uploading file")
374
                when (ex) {
375 376
                    is RocketChatException -> view.showMessage(ex)
                    else -> view.showGenericErrorMessage()
377
                }
378 379 380 381 382 383
            } finally {
                view.hideLoading()
            }
        }
    }

384 385 386 387 388 389 390 391 392
    fun uploadDrawingImage(roomId: String, byteArray: ByteArray, msg: String) {
        launchUI(strategy) {
            view.showLoading()
            try {
                withContext(DefaultDispatcher) {
                    val fileName = UUID.randomUUID().toString() + ".png"
                    val fileSize = byteArray.size
                    val mimeType = "image/png"
                    val maxFileSizeAllowed = settings.uploadMaxFileSize()
393

394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417
                    when {
                        fileSize > maxFileSizeAllowed -> {
                            view.showInvalidFileSize(fileSize, maxFileSizeAllowed)
                        }
                        else -> {
                            retryIO("uploadFile($roomId, $fileName, $mimeType") {
                                client.uploadFile(
                                    roomId,
                                    fileName,
                                    mimeType,
                                    msg,
                                    description = fileName
                                ) {
                                    byteArray.inputStream()
                                }
                            }
                        }
                    }
                }
            } catch (ex: Exception) {
                Timber.d(ex, "Error uploading file")
                when (ex) {
                    is RocketChatException -> view.showMessage(ex)
                    else -> view.showGenericErrorMessage()
418
                }
419 420
            } finally {
                view.hideLoading()
421 422 423 424
            }
        }
    }

425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440
    fun sendTyping() {
        launch(CommonPool + strategy.jobs) {
            if (chatRoomId != null && currentLoggedUsername != null) {
                client.setTypingStatus(chatRoomId.toString(), currentLoggedUsername, true)
            }
        }
    }

    fun sendNotTyping() {
        launch(CommonPool + strategy.jobs) {
            if (chatRoomId != null && currentLoggedUsername != null) {
                client.setTypingStatus(chatRoomId.toString(), currentLoggedUsername, false)
            }
        }
    }

441
    private fun markRoomAsRead(roomId: String) {
442 443
        launchUI(strategy) {
            try {
444
                retryIO(description = "markAsRead($roomId)") { client.markAsRead(roomId) }
445 446 447 448 449 450 451
            } catch (ex: RocketChatException) {
                view.showMessage(ex.message!!) // TODO Remove.
                Timber.e(ex) // FIXME: Right now we are only catching the exception with Timber.
            }
        }
    }

452
    private suspend fun subscribeState() {
453 454 455
        Timber.d("Subscribing to Status changes")
        lastState = manager.state
        manager.addStatusChannel(stateChannel)
Lucio Maciel's avatar
Lucio Maciel committed
456
        launch(CommonPool + strategy.jobs) {
457 458 459 460 461
            for (state in stateChannel) {
                Timber.d("Got new state: $state - last: $lastState")
                if (state != lastState) {
                    launch(UI) {
                        view.showConnectionState(state)
Lucio Maciel's avatar
Lucio Maciel committed
462 463
                    }

464
                    if (state is State.Connected) {
465
                        jobSchedulerInteractor.scheduleSendingMessages()
466 467
                        loadMissingMessages()
                    }
Lucio Maciel's avatar
Lucio Maciel committed
468
                }
469
                lastState = state
Lucio Maciel's avatar
Lucio Maciel committed
470 471
            }
        }
472
    }
Lucio Maciel's avatar
Lucio Maciel committed
473

474
    private fun subscribeMessages(roomId: String) {
475
        manager.subscribeRoomMessages(roomId, messagesChannel)
476

Lucio Maciel's avatar
Lucio Maciel committed
477
        launch(CommonPool + strategy.jobs) {
478 479 480
            for (message in messagesChannel) {
                Timber.d("New message for room ${message.roomId}")
                updateMessage(message)
Lucio Maciel's avatar
Lucio Maciel committed
481
            }
Lucio Maciel's avatar
Lucio Maciel committed
482
        }
Lucio Maciel's avatar
Lucio Maciel committed
483 484
    }

485 486 487 488
    private fun loadMissingMessages() {
        launch(parent = strategy.jobs) {
            if (chatRoomId != null && chatRoomType != null) {
                val roomType = roomTypeOf(chatRoomType!!)
489
                messagesRepository.getByRoomId(chatRoomId!!)
490 491 492
                    .sortedByDescending { it.timestamp }.firstOrNull()?.let { lastMessage ->
                        val instant = Instant.ofEpochMilli(lastMessage.timestamp).toString()
                        try {
493 494 495 496 497 498 499
                            val messages =
                                retryIO(description = "history($chatRoomId, $roomType, $instant)") {
                                    client.history(
                                        chatRoomId!!, roomType, count = 50,
                                        oldest = instant
                                    )
                                }
500
                            Timber.d("History: $messages")
501

502
                            if (messages.result.isNotEmpty()) {
503 504 505
                                val models = mapper.map(messages.result, RoomUiModel(
                                    roles = chatRoles,
                                    isBroadcast = chatIsBroadcast,
506
                                    // FIXME: Why are we fixing isRoom attribute to true here?
507 508
                                    isRoom = true
                                ))
509
                                messagesRepository.saveAll(messages.result)
510

511
                                launchUI(strategy) {
512
                                    view.showNewMessage(models, true)
513
                                }
514

515 516 517
                                if (messages.result.size == 50) {
                                    // we loaded at least count messages, try one more to fetch more messages
                                    loadMissingMessages()
518
                                }
519
                            }
520 521 522 523
                        } catch (ex: Exception) {
                            // TODO - we need to better treat connection problems here, but no let gaps
                            // on the messages list
                            Timber.d(ex, "Error fetching channel history")
524 525
                        }
                    }
Lucio Maciel's avatar
Lucio Maciel committed
526 527 528 529
            }
        }
    }

530 531 532 533 534 535 536 537
    /**
     * Delete the message with the given id.
     *
     * @param roomId The room id of the message to be deleted.
     * @param id The id of the message to be deleted.
     */
    fun deleteMessage(roomId: String, id: String) {
        launchUI(strategy) {
538
            if (!permissions.allowedMessageDeleting()) {
539 540
                return@launchUI
            }
541 542 543
            //TODO: Default delete message always to true. Until we have the permissions system
            //implemented, a user will only be able to delete his own messages.
            try {
544 545 546
                retryIO(description = "deleteMessage($roomId, $id)") {
                    client.deleteMessage(roomId, id, true)
                }
547 548
                // if Message_ShowDeletedStatus == true an update to that message will be dispatched.
                // Otherwise we signalize that we just want the message removed.
549
                if (!permissions.showDeletedStatus()) {
550 551
                    view.dispatchDeleteMessage(id)
                }
552 553 554 555 556 557
            } catch (e: RocketChatException) {
                Timber.e(e)
            }
        }
    }

558 559 560 561 562
    /**
     * Quote or reply a message.
     *
     * @param roomType The current room type.
     * @param messageId The id of the message to make citation for.
Leonardo Aramaki's avatar
Leonardo Aramaki committed
563
     * @param mentionAuthor true means the citation is a reply otherwise it's a quote.
564
     */
565
    fun citeMessage(roomName: String, roomType: String, messageId: String, mentionAuthor: Boolean) {
566 567
        launchUI(strategy) {
            val message = messagesRepository.getById(messageId)
568
            val currentUsername: String? = userHelper.user()?.username
569 570 571
            message?.let { msg ->
                val id = msg.id
                val username = msg.sender?.username ?: ""
572
                val mention = if (mentionAuthor && currentUsername != username) "@$username" else ""
573 574
                val room =
                    if (roomTypeOf(roomType) is RoomType.DirectMessage) username else roomName
575
                val chatRoomType = when (roomTypeOf(roomType)) {
576 577 578
                    is RoomType.DirectMessage -> "direct"
                    is RoomType.PrivateGroup -> "group"
                    is RoomType.Channel -> "channel"
579
                    is RoomType.LiveChat -> "livechat"
580 581
                    else -> "custom"
                }
582
                view.showReplyingAction(
583
                    username = getDisplayName(msg.sender),
584
                    replyMarkdown = "[ ]($currentServer/$chatRoomType/$room?msg=$id) $mention ",
585 586 587 588 589 590
                    quotedMessage = mapper.map(
                        message, RoomUiModel(
                            roles = chatRoles,
                            isBroadcast = chatIsBroadcast
                        )
                    ).last().preview?.message ?: ""
591
                )
592 593 594 595
            }
        }
    }

596 597
    private fun getDisplayName(user: SimpleUser?): String {
        val username = user?.username ?: ""
598
        return if (settings.useRealName()) user?.name ?: "@$username" else "@$username"
599 600
    }

601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617
    /**
     * Copy message to clipboard.
     *
     * @param messageId The id of the message to copy to clipboard.
     */
    fun copyMessage(messageId: String) {
        launchUI(strategy) {
            try {
                messagesRepository.getById(messageId)?.let { m ->
                    view.copyToClipboard(m.message)
                }
            } catch (e: RocketChatException) {
                Timber.e(e)
            }
        }
    }

618 619 620 621 622 623 624 625 626
    /**
     * Update message identified by given id with given text.
     *
     * @param roomId The id of the room of the message.
     * @param messageId The id of the message to update.
     * @param text The updated text.
     */
    fun editMessage(roomId: String, messageId: String, text: String) {
        launchUI(strategy) {
627
            if (!permissions.allowedMessageEditing()) {
628 629 630 631 632 633 634
                view.showMessage(R.string.permission_editing_not_allowed)
                return@launchUI
            }
            view.showEditingAction(roomId, messageId, text)
        }
    }

635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662
    fun starMessage(messageId: String) {
        launchUI(strategy) {
            if (!permissions.allowedMessageStarring()) {
                view.showMessage(R.string.permission_starring_not_allowed)
                return@launchUI
            }
            try {
                retryIO("starMessage($messageId)") { client.starMessage(messageId) }
            } catch (e: RocketChatException) {
                Timber.e(e)
            }
        }
    }

    fun unstarMessage(messageId: String) {
        launchUI(strategy) {
            if (!permissions.allowedMessageStarring()) {
                view.showMessage(R.string.permission_starring_not_allowed)
                return@launchUI
            }
            try {
                retryIO("unstarMessage($messageId)") { client.unstarMessage(messageId) }
            } catch (e: RocketChatException) {
                Timber.e(e)
            }
        }
    }

Leonardo Aramaki's avatar
Leonardo Aramaki committed
663 664
    fun pinMessage(messageId: String) {
        launchUI(strategy) {
665 666 667 668
            if (!permissions.allowedMessagePinning()) {
                view.showMessage(R.string.permission_pinning_not_allowed)
                return@launchUI
            }
Leonardo Aramaki's avatar
Leonardo Aramaki committed
669
            try {
670
                retryIO("pinMessage($messageId)") { client.pinMessage(messageId) }
Leonardo Aramaki's avatar
Leonardo Aramaki committed
671 672 673 674 675 676
            } catch (e: RocketChatException) {
                Timber.e(e)
            }
        }
    }

677 678
    fun unpinMessage(messageId: String) {
        launchUI(strategy) {
679 680 681 682
            if (!permissions.allowedMessagePinning()) {
                view.showMessage(R.string.permission_pinning_not_allowed)
                return@launchUI
            }
683
            try {
684
                retryIO("unpinMessage($messageId)") { client.unpinMessage(messageId) }
685 686 687 688 689 690
            } catch (e: RocketChatException) {
                Timber.e(e)
            }
        }
    }

691 692 693 694 695 696
    fun loadActiveMembers(
        chatRoomId: String,
        chatRoomType: String,
        offset: Long = 0,
        filterSelfOut: Boolean = false
    ) {
697 698
        launchUI(strategy) {
            try {
699 700
                val members = retryIO("getMembers($chatRoomId, $chatRoomType, $offset)") {
                    client.getMembers(chatRoomId, roomTypeOf(chatRoomType), offset, 50).result
701
                }.take(50) // Get only 50, the backend is returning 7k+ users
702
                usersRepository.saveAll(members)
703
                val self = localRepository.get(LocalRepository.CURRENT_USERNAME_KEY)
704 705
                // Take at most the 100 most recent messages distinguished by user. Can return less.
                val recentMessages = messagesRepository.getRecentMessages(chatRoomId, 100)
706
                    .filterNot { filterSelfOut && it.sender?.username == self }
707
                val activeUsers = mutableListOf<PeopleSuggestionUiModel>()
708 709 710 711
                recentMessages.forEach {
                    val sender = it.sender!!
                    val username = sender.username ?: ""
                    val name = sender.name ?: ""
712
                    val avatarUrl = currentServer.avatarUrl(username)
713 714 715
                    val found = members.firstOrNull { member -> member.username == username }
                    val status = if (found != null) found.status else UserStatus.Offline()
                    val searchList = mutableListOf(username, name)
716 717 718 719 720 721
                    activeUsers.add(
                        PeopleSuggestionUiModel(
                            avatarUrl, username, username, name, status,
                            true, searchList
                        )
                    )
722 723 724 725 726 727 728 729 730 731 732
                }
                // Filter out from members list the active users.
                val others = members.filterNot { member ->
                    activeUsers.firstOrNull {
                        it.username == member.username
                    } != null
                }
                // Add found room members who're not active enough and add them in without pinning.
                activeUsers.addAll(others.map {
                    val username = it.username ?: ""
                    val name = it.name ?: ""
733
                    val avatarUrl = currentServer.avatarUrl(username)
734
                    val searchList = mutableListOf(username, name)
735 736 737 738 739 740 741 742 743
                    PeopleSuggestionUiModel(
                        avatarUrl,
                        username,
                        username,
                        name,
                        it.status,
                        true,
                        searchList
                    )
744 745
                })

746
                view.populatePeopleSuggestions(activeUsers)
747 748 749 750 751 752
            } catch (e: RocketChatException) {
                Timber.e(e)
            }
        }
    }

753
    fun spotlight(query: String, @AutoCompleteType type: Int, filterSelfOut: Boolean = false) {
754 755
        launchUI(strategy) {
            try {
756
                val (users, rooms) = retryIO("spotlight($query)") { client.spotlight(query) }
757 758 759 760 761
                when (type) {
                    PEOPLE -> {
                        if (users.isNotEmpty()) {
                            usersRepository.saveAll(users)
                        }
762
                        val self = localRepository.get(LocalRepository.CURRENT_USERNAME_KEY)
763
                        view.populatePeopleSuggestions(users.map {
764 765 766 767
                            val username = it.username ?: ""
                            val name = it.name ?: ""
                            val searchList = mutableListOf(username, name)
                            it.emails?.forEach { email -> searchList.add(email.address) }
768 769 770 771
                            PeopleSuggestionUiModel(
                                currentServer.avatarUrl(username),
                                username, username, name, it.status, false, searchList
                            )
772 773 774 775 776 777
                        }.filterNot { filterSelfOut && self != null && self == it.text })
                    }
                    ROOMS -> {
                        if (rooms.isNotEmpty()) {
                            roomsRepository.saveAll(rooms)
                        }
778
                        view.populateRoomSuggestions(rooms.map {
779 780 781
                            val fullName = it.fullName ?: ""
                            val name = it.name ?: ""
                            val searchList = mutableListOf(fullName, name)
782
                            ChatRoomSuggestionUiModel(name, fullName, name, searchList)
783 784 785 786 787 788 789 790 791
                        })
                    }
                }
            } catch (e: RocketChatException) {
                Timber.e(e)
            }
        }
    }

792 793 794 795
    fun toggleFavoriteChatRoom(roomId: String, isFavorite: Boolean) {
        launchUI(strategy) {
            try {
                // Note that if it is favorite then the user wants to unfavorite - and vice versa.
796 797 798
                retryIO("favorite($roomId, $isFavorite)") {
                    client.favorite(roomId, !isFavorite)
                }
799 800 801 802 803 804 805 806 807 808 809 810
                view.showFavoriteIcon(!isFavorite)
            } catch (e: RocketChatException) {
                Timber.e(e, "Error while trying to favorite/unfavorite chat room.")
                e.message?.let {
                    view.showMessage(it)
                }.ifNull {
                    view.showGenericErrorMessage()
                }
            }
        }
    }

811 812
    fun toMembersList(chatRoomId: String) =
        navigator.toMembersList(chatRoomId)
813

814 815 816
    fun toMentions(chatRoomId: String) =
        navigator.toMentions(chatRoomId)

817 818
    fun toPinnedMessageList(chatRoomId: String) =
        navigator.toPinnedMessageList(chatRoomId)
819

820 821 822 823 824
    fun toFavoriteMessageList(chatRoomId: String) =
        navigator.toFavoriteMessageList(chatRoomId)

    fun toFileList(chatRoomId: String) =
        navigator.toFileList(chatRoomId)
825

826 827 828
    fun loadChatRooms() {
        launchUI(strategy) {
            try {
829
                val chatRooms = getChatRoomsAsync()
830
                    .filterNot {
831
                        it.type is RoomType.DirectMessage || it.type is RoomType.LiveChat
832 833 834 835
                    }
                    .map { chatRoom ->
                        val name = chatRoom.name
                        val fullName = chatRoom.fullName ?: ""
836
                        ChatRoomSuggestionUiModel(
837 838 839 840 841 842
                            text = name,
                            name = name,
                            fullName = fullName,
                            searchList = listOf(name, fullName)
                        )
                    }
843
                view.populateRoomSuggestions(chatRooms)
844 845 846 847 848
            } catch (e: RocketChatException) {
                Timber.e(e)
            }
        }
    }
849

850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890
    // TODO: move this to new interactor or FetchChatRoomsInteractor?
    private suspend fun getChatRoomsAsync(name: String? = null): List<ChatRoom> = withContext(CommonPool) {
        return@withContext dbManager.chatRoomDao().getAllSync().filter {
            if (name == null) {
                return@filter true
            }
            it.chatRoom.name == name || it.chatRoom.fullname == name
        }.map {
            with (it.chatRoom) {
                ChatRoom(
                    id = id,
                    subscriptionId = subscriptionId,
                    type = roomTypeOf(type),
                    unread = unread,
                    broadcast = broadcast ?: false,
                    alert = alert,
                    fullName = fullname,
                    name = name ?: "",
                    favorite = favorite ?: false,
                    default = isDefault ?: false,
                    readonly = readonly,
                    open = open,
                    lastMessage = null,
                    archived = false,
                    status = null,
                    user = null,
                    userMentions = userMentions,
                    client = client,
                    announcement = null,
                    description = null,
                    groupMentions = groupMentions,
                    roles = null,
                    topic = null,
                    lastSeen = this.lastSeen,
                    timestamp = timestamp,
                    updatedAt = updatedAt
                )
            }
        }
    }

891 892 893
    fun joinChat(chatRoomId: String) {
        launchUI(strategy) {
            try {
894
                retryIO("joinChat($chatRoomId)") { client.joinChat(chatRoomId) }
895 896
                val canPost = permissions.canPostToReadOnlyChannels()
                view.onJoined(canPost)
897 898 899 900 901 902
            } catch (ex: RocketChatException) {
                Timber.e(ex)
            }
        }
    }

903
    fun openDirectMessage(roomName: String, message: String) {
904 905
        launchUI(strategy) {
            try {
906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922
                getChatRoomsAsync(roomName).let {
                    if (it.isNotEmpty()) {
                        if (it.first().type is RoomType.DirectMessage) {
                            navigator.toDirectMessage(
                                chatRoomId = it.first().id,
                                chatRoomType = it.first().type.toString(),
                                chatRoomLastSeen = it.first().lastSeen ?: -1,
                                chatRoomName = roomName,
                                isChatRoomCreator = false,
                                isChatRoomFavorite = false,
                                isChatRoomReadOnly = false,
                                isChatRoomSubscribed = it.first().open,
                                chatRoomMessage = message
                            )
                        } else {
                            throw IllegalStateException("Not a direct-message")
                        }
923 924 925 926 927 928 929 930 931
                    }
                }
            } catch (ex: Exception) {
                Timber.e(ex)
                view.showMessage(ex.message!!)
            }
        }
    }

932 933 934 935 936 937
    /**
     * Send an emoji reaction to a message.
     */
    fun react(messageId: String, emoji: String) {
        launchUI(strategy) {
            try {
938
                retryIO("toggleEmoji($messageId, $emoji)") {
939 940
                    client.toggleReaction(messageId, emoji.removeSurrounding(":"))
                }
941 942 943 944 945 946
            } catch (ex: RocketChatException) {
                Timber.e(ex)
            }
        }
    }

947 948 949 950
    fun showReactions(messageId: String) {
        view.showReactionsPopup(messageId)
    }

951
    fun loadCommands() {
952 953 954
        launchUI(strategy) {
            try {
                //TODO: cache the commands
955 956 957
                val commands = retryIO("commands(0, 100)") {
                    client.commands(0, 100).result
                }
958
                view.populateCommandSuggestions(commands.map {
959
                    CommandSuggestionUiModel(it.command, it.description ?: "", listOf(it.command))
960 961 962 963 964 965 966
                })
            } catch (ex: RocketChatException) {
                Timber.e(ex)
            }
        }
    }

967 968 969 970
    fun runCommand(text: String, roomId: String) {
        launchUI(strategy) {
            try {
                if (text.length == 1) {
971
                    view.disableSendMessageButton()
972 973 974
                    // we have just the slash, post it anyway
                    sendMessage(roomId, text, null)
                } else {
975
                    view.disableSendMessageButton()
976 977
                    val command = text.split(" ")
                    val name = command[0].substring(1)
978
                    var params = ""
979 980 981 982 983
                    command.forEachIndexed { index, param ->
                        if (index > 0) {
                            params += "$param "
                        }
                    }
984 985 986
                    val result = retryIO("runCommand($name, $params, $roomId)") {
                        client.runCommand(Command(name, params), roomId)
                    }
987 988 989 990 991 992 993 994 995
                    if (!result) {
                        // failed, command is not valid so post it
                        sendMessage(roomId, text, null)
                    }
                }
            } catch (ex: RocketChatException) {
                Timber.e(ex)
                // command is not valid, post it
                sendMessage(roomId, text, null)
996 997
            } finally {
                view.enableSendMessageButton()
998 999 1000 1001
            }
        }
    }

1002 1003 1004 1005 1006 1007 1008
    fun disconnect() {
        unsubscribeTypingStatus()
        if (chatRoomId != null) {
            unsubscribeMessages(chatRoomId.toString())
        }
    }

1009
    private fun subscribeTypingStatus() {
1010 1011 1012 1013
        launch(CommonPool + strategy.jobs) {
            client.subscribeTypingStatus(chatRoomId.toString()) { _, id ->
                typingStatusSubscriptionId = id
            }
1014

1015 1016 1017
            for (typingStatus in client.typingStatusChannel) {
                processTypingStatus(typingStatus)
            }
1018 1019 1020 1021
        }
    }

    private fun processTypingStatus(typingStatus: Pair<String, Boolean>) {
1022 1023
        if (typingStatus.first != currentLoggedUsername) {
            if (!typingStatusList.any { username -> username == typingStatus.first }) {
1024 1025 1026
                if (typingStatus.second) {
                    typingStatusList.add(typingStatus.first)
                }
1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039
            } else {
                typingStatusList.find { username -> username == typingStatus.first }?.let {
                    typingStatusList.remove(it)
                    if (typingStatus.second) {
                        typingStatusList.add(typingStatus.first)
                    }
                }
            }

            if (typingStatusList.isNotEmpty()) {
                view.showTypingStatus(typingStatusList.toList())
            } else {
                view.hideTypingStatusView()
1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057
            }
        }
    }

    private fun unsubscribeTypingStatus() {
        typingStatusSubscriptionId?.let {
            client.unsubscribe(it)
        }
    }

    private fun unsubscribeMessages(chatRoomId: String) {
        manager.removeStatusChannel(stateChannel)
        manager.unsubscribeRoomMessages(chatRoomId)
        // All messages during the subscribed period are assumed to be read,
        // and lastSeen is updated as the time when the user leaves the room
        markRoomAsRead(chatRoomId)
    }

Lucio Maciel's avatar
Lucio Maciel committed
1058 1059
    private fun updateMessage(streamedMessage: Message) {
        launchUI(strategy) {
1060 1061
            val viewModelStreamedMessage = mapper.map(
                streamedMessage, RoomUiModel(
1062
                    roles = chatRoles, isBroadcast = chatIsBroadcast, isRoom = true
1063 1064
                )
            )
1065

1066 1067 1068 1069 1070 1071 1072 1073 1074
            val roomMessages = messagesRepository.getByRoomId(streamedMessage.roomId)
            val index = roomMessages.indexOfFirst { msg -> msg.id == streamedMessage.id }
            if (index > -1) {
                Timber.d("Updating message at $index")
                messagesRepository.save(streamedMessage)
                view.dispatchUpdateMessage(index, viewModelStreamedMessage)
            } else {
                Timber.d("Adding new message")
                messagesRepository.save(streamedMessage)
1075
                view.showNewMessage(viewModelStreamedMessage, true)
Lucio Maciel's avatar
Lucio Maciel committed
1076 1077 1078
            }
        }
    }
1079 1080 1081 1082 1083 1084

    fun messageInfo(messageId: String) {
        launchUI(strategy) {
            navigator.toMessageInformation(messageId = messageId)
        }
    }
1085
}