Offline works amazingly well

Signed-off-by: Mario Danic <mario@lovelyhq.com>
This commit is contained in:
Mario Danic 2020-04-19 03:17:37 +02:00
parent 19e432fd95
commit a6be3e098c
No known key found for this signature in database
GPG Key ID: CDE0BBD2738C4CC0
36 changed files with 489 additions and 140 deletions

View File

@ -158,7 +158,7 @@ ext {
koin_version = "2.1.4" koin_version = "2.1.4"
lifecycle_version = '2.2.0' lifecycle_version = '2.2.0'
coil_version = "0.9.5" coil_version = "0.9.5"
room_version = "2.2.4" room_version = "2.2.5"
} }
configurations.all { configurations.all {

View File

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "4e8c1ae6a440d8491937afe33a3ab085", "identityHash": "4623fd40c40300731b8871e7d43e5f65",
"entities": [ "entities": [
{ {
"tableName": "conversations", "tableName": "conversations",
@ -212,7 +212,7 @@
}, },
{ {
"tableName": "messages", "tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `conversation_id` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `actor_id` TEXT, `actor_type` TEXT, `actor_display_name` TEXT, `timestamp` INTEGER NOT NULL, `message` TEXT, `messageParameters` TEXT, `parent` TEXT, `replyable` INTEGER NOT NULL, `system_message_type` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`conversation_id`) REFERENCES `conversations`(`id`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `conversation_id` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `actor_id` TEXT, `actor_type` TEXT, `actor_display_name` TEXT, `timestamp` INTEGER NOT NULL, `message` TEXT, `messageParameters` TEXT, `parent` TEXT, `replyable` INTEGER NOT NULL, `system_message_type` TEXT, `reference_id` TEXT, `message_status` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`conversation_id`) REFERENCES `conversations`(`id`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -285,6 +285,18 @@
"columnName": "system_message_type", "columnName": "system_message_type",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": false "notNull": false
},
{
"fieldPath": "referenceId",
"columnName": "reference_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "chatMessageStatus",
"columnName": "message_status",
"affinity": "INTEGER",
"notNull": true
} }
], ],
"primaryKey": { "primaryKey": {
@ -401,7 +413,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4e8c1ae6a440d8491937afe33a3ab085')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4623fd40c40300731b8871e7d43e5f65')"
] ]
} }
} }

View File

@ -63,12 +63,21 @@ public class MentionAutocompleteCallback implements AutocompleteCallback<Mention
} }
editable.replace(start, end, replacementStringBuilder.toString() + " "); editable.replace(start, end, replacementStringBuilder.toString() + " ");
String type = "user";
if (item.source.equals("users")) {
// do nothing
} else if (item.source.equals("guests")) {
type = "guests";
} else if (item.source.equals("calls")) {
type = "call";
}
Spans.MentionChipSpan mentionChipSpan = Spans.MentionChipSpan mentionChipSpan =
new Spans.MentionChipSpan(DisplayUtils.INSTANCE.getDrawableForMentionChipSpan(context, new Spans.MentionChipSpan(DisplayUtils.INSTANCE.getDrawableForMentionChipSpan(context,
item.id, item.label, conversationUser, item.source, item.id, item.label, conversationUser, item.source,
R.xml.chip_you, editText), R.xml.chip_you, editText), BetterImageSpan.ALIGN_CENTER, item.id, item.label, type);
BetterImageSpan.ALIGN_CENTER,
item.id, item.label);
editable.setSpan(mentionChipSpan, start, start + replacementStringBuilder.toString().length(), editable.setSpan(mentionChipSpan, start, start + replacementStringBuilder.toString().length(),
Spanned.SPAN_INCLUSIVE_INCLUSIVE); Spanned.SPAN_INCLUSIVE_INCLUSIVE);
return true; return true;

View File

@ -28,6 +28,7 @@ import com.nextcloud.talk.R
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
import com.nextcloud.talk.models.json.converters.EnumSystemMessageTypeConverter import com.nextcloud.talk.models.json.converters.EnumSystemMessageTypeConverter
import com.nextcloud.talk.newarch.local.models.UserNgEntity import com.nextcloud.talk.newarch.local.models.UserNgEntity
import com.nextcloud.talk.newarch.local.models.other.ChatMessageStatus
import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.TextMatchers import com.nextcloud.talk.utils.TextMatchers
import com.stfalcon.chatkit.commons.models.IMessage import com.stfalcon.chatkit.commons.models.IMessage
@ -81,6 +82,10 @@ class ChatMessage : IMessage, MessageContentType, MessageContentType.Image {
@Ignore @Ignore
var jsonMessageId: Long? = null var jsonMessageId: Long? = null
@JvmField
@JsonField(name = ["referenceId"])
var referenceId: String? = null
@JvmField @JvmField
@JsonField(name = ["token"]) @JsonField(name = ["token"])
var token: String? = null var token: String? = null
@ -132,6 +137,9 @@ class ChatMessage : IMessage, MessageContentType, MessageContentType.Image {
MessageType.SYSTEM_MESSAGE, MessageType.SINGLE_LINK_VIDEO_MESSAGE, MessageType.SYSTEM_MESSAGE, MessageType.SINGLE_LINK_VIDEO_MESSAGE,
MessageType.SINGLE_LINK_AUDIO_MESSAGE, MessageType.SINGLE_LINK_MESSAGE) MessageType.SINGLE_LINK_AUDIO_MESSAGE, MessageType.SINGLE_LINK_MESSAGE)
@JsonIgnore
var chatMessageStatus: ChatMessageStatus = ChatMessageStatus.RECEIVED
override fun equals(o: Any?): Boolean { override fun equals(o: Any?): Boolean {
if (this === o) return true if (this === o) return true
if (o !is ChatMessage) return false if (o !is ChatMessage) return false
@ -289,7 +297,7 @@ class ChatMessage : IMessage, MessageContentType, MessageContentType.Image {
ApiUtils.getUrlForAvatarWithName(activeUser!!.baseUrl, actorId, ApiUtils.getUrlForAvatarWithName(activeUser!!.baseUrl, actorId,
R.dimen.avatar_size) R.dimen.avatar_size)
} }
actorType.equals("guests") -> { actorType.equals("guests") || actorType.equals("bots") -> {
var apiId: String? = sharedApplication!!.getString(R.string.nc_guest) var apiId: String? = sharedApplication!!.getString(R.string.nc_guest)
if (!TextUtils.isEmpty(actorDisplayName)) { if (!TextUtils.isEmpty(actorDisplayName)) {
apiId = actorDisplayName apiId = actorDisplayName

View File

@ -31,10 +31,9 @@ public class ChatUtils {
HashMap<String, String> individualHashMap = messageParameters.get(key); HashMap<String, String> individualHashMap = messageParameters.get(key);
if (individualHashMap.get("type").equals("user") || individualHashMap.get("type") if (individualHashMap.get("type").equals("user") || individualHashMap.get("type")
.equals("guest") || individualHashMap.get("type").equals("call")) { .equals("guest") || individualHashMap.get("type").equals("call")) {
message = message.replaceAll("\\{" + key + "\\}", "@" + message = message.replace("{" + key + "}", "@" + messageParameters.get(key).get("name"));
messageParameters.get(key).get("name"));
} else if (individualHashMap.get("type").equals("file")) { } else if (individualHashMap.get("type").equals("file")) {
message = message.replaceAll("\\{" + key + "\\}", messageParameters.get(key).get("name")); message = message.replace("{" + key + "}", messageParameters.get(key).get("name"));
} }
} }
} }

View File

@ -101,7 +101,7 @@ class ConversationsRepositoryImpl(val conversationsDao: ConversationsDao) :
userId: Long, userId: Long,
conversations: List<Conversation>, conversations: List<Conversation>,
deleteOutdated: Boolean deleteOutdated: Boolean
): List<Long> { ) {
val map = conversations.map { val map = conversations.map {
it.toConversationEntity() it.toConversationEntity()
} }

View File

@ -28,6 +28,9 @@ import androidx.lifecycle.map
import com.nextcloud.talk.models.json.chat.ChatMessage import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.newarch.domain.repository.offline.MessagesRepository import com.nextcloud.talk.newarch.domain.repository.offline.MessagesRepository
import com.nextcloud.talk.newarch.local.dao.MessagesDao import com.nextcloud.talk.newarch.local.dao.MessagesDao
import com.nextcloud.talk.newarch.local.models.User
import com.nextcloud.talk.newarch.local.models.hasSpreedFeatureCapability
import com.nextcloud.talk.newarch.local.models.other.ChatMessageStatus
import com.nextcloud.talk.newarch.local.models.toChatMessage import com.nextcloud.talk.newarch.local.models.toChatMessage
import com.nextcloud.talk.newarch.local.models.toMessageEntity import com.nextcloud.talk.newarch.local.models.toMessageEntity
@ -42,6 +45,18 @@ class MessagesRepositoryImpl(private val messagesDao: MessagesDao) : MessagesRep
} }
} }
override fun getPendingMessagesForConversation(conversationId: String): LiveData<List<ChatMessage>> {
return messagesDao.getPendingMessagesLive(conversationId).distinctUntilChanged().map {
it.map { messageEntity ->
messageEntity.toChatMessage()
}
}
}
override suspend fun getMessageForConversation(conversationId: String, messageId: Long): ChatMessage? {
return messagesDao.getMessageForConversation(conversationId, messageId)?.toChatMessage()
}
override fun getMessagesWithUserForConversationSince(conversationId: String, messageId: Long): LiveData<List<ChatMessage>> { override fun getMessagesWithUserForConversationSince(conversationId: String, messageId: Long): LiveData<List<ChatMessage>> {
return messagesDao.getMessagesWithUserForConversationSince(conversationId, messageId).distinctUntilChanged().map { return messagesDao.getMessagesWithUserForConversationSince(conversationId, messageId).distinctUntilChanged().map {
it.map { messageEntity -> it.map { messageEntity ->
@ -50,11 +65,23 @@ class MessagesRepositoryImpl(private val messagesDao: MessagesDao) : MessagesRep
} }
} }
override suspend fun saveMessagesForConversation(messages: List<ChatMessage>): List<Long> { override suspend fun saveMessagesForConversation(user: User, messages: List<ChatMessage>, sendingMessages: Boolean){
val shouldInsert = !user.hasSpreedFeatureCapability("chat-reference-id") || sendingMessages
val updatedMessages = messages.map { val updatedMessages = messages.map {
if (!user.hasSpreedFeatureCapability("chat-reference-id")) {
it.chatMessageStatus = ChatMessageStatus.RECEIVED
}
it.toMessageEntity() it.toMessageEntity()
} }
return messagesDao.saveMessages(*updatedMessages.toTypedArray()) if (shouldInsert) {
messagesDao.saveMessages(*updatedMessages.toTypedArray())
} else {
messagesDao.updateMessages(user, updatedMessages.toTypedArray())
}
}
override suspend fun updateMessageStatus(status: Int, conversationId: String, messageId: Long) {
messagesDao.updateMessageStatus(status, conversationId, messageId)
} }
} }

View File

@ -98,6 +98,11 @@ class NextcloudTalkRepositoryImpl(private val apiService: ApiService) : Nextclou
} }
} }
override suspend fun sendChatMessage(user: User, conversationToken: String, message: CharSequence, authorDisplayName: String?, replyTo: Int?, referenceId: String?): Response<ChatOverall> {
return apiService.sendChatMessage(user.getCredentials(), ApiUtils.getUrlForChat(user.baseUrl, conversationToken), message, authorDisplayName, replyTo, referenceId)
}
override suspend fun getChatMessagesForConversation(user: User, conversationToken: String, lookIntoFuture: Int, lastKnownMessageId: Int, includeLastKnown: Int): Response<ChatOverall> { override suspend fun getChatMessagesForConversation(user: User, conversationToken: String, lookIntoFuture: Int, lastKnownMessageId: Int, includeLastKnown: Int): Response<ChatOverall> {
val mutableMap = mutableMapOf<String, Int>() val mutableMap = mutableMapOf<String, Int>()
mutableMap["lookIntoFuture"] = lookIntoFuture mutableMap["lookIntoFuture"] = lookIntoFuture

View File

@ -39,6 +39,20 @@ import retrofit2.Response
import retrofit2.http.* import retrofit2.http.*
interface ApiService { interface ApiService {
/*
Fieldmap items are as follows:
- "message": ,
- "actorDisplayName"
*/
@FormUrlEncoded
@POST
suspend fun sendChatMessage(@Header("Authorization") authorization: String,
@Url url: String,
@Field("message") message: CharSequence,
@Field("actorDisplayName") actorDisplayName: String?,
@Field("replyTo") replyTo: Int?,
@Field("referenceId") referenceId: String?): Response<ChatOverall>
/* /*
QueryMap items are as follows: QueryMap items are as follows:
- "lookIntoFuture": int (0 or 1), - "lookIntoFuture": int (0 or 1),

View File

@ -23,25 +23,28 @@
package com.nextcloud.talk.newarch.di.module package com.nextcloud.talk.newarch.di.module
import android.content.Context import android.content.Context
import com.nextcloud.talk.newarch.data.source.remote.ApiErrorHandler
import com.nextcloud.talk.newarch.domain.repository.offline.ConversationsRepository import com.nextcloud.talk.newarch.domain.repository.offline.ConversationsRepository
import com.nextcloud.talk.newarch.domain.repository.offline.MessagesRepository
import com.nextcloud.talk.newarch.domain.repository.offline.UsersRepository import com.nextcloud.talk.newarch.domain.repository.offline.UsersRepository
import com.nextcloud.talk.newarch.domain.usecases.GetConversationUseCase import com.nextcloud.talk.newarch.domain.usecases.GetConversationUseCase
import com.nextcloud.talk.newarch.domain.usecases.JoinConversationUseCase import com.nextcloud.talk.newarch.domain.usecases.JoinConversationUseCase
import com.nextcloud.talk.newarch.services.GlobalService import com.nextcloud.talk.newarch.services.GlobalService
import com.nextcloud.talk.newarch.services.shortcuts.ShortcutService import com.nextcloud.talk.newarch.services.shortcuts.ShortcutService
import com.nextcloud.talk.newarch.utils.NetworkComponents
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.dsl.module import org.koin.dsl.module
import java.net.CookieManager import java.net.CookieManager
val ServiceModule = module { val ServiceModule = module {
single { createGlobalService(get(), get(), get(), get(), get(), get()) } single { createGlobalService(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
single { createShortcutService(get(), get(), get()) } single { createShortcutService(get(), get(), get()) }
} }
fun createGlobalService(usersRepository: UsersRepository, cookieManager: CookieManager, fun createGlobalService(usersRepository: UsersRepository, cookieManager: CookieManager,
okHttpClient: OkHttpClient, conversationsRepository: ConversationsRepository, okHttpClient: OkHttpClient, apiErrorHandler: ApiErrorHandler, conversationsRepository: ConversationsRepository,
getConversationUseCase: GetConversationUseCase, joinConversationUseCase: JoinConversationUseCase): GlobalService { messagesRepository: MessagesRepository, networkComponents: NetworkComponents, getConversationUseCase: GetConversationUseCase, joinConversationUseCase: JoinConversationUseCase): GlobalService {
return GlobalService(usersRepository, cookieManager, okHttpClient, conversationsRepository, joinConversationUseCase, getConversationUseCase) return GlobalService(usersRepository, cookieManager, okHttpClient, apiErrorHandler, conversationsRepository, messagesRepository, networkComponents, joinConversationUseCase, getConversationUseCase)
} }
fun createShortcutService(context: Context, conversationsRepository: ConversationsRepository, conversationsService: GlobalService): ShortcutService { fun createShortcutService(context: Context, conversationsRepository: ConversationsRepository, conversationsService: GlobalService): ShortcutService {

View File

@ -54,10 +54,15 @@ val UseCasesModule = module {
factory { setConversationPasswordUseCase(get(), get()) } factory { setConversationPasswordUseCase(get(), get()) }
factory { getParticipantsForCallUseCase(get(), get()) } factory { getParticipantsForCallUseCase(get(), get()) }
factory { createGetChatMessagesUseCase(get(), get()) } factory { createGetChatMessagesUseCase(get(), get()) }
factory { createSendChatMessageUseCase(get(), get()) }
factory { getNotificationUseCase(get(), get()) } factory { getNotificationUseCase(get(), get()) }
factory { createChatViewModelFactory(get(), get(), get(), get(), get(), get()) } factory { createChatViewModelFactory(get(), get(), get(), get(), get(), get()) }
} }
fun createSendChatMessageUseCase(nextcloudTalkRepository: NextcloudTalkRepository, apiErrorHandler: ApiErrorHandler): SendChatMessageUseCase {
return SendChatMessageUseCase(nextcloudTalkRepository, apiErrorHandler)
}
fun createGetChatMessagesUseCase(nextcloudTalkRepository: NextcloudTalkRepository, apiErrorHandler: ApiErrorHandler): GetChatMessagesUseCase { fun createGetChatMessagesUseCase(nextcloudTalkRepository: NextcloudTalkRepository, apiErrorHandler: ApiErrorHandler): GetChatMessagesUseCase {
return GetChatMessagesUseCase(nextcloudTalkRepository, apiErrorHandler) return GetChatMessagesUseCase(nextcloudTalkRepository, apiErrorHandler)
} }

View File

@ -35,7 +35,7 @@ interface ConversationsRepository {
userId: Long, userId: Long,
conversations: List<Conversation>, conversations: List<Conversation>,
deleteOutdated: Boolean deleteOutdated: Boolean
): List<Long> )
suspend fun setChangingValueForConversation( suspend fun setChangingValueForConversation(
userId: Long, userId: Long,

View File

@ -24,9 +24,13 @@ package com.nextcloud.talk.newarch.domain.repository.offline
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.nextcloud.talk.models.json.chat.ChatMessage import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.newarch.local.models.User
interface MessagesRepository { interface MessagesRepository {
fun getMessagesWithUserForConversation(conversationId: String): LiveData<List<ChatMessage>> fun getMessagesWithUserForConversation(conversationId: String): LiveData<List<ChatMessage>>
fun getPendingMessagesForConversation(conversationId: String): LiveData<List<ChatMessage>>
suspend fun getMessageForConversation(conversationId: String, messageId: Long): ChatMessage?
fun getMessagesWithUserForConversationSince(conversationId: String, messageId: Long): LiveData<List<ChatMessage>> fun getMessagesWithUserForConversationSince(conversationId: String, messageId: Long): LiveData<List<ChatMessage>>
suspend fun saveMessagesForConversation(messages: List<ChatMessage>): List<Long> suspend fun saveMessagesForConversation(user: User, messages: List<ChatMessage>, sendingMessages: Boolean)
suspend fun updateMessageStatus(status: Int, conversationId: String, messageId: Long)
} }

View File

@ -39,6 +39,7 @@ import com.nextcloud.talk.newarch.local.models.UserNgEntity
import retrofit2.Response import retrofit2.Response
interface NextcloudTalkRepository { interface NextcloudTalkRepository {
suspend fun sendChatMessage(user: User, conversationToken: String, message: CharSequence, authorDisplayName: String?, replyTo: Int?, referenceId: String?): Response<ChatOverall>
suspend fun getChatMessagesForConversation(user: User, conversationToken: String, lookIntoFuture: Int, lastKnownMessageId: Int, includeLastKnown: Int = 0): Response<ChatOverall> suspend fun getChatMessagesForConversation(user: User, conversationToken: String, lookIntoFuture: Int, lastKnownMessageId: Int, includeLastKnown: Int = 0): Response<ChatOverall>
suspend fun getNotificationForUser(user: UserNgEntity, notificationId: String): NotificationOverall suspend fun getNotificationForUser(user: UserNgEntity, notificationId: String): NotificationOverall
suspend fun getParticipantsForCall(user: UserNgEntity, conversationToken: String): ParticipantsOverall suspend fun getParticipantsForCall(user: UserNgEntity, conversationToken: String): ParticipantsOverall

View File

@ -0,0 +1,20 @@
package com.nextcloud.talk.newarch.domain.usecases
import com.nextcloud.talk.models.json.chat.ChatOverall
import com.nextcloud.talk.newarch.data.source.remote.ApiErrorHandler
import com.nextcloud.talk.newarch.domain.repository.online.NextcloudTalkRepository
import com.nextcloud.talk.newarch.domain.usecases.base.UseCase
import com.nextcloud.talk.newarch.local.models.User
import org.koin.core.parameter.DefinitionParameters
import retrofit2.Response
class SendChatMessageUseCase constructor(
private val nextcloudTalkRepository: NextcloudTalkRepository,
apiErrorHandler: ApiErrorHandler?
) : UseCase<Response<ChatOverall>, Any?>(apiErrorHandler) {
override suspend fun run(params: Any?): Response<ChatOverall> {
val definitionParameters = params as DefinitionParameters
val user: User = definitionParameters[0]
return nextcloudTalkRepository.sendChatMessage(definitionParameters[0], definitionParameters[1], definitionParameters[2], user.displayName, definitionParameters[3], definitionParameters[4])
}
}

View File

@ -33,7 +33,7 @@ import com.nextcloud.talk.models.json.push.PushConfigurationStateWrapper
import com.nextcloud.talk.models.json.push.PushRegistrationOverall import com.nextcloud.talk.models.json.push.PushRegistrationOverall
import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOverall import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOverall
import com.nextcloud.talk.models.json.userprofile.UserProfileOverall import com.nextcloud.talk.models.json.userprofile.UserProfileOverall
import com.nextcloud.talk.newarch.conversationsList.mvp.BaseViewModel import com.nextcloud.talk.newarch.mvvm.BaseViewModel
import com.nextcloud.talk.newarch.data.model.ErrorModel import com.nextcloud.talk.newarch.data.model.ErrorModel
import com.nextcloud.talk.newarch.domain.repository.offline.UsersRepository import com.nextcloud.talk.newarch.domain.repository.offline.UsersRepository
import com.nextcloud.talk.newarch.domain.usecases.* import com.nextcloud.talk.newarch.domain.usecases.*

View File

@ -26,7 +26,7 @@ import android.app.Application
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.nextcloud.talk.models.json.capabilities.CapabilitiesOverall import com.nextcloud.talk.models.json.capabilities.CapabilitiesOverall
import com.nextcloud.talk.newarch.conversationsList.mvp.BaseViewModel import com.nextcloud.talk.newarch.mvvm.BaseViewModel
import com.nextcloud.talk.newarch.data.model.ErrorModel import com.nextcloud.talk.newarch.data.model.ErrorModel
import com.nextcloud.talk.newarch.domain.usecases.GetCapabilitiesUseCase import com.nextcloud.talk.newarch.domain.usecases.GetCapabilitiesUseCase
import com.nextcloud.talk.newarch.domain.usecases.base.UseCaseResponse import com.nextcloud.talk.newarch.domain.usecases.base.UseCaseResponse

View File

@ -13,6 +13,7 @@ import com.amulyakhare.textdrawable.TextDrawable
import com.nextcloud.talk.R import com.nextcloud.talk.R
import com.nextcloud.talk.models.json.chat.ChatMessage import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.newarch.features.chat.interfaces.ImageLoaderInterface import com.nextcloud.talk.newarch.features.chat.interfaces.ImageLoaderInterface
import com.nextcloud.talk.newarch.local.models.other.ChatMessageStatus
import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.DisplayUtils
import com.nextcloud.talk.utils.DrawableUtils.getDrawableResourceIdForMimeType import com.nextcloud.talk.utils.DrawableUtils.getDrawableResourceIdForMimeType
import com.nextcloud.talk.utils.TextMatchers import com.nextcloud.talk.utils.TextMatchers
@ -80,6 +81,8 @@ open class ChatPresenter<T : Any>(context: Context, private val onElementClickPa
} }
holder.itemView.messageTime?.text = DateFormatter.format(it.createdAt, DateFormatter.Template.TIME) holder.itemView.messageTime?.text = DateFormatter.format(it.createdAt, DateFormatter.Template.TIME)
holder.itemView.sendingProgressBar.isVisible = it.chatMessageStatus != ChatMessageStatus.RECEIVED
holder.itemView.failedToSendNotice.isVisible = it.chatMessageStatus == ChatMessageStatus.FAILED
holder.itemView.chatMessage.text = it.text holder.itemView.chatMessage.text = it.text
if (TextMatchers.isMessageWithSingleEmoticonOnly(it.text)) { if (TextMatchers.isMessageWithSingleEmoticonOnly(it.text)) {
holder.itemView.chatMessage.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f) holder.itemView.chatMessage.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f)

View File

@ -93,7 +93,6 @@ import com.stfalcon.chatkit.utils.DateFormatter
import com.uber.autodispose.lifecycle.LifecycleScopeProvider import com.uber.autodispose.lifecycle.LifecycleScopeProvider
import com.vanniktech.emoji.EmojiPopup import com.vanniktech.emoji.EmojiPopup
import kotlinx.android.synthetic.main.controller_chat.view.* import kotlinx.android.synthetic.main.controller_chat.view.*
import kotlinx.android.synthetic.main.conversations_list_view.view.*
import kotlinx.android.synthetic.main.item_message_quote.view.* import kotlinx.android.synthetic.main.item_message_quote.view.*
import kotlinx.android.synthetic.main.lobby_view.view.* import kotlinx.android.synthetic.main.lobby_view.view.*
import kotlinx.android.synthetic.main.view_message_input.view.* import kotlinx.android.synthetic.main.view_message_input.view.*
@ -105,7 +104,6 @@ class ChatView(private val bundle: Bundle) : BaseView(), ImageLoaderInterface {
override val scopeProvider: LifecycleScopeProvider<*> = ControllerScopeProvider.from(this) override val scopeProvider: LifecycleScopeProvider<*> = ControllerScopeProvider.from(this)
override val lifecycleOwner = ControllerLifecycleOwner(this) override val lifecycleOwner = ControllerLifecycleOwner(this)
private lateinit var viewModel: ChatViewModel private lateinit var viewModel: ChatViewModel
val factory: ChatViewModelFactory by inject() val factory: ChatViewModelFactory by inject()
private val networkComponents: NetworkComponents by inject() private val networkComponents: NetworkComponents by inject()
@ -211,12 +209,6 @@ class ChatView(private val bundle: Bundle) : BaseView(), ImageLoaderInterface {
conversation.observe(this@ChatView) { conversation -> conversation.observe(this@ChatView) { conversation ->
setTitle() setTitle()
if (Conversation.ConversationType.ONE_TO_ONE_CONVERSATION == conversation?.type) {
loadAvatar()
} else {
actionBar?.setIcon(null)
}
shouldShowLobby = conversation!!.shouldShowLobby(user) shouldShowLobby = conversation!!.shouldShowLobby(user)
isReadOnlyConversation = conversation.conversationReadOnlyState == Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY isReadOnlyConversation = conversation.conversationReadOnlyState == Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY
@ -375,6 +367,7 @@ class ChatView(private val bundle: Bundle) : BaseView(), ImageLoaderInterface {
private fun hideReplyView() { private fun hideReplyView() {
view?.messageInputView?.let { view?.messageInputView?.let {
with (it) { with (it) {
quotedMessageLayout.tag = null
quotedMessageLayout.isVisible = false quotedMessageLayout.isVisible = false
attachmentButton.isVisible = true attachmentButton.isVisible = true
attachmentButtonSpace.isVisible = true attachmentButtonSpace.isVisible = true
@ -393,7 +386,6 @@ class ChatView(private val bundle: Bundle) : BaseView(), ImageLoaderInterface {
quotedChatText.text = chatMessage.text quotedChatText.text = chatMessage.text
quotedAuthor.text = chatMessage.user.name quotedAuthor.text = chatMessage.user.name
quotedMessageTime.text = DateFormatter.format(chatMessage.createdAt, DateFormatter.Template.TIME) quotedMessageTime.text = DateFormatter.format(chatMessage.createdAt, DateFormatter.Template.TIME)
loadImage(quotedUserAvatar, chatMessage.user.avatar) loadImage(quotedUserAvatar, chatMessage.user.avatar)
chatMessage.imageUrl?.let { previewImageUrl -> chatMessage.imageUrl?.let { previewImageUrl ->
@ -493,10 +485,6 @@ class ChatView(private val bundle: Bundle) : BaseView(), ImageLoaderInterface {
conversationVoiceCallMenuItem?.isVisible = true conversationVoiceCallMenuItem?.isVisible = true
conversationVideoMenuItem?.isVisible = true conversationVideoMenuItem?.isVisible = true
} }
if (Conversation.ConversationType.ONE_TO_ONE_CONVERSATION == viewModel.conversation.value?.type) {
loadAvatar()
}
} }
private fun setupViews() { private fun setupViews() {
@ -622,25 +610,12 @@ class ChatView(private val bundle: Bundle) : BaseView(), ImageLoaderInterface {
private fun submitMessage() { private fun submitMessage() {
val editable = view?.messageInput?.editableText val editable = view?.messageInput?.editableText
editable?.let { editable?.let {
val mentionSpans = it.getSpans( val replyMessageId= view?.messageInputView?.quotedMessageLayout?.tag as Long?
0, it.length,
Spans.MentionChipSpan::class.java
)
var mentionSpan: Spans.MentionChipSpan
for (i in mentionSpans.indices) {
mentionSpan = mentionSpans[i]
var mentionId = mentionSpan.id
if (mentionId.contains(" ") || mentionId.startsWith("guest/")) {
mentionId = "\"" + mentionId + "\""
}
it.replace(
it.getSpanStart(mentionSpan), it.getSpanEnd(mentionSpan), "@$mentionId"
)
}
view?.messageInput?.setText("") view?.messageInput?.setText("")
viewModel.sendMessage(it) viewModel.sendMessage(it, replyMessageId)
if (replyMessageId != null) {
hideReplyView()
}
} }
} }
@ -696,37 +671,6 @@ class ChatView(private val bundle: Bundle) : BaseView(), ImageLoaderInterface {
} }
} }
private fun loadAvatar() {
val imageLoader = networkComponents.getImageLoader(viewModel.user)
conversationVoiceCallMenuItem?.let {
val avatarSize = DisplayUtils.convertDpToPixel(
it.icon!!.intrinsicWidth.toFloat(), activity!!
)
.toInt()
avatarSize.let {
val target = object : Target {
override fun onSuccess(result: Drawable) {
super.onSuccess(result)
actionBar?.setIcon(result)
}
}
viewModel.conversation.value?.let {
val avatarRequest = Images().getRequestForUrl(
imageLoader, context, ApiUtils.getUrlForAvatarWithNameAndPixels(
viewModel.user.baseUrl,
it.name, avatarSize / 2
), viewModel.user, target, this,
CircleCropTransformation()
)
imageLoader.load(avatarRequest)
}
}
}
}
override fun getLayoutId(): Int { override fun getLayoutId(): Int {
return R.layout.controller_chat return R.layout.controller_chat
} }

View File

@ -33,7 +33,7 @@ class ChatViewLiveDataSource<T : ChatElement>(private val data: LiveData<List<T>
} }
if (first.data is ChatMessage && second.data is ChatMessage) { if (first.data is ChatMessage && second.data is ChatMessage) {
return first.data.jsonMessageId == second.data.jsonMessageId return first.data.jsonMessageId == second.data.jsonMessageId || first.data.referenceId == second.data.referenceId
} }
return false return false

View File

@ -23,7 +23,7 @@
package com.nextcloud.talk.newarch.features.chat package com.nextcloud.talk.newarch.features.chat
import android.app.Application import android.app.Application
import android.text.TextUtils import android.text.Editable
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.Transformations
import androidx.lifecycle.map import androidx.lifecycle.map
@ -32,23 +32,31 @@ import com.bluelinelabs.conductor.Controller
import com.nextcloud.talk.models.json.chat.ChatMessage import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.models.json.chat.ChatOverall import com.nextcloud.talk.models.json.chat.ChatOverall
import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.models.json.conversations.Conversation
import com.nextcloud.talk.newarch.conversationsList.mvp.BaseViewModel
import com.nextcloud.talk.newarch.data.model.ErrorModel import com.nextcloud.talk.newarch.data.model.ErrorModel
import com.nextcloud.talk.newarch.data.source.remote.ApiErrorHandler import com.nextcloud.talk.newarch.data.source.remote.ApiErrorHandler
import com.nextcloud.talk.newarch.domain.repository.offline.ConversationsRepository import com.nextcloud.talk.newarch.domain.repository.offline.ConversationsRepository
import com.nextcloud.talk.newarch.domain.repository.offline.MessagesRepository import com.nextcloud.talk.newarch.domain.repository.offline.MessagesRepository
import com.nextcloud.talk.newarch.domain.usecases.GetChatMessagesUseCase import com.nextcloud.talk.newarch.domain.usecases.GetChatMessagesUseCase
import com.nextcloud.talk.newarch.domain.usecases.SendChatMessageUseCase
import com.nextcloud.talk.newarch.domain.usecases.base.UseCaseResponse import com.nextcloud.talk.newarch.domain.usecases.base.UseCaseResponse
import com.nextcloud.talk.newarch.local.models.User import com.nextcloud.talk.newarch.local.models.*
import com.nextcloud.talk.newarch.local.models.UserNgEntity import com.nextcloud.talk.newarch.local.models.other.ChatMessageStatus
import com.nextcloud.talk.newarch.local.models.toUser import com.nextcloud.talk.newarch.mvvm.BaseViewModel
import com.nextcloud.talk.newarch.local.models.toUserEntity
import com.nextcloud.talk.newarch.services.GlobalService import com.nextcloud.talk.newarch.services.GlobalService
import com.nextcloud.talk.newarch.services.GlobalServiceInterface import com.nextcloud.talk.newarch.services.GlobalServiceInterface
import com.nextcloud.talk.newarch.utils.NetworkComponents import com.nextcloud.talk.newarch.utils.NetworkComponents
import com.nextcloud.talk.newarch.utils.hashWithAlgorithm
import com.nextcloud.talk.utils.text.Spans
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import retrofit2.Response import retrofit2.Response
import kotlin.collections.HashMap
import kotlin.collections.hashMapOf
import kotlin.collections.indices
import kotlin.collections.listOf
import kotlin.collections.map
import kotlin.collections.mutableListOf
import kotlin.collections.set
class ChatViewModel constructor(application: Application, class ChatViewModel constructor(application: Application,
private val networkComponents: NetworkComponents, private val networkComponents: NetworkComponents,
@ -62,7 +70,7 @@ class ChatViewModel constructor(application: Application,
val futureStartingPoint: MutableLiveData<Long> = MutableLiveData() val futureStartingPoint: MutableLiveData<Long> = MutableLiveData()
private var initConversation: Conversation? = null private var initConversation: Conversation? = null
val messagesLiveData = Transformations.switchMap(futureStartingPoint) {futureStartingPoint -> val messagesLiveData = Transformations.switchMap(futureStartingPoint) { futureStartingPoint ->
conversation.value?.let { conversation.value?.let {
messagesRepository.getMessagesWithUserForConversationSince(it.databaseId!!, futureStartingPoint).map { chatMessagesList -> messagesRepository.getMessagesWithUserForConversationSince(it.databaseId!!, futureStartingPoint).map { chatMessagesList ->
chatMessagesList.map { chatMessage -> chatMessagesList.map { chatMessage ->
@ -92,8 +100,75 @@ class ChatViewModel constructor(application: Application,
} }
} }
fun sendMessage(message: CharSequence) { fun sendMessage(editable: Editable, replyTo: Long?) {
val messageParameters = hashMapOf<String, HashMap<String, String>>()
val mentionSpans = editable.getSpans(
0, editable.length,
Spans.MentionChipSpan::class.java
)
var mentionSpan: Spans.MentionChipSpan
val ids = mutableListOf<String>()
for (i in mentionSpans.indices) {
mentionSpan = mentionSpans[i]
var mentionId = mentionSpan.id
if (mentionId.contains(" ") || mentionId.startsWith("guest/")) {
mentionId = "\"" + mentionId + "\""
}
val mentionNo = if (ids.contains("mentionId")) ids.indexOf("mentionId") + 1 else ids.size + 1
val mentionReplace = "mention-${mentionSpan.type}$mentionNo"
if (!ids.contains(mentionId)) {
ids.add(mentionId)
messageParameters[mentionReplace] = hashMapOf("type" to mentionSpan.type, "id" to mentionId.toString(), "name" to mentionSpan.label.toString())
}
val start = editable.getSpanStart(mentionSpan)
editable.replace(start, editable.getSpanEnd(mentionSpan), "")
editable.insert(start, "{$mentionReplace}")
}
if (user.hasSpreedFeatureCapability("chat-reference-id")) {
ioScope.launch {
val chatMessage = ChatMessage()
val timestamp = System.currentTimeMillis()
val sha1 = timestamp.toString().hashWithAlgorithm("SHA-1")
conversation.value?.databaseId?.let { conversationDatabaseId ->
chatMessage.internalMessageId = sha1
chatMessage.internalConversationId = conversationDatabaseId
chatMessage.timestamp = timestamp / 1000
chatMessage.referenceId = sha1
chatMessage.replyable = false
// can also be "guests", but not now
chatMessage.actorId = user.userId
chatMessage.actorType = "users"
chatMessage.actorDisplayName = user.displayName
chatMessage.message = editable.toString()
chatMessage.systemMessageType = null
chatMessage.chatMessageStatus = ChatMessageStatus.PENDING_MESSAGE_SEND
if (replyTo != null) {
chatMessage.parentMessage = messagesRepository.getMessageForConversation(conversationDatabaseId, replyTo)
} else {
chatMessage.parentMessage = null
}
chatMessage.messageParameters = messageParameters
messagesRepository.saveMessagesForConversation(user, listOf(chatMessage), true)
}
}
} else {
val sendChatMessageUseCase = SendChatMessageUseCase(networkComponents.getRepository(false, user), apiErrorHandler)
// No reference id needed here
initConversation?.let {
sendChatMessageUseCase.invoke(viewModelScope, parametersOf(user, it.token, editable, replyTo, null), object : UseCaseResponse<Response<ChatOverall>> {
override suspend fun onSuccess(result: Response<ChatOverall>) {
// also do nothing, we did it - time to celebrate1
}
override suspend fun onError(errorModel: ErrorModel?) {
// Do nothing, error - tough luck
}
})
}
}
} }
override suspend fun gotConversationInfoForUser(userNgEntity: UserNgEntity, conversation: Conversation?, operationStatus: GlobalServiceInterface.OperationStatus) { override suspend fun gotConversationInfoForUser(userNgEntity: UserNgEntity, conversation: Conversation?, operationStatus: GlobalServiceInterface.OperationStatus) {
@ -124,11 +199,10 @@ class ChatViewModel constructor(application: Application,
val messages = result.body()?.ocs?.data val messages = result.body()?.ocs?.data
messages?.let { messages?.let {
for (message in it) { for (message in it) {
message.activeUser = userNgEntity
message.internalConversationId = conversation.databaseId message.internalConversationId = conversation.databaseId
} }
messagesRepository.saveMessagesForConversation(it) messagesRepository.saveMessagesForConversation(user, it, false)
} }
val xChatLastGivenHeader: String? = result.headers().get("X-Chat-Last-Given") val xChatLastGivenHeader: String? = result.headers().get("X-Chat-Last-Given")
@ -159,25 +233,19 @@ class ChatViewModel constructor(application: Application,
val messages = result.body()?.ocs?.data val messages = result.body()?.ocs?.data
messages?.let { messages?.let {
for (message in it) { for (message in it) {
message.activeUser = userNgEntity
message.internalConversationId = conversation.databaseId message.internalConversationId = conversation.databaseId
} }
messagesRepository.saveMessagesForConversation(it) messagesRepository.saveMessagesForConversation(user, it, false)
} }
if (result.code() == 200) {
val xChatLastGivenHeader: String? = result.headers().get("X-Chat-Last-Given") val xChatLastGivenHeader: String? = result.headers().get("X-Chat-Last-Given")
if (xChatLastGivenHeader != null) { if (xChatLastGivenHeader != null) {
pullFutureMessagesForUserAndConversation(userNgEntity, conversation, xChatLastGivenHeader.toInt()) pullFutureMessagesForUserAndConversation(userNgEntity, conversation, xChatLastGivenHeader.toInt())
} }
} else {
pullFutureMessagesForUserAndConversation(userNgEntity, conversation, lastKnownMessageId)
}
} }
override suspend fun onError(errorModel: ErrorModel?) { override suspend fun onError(errorModel: ErrorModel?) {
pullFutureMessagesForUserAndConversation(userNgEntity, conversation)
} }
}) })
} }

View File

@ -32,7 +32,7 @@ import com.nextcloud.talk.models.json.conversations.Conversation
import com.nextcloud.talk.models.json.conversations.ConversationOverall import com.nextcloud.talk.models.json.conversations.ConversationOverall
import com.nextcloud.talk.models.json.participants.AddParticipantOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall
import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.models.json.participants.Participant
import com.nextcloud.talk.newarch.conversationsList.mvp.BaseViewModel import com.nextcloud.talk.newarch.mvvm.BaseViewModel
import com.nextcloud.talk.newarch.data.model.ErrorModel import com.nextcloud.talk.newarch.data.model.ErrorModel
import com.nextcloud.talk.newarch.domain.usecases.AddParticipantToConversationUseCase import com.nextcloud.talk.newarch.domain.usecases.AddParticipantToConversationUseCase
import com.nextcloud.talk.newarch.domain.usecases.CreateConversationUseCase import com.nextcloud.talk.newarch.domain.usecases.CreateConversationUseCase

View File

@ -29,7 +29,7 @@ import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.nextcloud.talk.models.json.conversations.ConversationOverall import com.nextcloud.talk.models.json.conversations.ConversationOverall
import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.newarch.conversationsList.mvp.BaseViewModel import com.nextcloud.talk.newarch.mvvm.BaseViewModel
import com.nextcloud.talk.newarch.data.model.ErrorModel import com.nextcloud.talk.newarch.data.model.ErrorModel
import com.nextcloud.talk.newarch.domain.usecases.CreateConversationUseCase import com.nextcloud.talk.newarch.domain.usecases.CreateConversationUseCase
import com.nextcloud.talk.newarch.domain.usecases.SetConversationPasswordUseCase import com.nextcloud.talk.newarch.domain.usecases.SetConversationPasswordUseCase

View File

@ -34,7 +34,7 @@ import coil.transform.CircleCropTransformation
import com.nextcloud.talk.R import com.nextcloud.talk.R
import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.models.json.conversations.Conversation
import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.newarch.conversationsList.mvp.BaseViewModel import com.nextcloud.talk.newarch.mvvm.BaseViewModel
import com.nextcloud.talk.newarch.data.model.ErrorModel import com.nextcloud.talk.newarch.data.model.ErrorModel
import com.nextcloud.talk.newarch.domain.repository.offline.ConversationsRepository import com.nextcloud.talk.newarch.domain.repository.offline.ConversationsRepository
import com.nextcloud.talk.newarch.domain.usecases.DeleteConversationUseCase import com.nextcloud.talk.newarch.domain.usecases.DeleteConversationUseCase
@ -200,9 +200,6 @@ class ConversationsListViewModel (
networkStateLiveData.postValue(ConversationsListViewNetworkState.LOADED) networkStateLiveData.postValue(ConversationsListViewNetworkState.LOADED)
val mutableList = result.toMutableList() val mutableList = result.toMutableList()
val internalUserId = globalService.currentUserLiveData.value!!.id val internalUserId = globalService.currentUserLiveData.value!!.id
mutableList.forEach {
it.databaseUserId = internalUserId
}
conversationsRepository.saveConversationsForUser( conversationsRepository.saveConversationsForUser(
internalUserId, internalUserId,

View File

@ -0,0 +1,45 @@
/*
*
* * Nextcloud Talk application
* *
* * @author Mario Danic
* * Copyright (C) 2017-2020 Mario Danic <mario@lovelyhq.com>
* *
* * This program is free software: you can redistribute it and/or modify
* * it under the terms of the GNU General Public License as published by
* * the Free Software Foundation, either version 3 of the License, or
* * at your option) any later version.
* *
* * This program is distributed in the hope that it will be useful,
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* * GNU General Public License for more details.
* *
* * You should have received a copy of the GNU General Public License
* * along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.nextcloud.talk.newarch.local.converters
import androidx.room.TypeConverter
import com.nextcloud.talk.newarch.local.models.other.ChatMessageStatus
class ChatMessageStatusConverter {
@TypeConverter
fun fromChatMessageStatusToInt(chatMessageStatus: ChatMessageStatus): Int {
return chatMessageStatus.ordinal
}
@TypeConverter
fun fromIntToChatMessageStatus(value: Int): ChatMessageStatus {
return when (value) {
0 -> ChatMessageStatus.SENT
1 -> ChatMessageStatus.RECEIVED
2 -> ChatMessageStatus.PENDING_MESSAGE_SEND
3 -> ChatMessageStatus.PENDING_FILE_UPLOAD
4 -> ChatMessageStatus.PENDING_FILE_SHARE
else -> ChatMessageStatus.FAILED
}
}
}

View File

@ -41,11 +41,11 @@ abstract class ConversationsDao {
@Query("DELETE FROM conversations WHERE user_id = :userId") @Query("DELETE FROM conversations WHERE user_id = :userId")
abstract suspend fun clearConversationsForUser(userId: Long) abstract suspend fun clearConversationsForUser(userId: Long)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Update(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun saveConversationWithInsert(conversation: ConversationEntity): Long abstract suspend fun update(conversation: ConversationEntity): Int
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun saveConversationsWithInsert(vararg conversations: ConversationEntity): List<Long> abstract suspend fun insert(conversation: ConversationEntity)
@Query( @Query(
"UPDATE conversations SET changing = :changing WHERE user_id = :userId AND conversation_id = :conversationId" "UPDATE conversations SET changing = :changing WHERE user_id = :userId AND conversation_id = :conversationId"
@ -88,18 +88,26 @@ abstract class ConversationsDao {
userId: Long, userId: Long,
newConversations: Array<ConversationEntity>, newConversations: Array<ConversationEntity>,
deleteOutdated: Boolean deleteOutdated: Boolean
): List<Long> { ) {
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
val conversationsWithTimestampApplied = newConversations.map { val conversationsWithTimestampApplied = newConversations.map {
it.modifiedAt = timestamp it.modifiedAt = timestamp
it.userId = userId
it.id = it.userId.toString() + "@" + it.token
it it
} }
val list = saveConversationsWithInsert(*conversationsWithTimestampApplied.toTypedArray()) conversationsWithTimestampApplied.forEach { internalUpsert(it) }
if (deleteOutdated) { if (deleteOutdated) {
deleteConversationsForUserWithTimestamp(userId, timestamp) deleteConversationsForUserWithTimestamp(userId, timestamp)
} }
return list }
private suspend fun internalUpsert(conversationEntity: ConversationEntity) {
val count = update(conversationEntity)
if (count == 0) {
insert(conversationEntity)
}
} }
} }

View File

@ -23,21 +23,76 @@
package com.nextcloud.talk.newarch.local.dao package com.nextcloud.talk.newarch.local.dao
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.room.Dao import androidx.room.*
import androidx.room.Insert import com.nextcloud.talk.newarch.local.models.ConversationEntity
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.nextcloud.talk.newarch.local.models.MessageEntity import com.nextcloud.talk.newarch.local.models.MessageEntity
import com.nextcloud.talk.newarch.local.models.User
@Dao @Dao
abstract class MessagesDao { abstract class MessagesDao {
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId ORDER BY message_id ASC") @Query("SELECT * FROM messages WHERE conversation_id = :conversationId ORDER BY timestamp ASC")
abstract fun getMessagesWithUserForConversation(conversationId: String): abstract fun getMessagesWithUserForConversation(conversationId: String):
LiveData<List<MessageEntity>> LiveData<List<MessageEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun saveMessages(vararg messages: MessageEntity): List<Long> abstract suspend fun saveMessages(vararg messages: MessageEntity): List<Long>
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId AND message_id >= :messageId ORDER BY message_id ASC") @Query("SELECT * FROM messages WHERE conversation_id = :conversationId AND id = reference_id")
abstract suspend fun getPendingMessages(conversationId: String): List<MessageEntity>
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId and id = reference_id and message_status != 5 and message_status != 0")
abstract fun getPendingMessagesLive(conversationId: String): LiveData<List<MessageEntity>>
@Query(
"UPDATE messages SET id = :newId WHERE conversation_id = :conversationId AND reference_id = :referenceId"
)
abstract suspend fun updateMessageId(newId: String, conversationId: String, referenceId: String)
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId AND (message_id >= :messageId OR message_id = 0) ORDER BY timestamp ASC")
abstract fun getMessagesWithUserForConversationSince(conversationId: String, messageId: Long): LiveData<List<MessageEntity>> abstract fun getMessagesWithUserForConversationSince(conversationId: String, messageId: Long): LiveData<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId AND message_id = :messageId")
abstract fun getMessageForConversation(conversationId: String, messageId: Long): MessageEntity?
@Query(
"UPDATE messages SET message_status = :status WHERE conversation_id = :conversationId AND message_id = :messageId"
)
abstract suspend fun updateMessageStatus(
status: Int,
conversationId: String,
messageId: Long
)
@Update(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun update(message: MessageEntity): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(message: MessageEntity)
@Transaction
open suspend fun updateMessages(user: User, messages: Array<MessageEntity>) {
val messagesToUpdate = messages.toMutableList()
if (messagesToUpdate.size > 0) {
val conversationId = messagesToUpdate[0].conversationId
val pendingMessages = getPendingMessages(conversationId)
val pendingMessagesReferenceIds = pendingMessages.map { it.referenceId }
messagesToUpdate.forEach {
it.referenceId?.let { referenceId ->
if (pendingMessagesReferenceIds.contains(referenceId)) {
updateMessageId(it.id, it.conversationId, referenceId)
}
}
}
messagesToUpdate.forEach { internalUpsert(it) }
}
}
private suspend fun internalUpsert(message: MessageEntity) {
val count = update(message)
if (count == 0) {
insert(message)
}
}
} }

View File

@ -27,6 +27,8 @@ import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteQuery
import com.nextcloud.talk.newarch.local.converters.* import com.nextcloud.talk.newarch.local.converters.*
import com.nextcloud.talk.newarch.local.dao.ConversationsDao import com.nextcloud.talk.newarch.local.dao.ConversationsDao
import com.nextcloud.talk.newarch.local.dao.MessagesDao import com.nextcloud.talk.newarch.local.dao.MessagesDao
@ -48,7 +50,7 @@ import org.parceler.converter.HashMapParcelConverter
PushConfigurationConverter::class, CapabilitiesConverter::class, PushConfigurationConverter::class, CapabilitiesConverter::class,
SignalingSettingsConverter::class, SignalingSettingsConverter::class,
UserStatusConverter::class, SystemMessageTypeConverter::class, ParticipantMapConverter::class, UserStatusConverter::class, SystemMessageTypeConverter::class, ParticipantMapConverter::class,
HashMapHashMapConverter::class HashMapHashMapConverter::class, ChatMessageStatusConverter::class
) )
abstract class TalkDatabase : RoomDatabase() { abstract class TalkDatabase : RoomDatabase() {
@ -71,6 +73,12 @@ abstract class TalkDatabase : RoomDatabase() {
private fun build(context: Context) = private fun build(context: Context) =
Room.databaseBuilder(context.applicationContext, TalkDatabase::class.java, DB_NAME) Room.databaseBuilder(context.applicationContext, TalkDatabase::class.java, DB_NAME)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.addCallback(object : RoomDatabase.Callback() {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
db.execSQL("PRAGMA defer_foreign_keys = 1")
}
})
.build() .build()
} }
} }

View File

@ -26,17 +26,18 @@ import androidx.room.*
import androidx.room.ForeignKey.CASCADE import androidx.room.ForeignKey.CASCADE
import com.nextcloud.talk.models.json.chat.ChatMessage import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType
import com.nextcloud.talk.newarch.local.models.other.ChatMessageStatus
@Entity( @Entity(
tableName = "messages", tableName = "messages",
indices = [Index(value = ["conversation_id"])], indices = [Index(value = ["conversation_id"])],
foreignKeys = [ForeignKey( foreignKeys = [ForeignKey(
entity = ConversationEntity::class, entity = ConversationEntity::class,
deferred = true,
parentColumns = arrayOf("id"), parentColumns = arrayOf("id"),
childColumns = arrayOf("conversation_id"), childColumns = arrayOf("conversation_id"),
onDelete = CASCADE, onDelete = CASCADE,
onUpdate = CASCADE, onUpdate = CASCADE
deferred = true
)] )]
) )
data class MessageEntity( data class MessageEntity(
@ -51,7 +52,9 @@ data class MessageEntity(
@ColumnInfo(name = "messageParameters") var messageParameters: HashMap<String, HashMap<String, String>>? = null, @ColumnInfo(name = "messageParameters") var messageParameters: HashMap<String, HashMap<String, String>>? = null,
@ColumnInfo(name = "parent") var parentMessage: ChatMessage? = null, @ColumnInfo(name = "parent") var parentMessage: ChatMessage? = null,
@ColumnInfo(name = "replyable") var replyable: Boolean = false, @ColumnInfo(name = "replyable") var replyable: Boolean = false,
@ColumnInfo(name = "system_message_type") var systemMessageType: SystemMessageType? = null @ColumnInfo(name = "system_message_type") var systemMessageType: SystemMessageType? = null,
@ColumnInfo(name = "reference_id") var referenceId: String? = null,
@ColumnInfo(name = "message_status") var chatMessageStatus: ChatMessageStatus = ChatMessageStatus.RECEIVED
) )
fun MessageEntity.toChatMessage(): ChatMessage { fun MessageEntity.toChatMessage(): ChatMessage {
@ -68,12 +71,15 @@ fun MessageEntity.toChatMessage(): ChatMessage {
chatMessage.systemMessageType = this.systemMessageType chatMessage.systemMessageType = this.systemMessageType
chatMessage.replyable = this.replyable chatMessage.replyable = this.replyable
chatMessage.parentMessage = this.parentMessage chatMessage.parentMessage = this.parentMessage
chatMessage.referenceId = this.referenceId
chatMessage.chatMessageStatus = this.chatMessageStatus
return chatMessage return chatMessage
} }
fun ChatMessage.toMessageEntity(): MessageEntity { fun ChatMessage.toMessageEntity(): MessageEntity {
val messageEntity = MessageEntity(this.internalConversationId + "@" + this.jsonMessageId, this.internalConversationId!!) val messageEntityId = if (this.internalMessageId != null) internalMessageId else this.internalConversationId + "@" + this.jsonMessageId
messageEntity.messageId = this.jsonMessageId!! val messageEntity = MessageEntity(messageEntityId!!, this.internalConversationId!!)
messageEntity.messageId = this.jsonMessageId ?: 0
messageEntity.actorType = this.actorType messageEntity.actorType = this.actorType
messageEntity.actorId = this.actorId messageEntity.actorId = this.actorId
messageEntity.actorDisplayName = this.actorDisplayName messageEntity.actorDisplayName = this.actorDisplayName
@ -83,6 +89,7 @@ fun ChatMessage.toMessageEntity(): MessageEntity {
messageEntity.replyable = this.replyable messageEntity.replyable = this.replyable
messageEntity.messageParameters = this.messageParameters messageEntity.messageParameters = this.messageParameters
messageEntity.parentMessage = this.parentMessage messageEntity.parentMessage = this.parentMessage
messageEntity.referenceId = this.referenceId
messageEntity.chatMessageStatus = this.chatMessageStatus
return messageEntity return messageEntity
} }

View File

@ -0,0 +1,10 @@
package com.nextcloud.talk.newarch.local.models.other
enum class ChatMessageStatus {
SENT,
RECEIVED,
PENDING_MESSAGE_SEND,
PENDING_FILE_UPLOAD,
PENDING_FILE_SHARE,
FAILED
}

View File

@ -20,7 +20,7 @@
* *
*/ */
package com.nextcloud.talk.newarch.conversationsList.mvp package com.nextcloud.talk.newarch.mvvm
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context

View File

@ -23,39 +23,106 @@
package com.nextcloud.talk.newarch.services package com.nextcloud.talk.newarch.services
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.models.json.chat.ChatOverall
import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.models.json.conversations.Conversation
import com.nextcloud.talk.models.json.conversations.ConversationOverall import com.nextcloud.talk.models.json.conversations.ConversationOverall
import com.nextcloud.talk.newarch.data.model.ErrorModel import com.nextcloud.talk.newarch.data.model.ErrorModel
import com.nextcloud.talk.newarch.data.source.remote.ApiErrorHandler
import com.nextcloud.talk.newarch.domain.repository.offline.ConversationsRepository import com.nextcloud.talk.newarch.domain.repository.offline.ConversationsRepository
import com.nextcloud.talk.newarch.domain.repository.offline.MessagesRepository
import com.nextcloud.talk.newarch.domain.repository.offline.UsersRepository import com.nextcloud.talk.newarch.domain.repository.offline.UsersRepository
import com.nextcloud.talk.newarch.domain.usecases.GetConversationUseCase import com.nextcloud.talk.newarch.domain.usecases.GetConversationUseCase
import com.nextcloud.talk.newarch.domain.usecases.JoinConversationUseCase import com.nextcloud.talk.newarch.domain.usecases.JoinConversationUseCase
import com.nextcloud.talk.newarch.domain.usecases.SendChatMessageUseCase
import com.nextcloud.talk.newarch.domain.usecases.base.UseCaseResponse import com.nextcloud.talk.newarch.domain.usecases.base.UseCaseResponse
import com.nextcloud.talk.newarch.local.models.UserNgEntity import com.nextcloud.talk.newarch.local.models.UserNgEntity
import com.nextcloud.talk.newarch.local.models.other.ChatMessageStatus
import com.nextcloud.talk.newarch.local.models.toUser
import com.nextcloud.talk.newarch.utils.NetworkComponents
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import retrofit2.Response
import java.net.CookieManager import java.net.CookieManager
import java.util.concurrent.ConcurrentHashMap
class GlobalService constructor(usersRepository: UsersRepository, class GlobalService constructor(usersRepository: UsersRepository,
cookieManager: CookieManager, cookieManager: CookieManager,
okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val apiErrorHandler: ApiErrorHandler,
private val conversationsRepository: ConversationsRepository, private val conversationsRepository: ConversationsRepository,
private val messagesRepository: MessagesRepository,
private val networkComponents: NetworkComponents,
private val joinConversationUseCase: JoinConversationUseCase, private val joinConversationUseCase: JoinConversationUseCase,
private val getConversationUseCase: GetConversationUseCase) : KoinComponent { private val getConversationUseCase: GetConversationUseCase) : KoinComponent {
private val applicationScope = CoroutineScope(Dispatchers.Default) private val applicationScope = CoroutineScope(Dispatchers.Default)
private val previousUser: UserNgEntity? = null private val previousUser: UserNgEntity? = null
val currentUserLiveData: LiveData<UserNgEntity?> = usersRepository.getActiveUserLiveData() val currentUserLiveData: LiveData<UserNgEntity?> = usersRepository.getActiveUserLiveData()
private var currentConversation: Conversation? = null private var currentConversation: MutableLiveData<Conversation?> = MutableLiveData<Conversation?>(null)
private val pendingMessages: LiveData<List<ChatMessage>> = Transformations.switchMap(currentConversation) { conversation ->
conversation?.let {
messagesRepository.getPendingMessagesForConversation(it.databaseId!!)
}
}
private var messagesOperations: ConcurrentHashMap<String, Pair<ChatMessage, Int>> = ConcurrentHashMap<String, Pair<ChatMessage, Int>>()
init { init {
pendingMessages.observeForever { chatMessages ->
for (chatMessage in chatMessages) {
if (!messagesOperations.contains(chatMessage.internalMessageId) || messagesOperations[chatMessage.internalMessageId]?.first != chatMessage) {
messagesOperations[chatMessage.internalMessageId!!] = Pair(chatMessage, 0)
applicationScope.launch {
sendMessage(chatMessage)
}
}
}
}
currentUserLiveData.observeForever { user -> currentUserLiveData.observeForever { user ->
user?.let { user?.let {
if (it.id != previousUser?.id) { if (it.id != previousUser?.id) {
cookieManager.cookieStore.removeAll() cookieManager.cookieStore.removeAll()
currentConversation = null currentConversation.postValue(null)
}
}
}
}
suspend fun sendMessage(chatMessage: ChatMessage) {
val currentUser = currentUserLiveData.value?.toUser()
val conversation = currentConversation.value
val operationChatMessage = messagesOperations[chatMessage.internalMessageId]
operationChatMessage?.let { pair ->
conversation?.let { conversation ->
if (pair.second == 4) {
messagesOperations.remove(pair.first.internalMessageId)
messagesRepository.updateMessageStatus(ChatMessageStatus.FAILED.ordinal, conversation.databaseId!!, pair.first.jsonMessageId!!)
} else {
currentUser?.let { user ->
if (chatMessage.internalConversationId == conversation.databaseId && conversation.databaseUserId == currentUser.id) {
val sendChatMessageUseCase = SendChatMessageUseCase(networkComponents.getRepository(false, user), apiErrorHandler)
sendChatMessageUseCase.invoke(applicationScope, parametersOf(user, conversation.token, chatMessage.message, chatMessage.parentMessage?.jsonMessageId, chatMessage.referenceId), object : UseCaseResponse<Response<ChatOverall>> {
override suspend fun onSuccess(result: Response<ChatOverall>) {
messagesOperations.remove(pair.first.internalMessageId!!)
messagesRepository.updateMessageStatus(ChatMessageStatus.SENT.ordinal, conversation.databaseId!!, pair.first.jsonMessageId!!)
}
override suspend fun onError(errorModel: ErrorModel?) {
val newValue = operationChatMessage.second + 1
messagesOperations[pair.first.internalMessageId!!] = Pair(chatMessage, newValue)
sendMessage(chatMessage)
}
})
}
}
} }
} }
} }
@ -63,6 +130,7 @@ class GlobalService constructor(usersRepository: UsersRepository,
suspend fun getConversation(conversationToken: String, globalServiceInterface: GlobalServiceInterface) { suspend fun getConversation(conversationToken: String, globalServiceInterface: GlobalServiceInterface) {
val currentUser = currentUserLiveData.value val currentUser = currentUserLiveData.value
val getConversationUseCase = GetConversationUseCase(networkComponents.getRepository(true, currentUser!!.toUser()), apiErrorHandler)
getConversationUseCase.invoke(applicationScope, parametersOf( getConversationUseCase.invoke(applicationScope, parametersOf(
currentUser, currentUser,
conversationToken conversationToken
@ -72,6 +140,7 @@ class GlobalService constructor(usersRepository: UsersRepository,
currentUser?.let { currentUser?.let {
conversationsRepository.saveConversationsForUser(it.id, listOf(result.ocs.data), false) conversationsRepository.saveConversationsForUser(it.id, listOf(result.ocs.data), false)
globalServiceInterface.gotConversationInfoForUser(it, result.ocs.data, GlobalServiceInterface.OperationStatus.STATUS_OK) globalServiceInterface.gotConversationInfoForUser(it, result.ocs.data, GlobalServiceInterface.OperationStatus.STATUS_OK)
} }
} }
@ -80,11 +149,13 @@ class GlobalService constructor(usersRepository: UsersRepository,
globalServiceInterface.gotConversationInfoForUser(it, null, GlobalServiceInterface.OperationStatus.STATUS_FAILED) globalServiceInterface.gotConversationInfoForUser(it, null, GlobalServiceInterface.OperationStatus.STATUS_FAILED)
} }
} }
}) })
} }
suspend fun joinConversation(conversationToken: String, conversationPassword: String?, globalServiceInterface: GlobalServiceInterface) { suspend fun joinConversation(conversationToken: String, conversationPassword: String?, globalServiceInterface: GlobalServiceInterface) {
val currentUser = currentUserLiveData.value val currentUser = currentUserLiveData.value
val joinConversationUseCase = JoinConversationUseCase(networkComponents.getRepository(true, currentUser!!.toUser()), apiErrorHandler)
joinConversationUseCase.invoke(applicationScope, parametersOf( joinConversationUseCase.invoke(applicationScope, parametersOf(
currentUser, currentUser,
conversationToken, conversationToken,
@ -94,14 +165,14 @@ class GlobalService constructor(usersRepository: UsersRepository,
override suspend fun onSuccess(result: ConversationOverall) { override suspend fun onSuccess(result: ConversationOverall) {
currentUser?.let { currentUser?.let {
conversationsRepository.saveConversationsForUser(it.id, listOf(result.ocs.data), false) conversationsRepository.saveConversationsForUser(it.id, listOf(result.ocs.data), false)
currentConversation = conversationsRepository.getConversationForUserWithToken(it.id, result.ocs!!.data!!.token!!) currentConversation.postValue(conversationsRepository.getConversationForUserWithToken(it.id, result.ocs!!.data!!.token!!))
globalServiceInterface.joinedConversationForUser(it, currentConversation, GlobalServiceInterface.OperationStatus.STATUS_OK) globalServiceInterface.joinedConversationForUser(it, currentConversation.value, GlobalServiceInterface.OperationStatus.STATUS_OK)
} }
} }
override suspend fun onError(errorModel: ErrorModel?) { override suspend fun onError(errorModel: ErrorModel?) {
currentUser?.let { currentUser?.let {
globalServiceInterface.joinedConversationForUser(it, currentConversation, GlobalServiceInterface.OperationStatus.STATUS_FAILED) globalServiceInterface.joinedConversationForUser(it, currentConversation.value, GlobalServiceInterface.OperationStatus.STATUS_FAILED)
} }
} }
}) })

View File

@ -291,15 +291,15 @@ object DisplayUtils {
val start = stringText.indexOf(m.group(), lastStartIndex) val start = stringText.indexOf(m.group(), lastStartIndex)
val end = start + m.group().length val end = start + m.group().length
lastStartIndex = end lastStartIndex = end
mentionChipSpan = Spans.MentionChipSpan( /*mentionChipSpan = Spans.MentionChipSpan(
getDrawableForMentionChipSpan( getDrawableForMentionChipSpan(
context, context,
id, label, conversationUser, type, chipXmlRes, null id, label, conversationUser, chipXmlRes, null
), ),
BetterImageSpan.ALIGN_CENTER, id, BetterImageSpan.ALIGN_CENTER, id,
label label
) )*/
spannableString.setSpan(mentionChipSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) //spannableString.setSpan(mentionChipSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
if ("user" == type && conversationUser.userId != id) { if ("user" == type && conversationUser.userId != id) {
spannableString.setSpan(clickableSpan, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) spannableString.setSpan(clickableSpan, start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
} }

View File

@ -34,12 +34,14 @@ public class Spans {
public static class MentionChipSpan extends BetterImageSpan { public static class MentionChipSpan extends BetterImageSpan {
public String id; public String id;
public CharSequence label; public CharSequence label;
public String type;
public MentionChipSpan(@NonNull Drawable drawable, int verticalAlignment, String id, public MentionChipSpan(@NonNull Drawable drawable, int verticalAlignment, String id,
CharSequence label) { CharSequence label, String type) {
super(drawable, verticalAlignment); super(drawable, verticalAlignment);
this.id = id; this.id = id;
this.label = label; this.label = label;
this.type = type;
} }
} }
} }

View File

@ -64,14 +64,37 @@
android:layout_below="@id/previewImage" android:layout_below="@id/previewImage"
tools:text="Just another chat message"/> tools:text="Just another chat message"/>
<ProgressBar
android:layout_width="12dp"
android:layout_height="12dp"
android:id="@+id/sendingProgressBar"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_marginEnd="8dp"
android:visibility="gone"
android:progressBackgroundTint="@color/colorPrimary"/>
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_below="@id/chatMessage" android:layout_below="@id/chatMessage"
android:textSize="10sp" android:textSize="10sp"
android:layout_alignParentBottom="true"
android:layout_alignWithParentIfMissing="true"
android:layout_toStartOf="@id/sendingProgressBar"
android:id="@+id/messageTime" android:id="@+id/messageTime"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
tools:text="12:30"/> tools:text="12:30"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:text="@string/nc_failed_to_send"
android:layout_below="@id/messageTime"
android:id="@+id/failedToSendNotice"
android:visibility="gone"
android:layout_alignParentEnd="true"/>
</RelativeLayout> </RelativeLayout>

View File

@ -346,4 +346,5 @@
<string name="nc_search_empty_contacts">Where did they all hide?</string> <string name="nc_search_empty_contacts">Where did they all hide?</string>
<string name="nc_reject_call">Reject</string> <string name="nc_reject_call">Reject</string>
<string name="silenced_by_moderator">You were silenced by a moderator</string> <string name="silenced_by_moderator">You were silenced by a moderator</string>
<string name="nc_failed_to_send">Failed to sent - tap to retry sending.</string>
</resources> </resources>