Unverified Commit 7c8e577d authored by Leonardo Aramaki's avatar Leonardo Aramaki Committed by GitHub

Merge pull request #732 from RocketChat/markdown-support

[NEW] Markdown support
parents d31b9ea1 0749391e
...@@ -99,6 +99,10 @@ dependencies { ...@@ -99,6 +99,10 @@ dependencies {
implementation libraries.textDrawable implementation libraries.textDrawable
implementation libraries.markwon
implementation libraries.markwonImageLoader
implementation libraries.moshiLazyAdapters implementation libraries.moshiLazyAdapters
implementation('com.crashlytics.sdk.android:crashlytics:2.6.8@aar') { implementation('com.crashlytics.sdk.android:crashlytics:2.6.8@aar') {
......
...@@ -4,6 +4,7 @@ import chat.rocket.android.chatroom.viewmodel.MessageViewModelMapper ...@@ -4,6 +4,7 @@ import chat.rocket.android.chatroom.viewmodel.MessageViewModelMapper
import chat.rocket.android.core.lifecycle.CancelStrategy import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.server.domain.GetCurrentServerInteractor import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.GetSettingsInteractor import chat.rocket.android.server.domain.GetSettingsInteractor
import chat.rocket.android.server.domain.MessagesRepository
import chat.rocket.android.server.infraestructure.RocketChatClientFactory import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.android.util.launchUI import chat.rocket.android.util.launchUI
import chat.rocket.common.model.roomTypeOf import chat.rocket.common.model.roomTypeOf
...@@ -26,6 +27,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView, ...@@ -26,6 +27,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
private val strategy: CancelStrategy, private val strategy: CancelStrategy,
getSettingsInteractor: GetSettingsInteractor, getSettingsInteractor: GetSettingsInteractor,
private val serverInteractor: GetCurrentServerInteractor, private val serverInteractor: GetCurrentServerInteractor,
private val messagesRepository: MessagesRepository,
factory: RocketChatClientFactory, factory: RocketChatClientFactory,
private val mapper: MessageViewModelMapper) { private val mapper: MessageViewModelMapper) {
private val client = factory.create(serverInteractor.get()!!) private val client = factory.create(serverInteractor.get()!!)
...@@ -46,6 +48,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView, ...@@ -46,6 +48,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
val messages = client.messages(chatRoomId, roomTypeOf(chatRoomType), offset, 30).result val messages = client.messages(chatRoomId, roomTypeOf(chatRoomType), offset, 30).result
synchronized(roomMessages) { synchronized(roomMessages) {
roomMessages.addAll(messages) roomMessages.addAll(messages)
messagesRepository.saveAll(messages)
} }
val messagesViewModels = mapper.mapToViewModelList(messages, settings) val messagesViewModels = mapper.mapToViewModelList(messages, settings)
......
package chat.rocket.android.chatroom.ui package chat.rocket.android.chatroom.ui
import android.support.v7.widget.RecyclerView import android.support.v7.widget.RecyclerView
import android.text.method.LinkMovementMethod
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
...@@ -9,8 +10,10 @@ import chat.rocket.android.R ...@@ -9,8 +10,10 @@ import chat.rocket.android.R
import chat.rocket.android.chatroom.viewmodel.AttachmentType import chat.rocket.android.chatroom.viewmodel.AttachmentType
import chat.rocket.android.chatroom.viewmodel.MessageViewModel import chat.rocket.android.chatroom.viewmodel.MessageViewModel
import chat.rocket.android.player.PlayerActivity import chat.rocket.android.player.PlayerActivity
import chat.rocket.android.util.content
import chat.rocket.android.util.inflate import chat.rocket.android.util.inflate
import chat.rocket.android.util.setVisible import chat.rocket.android.util.setVisible
import chat.rocket.android.util.textContent
import chat.rocket.common.util.ifNull import chat.rocket.common.util.ifNull
import com.facebook.drawee.view.SimpleDraweeView import com.facebook.drawee.view.SimpleDraweeView
import com.stfalcon.frescoimageviewer.ImageViewer import com.stfalcon.frescoimageviewer.ImageViewer
...@@ -63,9 +66,10 @@ class ChatRoomAdapter(private val serverUrl: String) : RecyclerView.Adapter<Chat ...@@ -63,9 +66,10 @@ class ChatRoomAdapter(private val serverUrl: String) : RecyclerView.Adapter<Chat
fun bind(message: MessageViewModel) = with(itemView) { fun bind(message: MessageViewModel) = with(itemView) {
bindUserAvatar(message, image_avatar, image_unknown_avatar) bindUserAvatar(message, image_avatar, image_unknown_avatar)
text_user_name.text = message.sender text_user_name.content = message.sender
text_message_time.text = message.time text_message_time.content = message.time
text_content.text = message.content text_content.content = message.content
text_content.movementMethod = LinkMovementMethod()
bindAttachment(message, message_attachment, image_attachment, audio_video_attachment, bindAttachment(message, message_attachment, image_attachment, audio_video_attachment,
file_name) file_name)
......
...@@ -8,7 +8,9 @@ import android.text.SpannableString ...@@ -8,7 +8,9 @@ import android.text.SpannableString
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import chat.rocket.android.R import chat.rocket.android.R
import chat.rocket.android.helper.MessageParser
import chat.rocket.android.helper.UrlHelper import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.server.domain.MessagesRepository
import chat.rocket.android.server.domain.SITE_URL import chat.rocket.android.server.domain.SITE_URL
import chat.rocket.android.server.domain.USE_REALNAME import chat.rocket.android.server.domain.USE_REALNAME
import chat.rocket.common.model.Token import chat.rocket.common.model.Token
...@@ -19,29 +21,50 @@ import chat.rocket.core.model.attachment.AudioAttachment ...@@ -19,29 +21,50 @@ import chat.rocket.core.model.attachment.AudioAttachment
import chat.rocket.core.model.attachment.FileAttachment import chat.rocket.core.model.attachment.FileAttachment
import chat.rocket.core.model.attachment.ImageAttachment import chat.rocket.core.model.attachment.ImageAttachment
import chat.rocket.core.model.attachment.VideoAttachment import chat.rocket.core.model.attachment.VideoAttachment
import chat.rocket.core.model.url.Url
import okhttp3.HttpUrl import okhttp3.HttpUrl
import timber.log.Timber
data class MessageViewModel(val context: Context, data class MessageViewModel(val context: Context,
private val token: Token?, private val token: Token?,
private val message: Message, private val message: Message,
private val settings: Map<String, Value<Any>>?) { private val settings: Map<String, Value<Any>>?,
private val parser: MessageParser,
private val messagesRepository: MessagesRepository) {
val id: String = message.id val id: String = message.id
val time: CharSequence val time: CharSequence
val sender: CharSequence val sender: CharSequence
val content: CharSequence val content: CharSequence
var quote: Message? = null
var urlsWithMeta = arrayListOf<Url>()
var attachmentUrl: String? = null var attachmentUrl: String? = null
var attachmentTitle: CharSequence? = null var attachmentTitle: CharSequence? = null
var attachmentType: AttachmentType? = null var attachmentType: AttachmentType? = null
init { init {
sender = getSenderName() sender = getSenderName()
content = getContent(context)
time = getTime() time = getTime()
val baseUrl = settings?.get(SITE_URL)
message.urls?.let {
if (it.isEmpty()) return@let
for (url in it) {
if (url.meta != null) {
urlsWithMeta.add(url)
}
baseUrl?.let {
val quoteUrl = HttpUrl.parse(url.url)
val serverUrl = HttpUrl.parse(baseUrl.value.toString())
if (quoteUrl != null && serverUrl != null) {
makeQuote(quoteUrl, serverUrl)
}
}
}
}
message.attachments?.let { message.attachments?.let {
if (it.isEmpty() || it[0] == null) return@let if (it.isEmpty() || it[0] == null) return@let
val attachment = it[0] as FileAttachment val attachment = it[0] as FileAttachment
val baseUrl = settings?.get(SITE_URL)
baseUrl?.let { baseUrl?.let {
attachmentUrl = attachmentUrl("${baseUrl.value}${attachment.url}") attachmentUrl = attachmentUrl("${baseUrl.value}${attachment.url}")
attachmentTitle = attachment.title attachmentTitle = attachment.title
...@@ -54,6 +77,18 @@ data class MessageViewModel(val context: Context, ...@@ -54,6 +77,18 @@ data class MessageViewModel(val context: Context,
} }
} }
} }
content = getContent(context)
}
private fun makeQuote(quoteUrl: HttpUrl, serverUrl: HttpUrl) {
if (quoteUrl.host() == serverUrl.host()) {
val msgIdToQuote = quoteUrl.queryParameter("msg")
Timber.d("Will quote message Id: $msgIdToQuote")
if (msgIdToQuote != null) {
quote = messagesRepository.getById(msgIdToQuote)
}
}
} }
fun getAvatarUrl(serverUrl: String): String? { fun getAvatarUrl(serverUrl: String): String? {
...@@ -62,9 +97,11 @@ data class MessageViewModel(val context: Context, ...@@ -62,9 +97,11 @@ data class MessageViewModel(val context: Context,
} }
} }
fun getTime() = DateTimeHelper.getTime(DateTimeHelper.getLocalDateTime(message.timestamp)) fun getOriginalMessage() = message.message
private fun getTime() = DateTimeHelper.getTime(DateTimeHelper.getLocalDateTime(message.timestamp))
fun getSenderName(): CharSequence { private fun getSenderName(): CharSequence {
val useRealName = settings?.get(USE_REALNAME)?.value as Boolean val useRealName = settings?.get(USE_REALNAME)?.value as Boolean
val username = message.sender?.username val username = message.sender?.username
val realName = message.sender?.name val realName = message.sender?.name
...@@ -72,7 +109,7 @@ data class MessageViewModel(val context: Context, ...@@ -72,7 +109,7 @@ data class MessageViewModel(val context: Context,
return senderName ?: username.toString() return senderName ?: username.toString()
} }
fun getContent(context: Context): CharSequence { private fun getContent(context: Context): CharSequence {
val contentMessage: CharSequence val contentMessage: CharSequence
when (message.type) { when (message.type) {
//TODO: Add implementation for Welcome type. //TODO: Add implementation for Welcome type.
...@@ -90,7 +127,14 @@ data class MessageViewModel(val context: Context, ...@@ -90,7 +127,14 @@ data class MessageViewModel(val context: Context,
return contentMessage return contentMessage
} }
private fun getNormalMessage() = message.message private fun getNormalMessage(): CharSequence {
var quoteViewModel: MessageViewModel? = null
if (quote != null) {
val quoteMessage: Message = quote!!
quoteViewModel = MessageViewModel(context, token, quoteMessage, settings, parser, messagesRepository)
}
return parser.renderMarkdown(message.message, quoteViewModel, urlsWithMeta)
}
private fun getSystemMessage(content: String): CharSequence { private fun getSystemMessage(content: String): CharSequence {
val spannableMsg = SpannableString(content) val spannableMsg = SpannableString(content)
......
package chat.rocket.android.chatroom.viewmodel package chat.rocket.android.chatroom.viewmodel
import android.content.Context import android.content.Context
import chat.rocket.android.helper.MessageParser
import chat.rocket.android.server.domain.MessagesRepository
import chat.rocket.core.TokenRepository import chat.rocket.core.TokenRepository
import chat.rocket.core.model.Message import chat.rocket.core.model.Message
import chat.rocket.core.model.Value import chat.rocket.core.model.Value
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.withContext
import javax.inject.Inject import javax.inject.Inject
class MessageViewModelMapper @Inject constructor(private val context: Context, private val tokenRepository: TokenRepository) { class MessageViewModelMapper @Inject constructor(private val context: Context,
private val tokenRepository: TokenRepository,
private val messageParser: MessageParser,
private val messagesRepository: MessagesRepository) {
suspend fun mapToViewModel(message: Message, settings: Map<String, Value<Any>>?) = MessageViewModel(context, tokenRepository.get(), message, settings) suspend fun mapToViewModel(message: Message, settings: Map<String, Value<Any>>?): MessageViewModel = withContext(CommonPool) {
MessageViewModel(
this@MessageViewModelMapper.context,
tokenRepository.get(),
message,
settings,
messageParser,
messagesRepository
)
}
suspend fun mapToViewModelList(messageList: List<Message>, settings: Map<String, Value<Any>>?): List<MessageViewModel> { suspend fun mapToViewModelList(messageList: List<Message>, settings: Map<String, Value<Any>>?): List<MessageViewModel> {
return messageList.map { MessageViewModel(context, tokenRepository.get(), it, settings) } return messageList.map { MessageViewModel(context, tokenRepository.get(), it, settings, messageParser, messagesRepository) }
} }
} }
\ No newline at end of file
...@@ -5,22 +5,18 @@ import android.arch.persistence.room.Room ...@@ -5,22 +5,18 @@ import android.arch.persistence.room.Room
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import chat.rocket.android.BuildConfig import chat.rocket.android.BuildConfig
import chat.rocket.android.R
import chat.rocket.android.app.RocketChatDatabase import chat.rocket.android.app.RocketChatDatabase
import chat.rocket.android.app.utils.CustomImageFormatConfigurator import chat.rocket.android.app.utils.CustomImageFormatConfigurator
import chat.rocket.android.authentication.infraestructure.MemoryTokenRepository import chat.rocket.android.authentication.infraestructure.MemoryTokenRepository
import chat.rocket.android.authentication.infraestructure.SharedPreferencesMultiServerTokenRepository import chat.rocket.android.authentication.infraestructure.SharedPreferencesMultiServerTokenRepository
import chat.rocket.android.dagger.qualifier.ForFresco import chat.rocket.android.dagger.qualifier.ForFresco
import chat.rocket.android.helper.FrescoAuthInterceptor import chat.rocket.android.helper.FrescoAuthInterceptor
import chat.rocket.android.helper.MessageParser
import chat.rocket.android.infrastructure.LocalRepository import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.infrastructure.SharedPrefsLocalRepository import chat.rocket.android.infrastructure.SharedPrefsLocalRepository
import chat.rocket.android.server.domain.ChatRoomsRepository import chat.rocket.android.server.domain.*
import chat.rocket.android.server.domain.CurrentServerRepository import chat.rocket.android.server.infraestructure.*
import chat.rocket.android.server.domain.MultiServerTokenRepository
import chat.rocket.android.server.domain.SettingsRepository
import chat.rocket.android.server.infraestructure.MemoryChatRoomsRepository
import chat.rocket.android.server.infraestructure.ServerDao
import chat.rocket.android.server.infraestructure.SharedPreferencesSettingsRepository
import chat.rocket.android.server.infraestructure.SharedPrefsCurrentServerRepository
import chat.rocket.android.util.AppJsonAdapterFactory import chat.rocket.android.util.AppJsonAdapterFactory
import chat.rocket.android.util.TimberLogger import chat.rocket.android.util.TimberLogger
import chat.rocket.common.util.PlatformLogger import chat.rocket.common.util.PlatformLogger
...@@ -38,7 +34,11 @@ import kotlinx.coroutines.experimental.Job ...@@ -38,7 +34,11 @@ import kotlinx.coroutines.experimental.Job
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import ru.noties.markwon.SpannableConfiguration
import ru.noties.markwon.il.AsyncDrawableLoader
import ru.noties.markwon.spans.SpannableTheme
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.Executors
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
...@@ -196,4 +196,32 @@ class AppModule { ...@@ -196,4 +196,32 @@ class AppModule {
fun provideMultiServerTokenRepository(repository: LocalRepository, moshi: Moshi): MultiServerTokenRepository { fun provideMultiServerTokenRepository(repository: LocalRepository, moshi: Moshi): MultiServerTokenRepository {
return SharedPreferencesMultiServerTokenRepository(repository, moshi) return SharedPreferencesMultiServerTokenRepository(repository, moshi)
} }
@Provides
@Singleton
fun provideMessageRepository(): MessagesRepository {
return MemoryMessagesRepository()
}
@Provides
@Singleton
fun provideConfiguration(context: Application, client: OkHttpClient): SpannableConfiguration {
val res = context.resources
return SpannableConfiguration.builder(context)
.asyncDrawableLoader(AsyncDrawableLoader.builder()
.client(client)
.executorService(Executors.newCachedThreadPool())
.resources(res)
.build())
.theme(SpannableTheme.builder()
.linkColor(res.getColor(R.color.colorAccent))
.build())
.build()
}
@Provides
@Singleton
fun provideMessageParser(context: Application, configuration: SpannableConfiguration): MessageParser {
return MessageParser(context, configuration)
}
} }
\ No newline at end of file
package chat.rocket.android.helper
import android.app.Application
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.text.Layout
import android.text.Spannable
import android.text.Spanned
import android.text.TextPaint
import android.text.style.*
import android.view.View
import chat.rocket.android.R
import chat.rocket.android.chatroom.viewmodel.MessageViewModel
import chat.rocket.core.model.url.Url
import org.commonmark.node.BlockQuote
import ru.noties.markwon.Markwon
import ru.noties.markwon.SpannableBuilder
import ru.noties.markwon.SpannableConfiguration
import ru.noties.markwon.renderer.SpannableMarkdownVisitor
import java.util.regex.Pattern
import javax.inject.Inject
class MessageParser @Inject constructor(val context: Application, private val configuration: SpannableConfiguration) {
private val parser = Markwon.createParser()
private val userPattern = Pattern.compile("(@[\\w.]+)",
Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
/**
* Render a markdown text message to Spannable.
*
* @param text The text message containing markdown syntax.
* @param quote An optional message to be quoted either by a quote or reply action.
* @param urls A list of urls to convert to markdown link syntax.
*
* @return A Spannable with the parsed markdown.
*/
fun renderMarkdown(text: String, quote: MessageViewModel?, urls: List<Url>): CharSequence {
val builder = SpannableBuilder()
var content: String = text
// Replace all url links to markdown url syntax.
for (url in urls) {
content = content.replace(url.url, "[${url.url}](${url.url})")
}
val parentNode = parser.parse(toLenientMarkdown(content))
parentNode.accept(SpannableMarkdownVisitor(configuration, builder))
quote?.apply {
var quoteNode = parser.parse("> $sender $time")
parentNode.appendChild(quoteNode)
quoteNode.accept(QuoteMessageSenderVisitor(context, configuration, builder, sender.length))
quoteNode = parser.parse("> ${toLenientMarkdown(quote.getOriginalMessage())}")
quoteNode.accept(QuoteMessageBodyVisitor(context, configuration, builder))
}
val result = builder.text()
applySpans(result)
return result
}
private fun applySpans(text: CharSequence) {
val matcher = userPattern.matcher(text)
val result = text as Spannable
while (matcher.find()) {
val user = matcher.group(1)
val start = matcher.start()
//TODO: should check if username actually exists prior to applying.
result.setSpan(UsernameClickableSpan(), start, start + user.length, 0)
}
}
/**
* Convert to a lenient markdown consistent with Rocket.Chat web markdown instead of the official specs.
*/
private fun toLenientMarkdown(text: String): String {
return text.trim().replace("\\*(.+)\\*".toRegex()) { "**${it.groupValues[1].trim()}**" }
.replace("\\~(.+)\\~".toRegex()) { "~~${it.groupValues[1].trim()}~~" }
.replace("\\_(.+)\\_".toRegex()) { "_${it.groupValues[1].trim()}_" }
}
class QuoteMessageSenderVisitor(private val context: Context,
configuration: SpannableConfiguration,
private val builder: SpannableBuilder,
private val senderNameLength: Int) : SpannableMarkdownVisitor(configuration, builder) {
override fun visit(blockQuote: BlockQuote) {
// mark current length
val length = builder.length()
// pass to super to apply markdown
super.visit(blockQuote)
val res = context.resources
val timeOffsetStart = length + senderNameLength + 1
builder.setSpan(QuoteMarginSpan(context.getDrawable(R.drawable.quote), 10), length, builder.length())
builder.setSpan(StyleSpan(Typeface.BOLD), length, length + senderNameLength)
builder.setSpan(ForegroundColorSpan(Color.BLACK), length, builder.length())
// set time spans
builder.setSpan(AbsoluteSizeSpan(res.getDimensionPixelSize(R.dimen.message_time_text_size)),
timeOffsetStart, builder.length())
builder.setSpan(ForegroundColorSpan(res.getColor(R.color.darkGray)),
timeOffsetStart, builder.length())
}
}
class QuoteMessageBodyVisitor(private val context: Context,
configuration: SpannableConfiguration,
private val builder: SpannableBuilder) : SpannableMarkdownVisitor(configuration, builder) {
override fun visit(blockQuote: BlockQuote) {
// mark current length
val length = builder.length()
// pass to super to apply markdown
super.visit(blockQuote)
builder.setSpan(QuoteMarginSpan(context.getDrawable(R.drawable.quote), 10), length, builder.length())
}
}
class QuoteMarginSpan(quoteDrawable: Drawable, private var pad: Int) : LeadingMarginSpan, LineHeightSpan {
private val drawable: Drawable = quoteDrawable
override fun getLeadingMargin(first: Boolean): Int {
return drawable.intrinsicWidth + pad
}
override fun drawLeadingMargin(c: Canvas, p: Paint, x: Int, dir: Int,
top: Int, baseline: Int, bottom: Int,
text: CharSequence, start: Int, end: Int,
first: Boolean, layout: Layout) {
val st = (text as Spanned).getSpanStart(this)
val ix = x
val itop = layout.getLineTop(layout.getLineForOffset(st))
val dw = drawable.intrinsicWidth
val dh = drawable.intrinsicHeight
// XXX What to do about Paint?
drawable.setBounds(ix, itop, ix + dw, itop + layout.height)
drawable.draw(c)
}
override fun chooseHeight(text: CharSequence, start: Int, end: Int,
spanstartv: Int, v: Int,
fm: Paint.FontMetricsInt) {
if (end == (text as Spanned).getSpanEnd(this)) {
val ht = drawable.intrinsicHeight
var need = ht - (v + fm.descent - fm.ascent - spanstartv)
if (need > 0)
fm.descent += need
need = ht - (v + fm.bottom - fm.top - spanstartv)
if (need > 0)
fm.bottom += need
}
}
}
class UsernameClickableSpan : ClickableSpan() {
override fun onClick(widget: View) {
//TODO: Implement action when clicking on username, like showing user profile.
}
override fun updateDrawState(ds: TextPaint) {
ds.color = ds.linkColor
ds.isUnderlineText = false
}
}
}
\ No newline at end of file
package chat.rocket.android.server.domain
import chat.rocket.core.model.Message
interface MessagesRepository {
/**
* Get message by its message id.
*
* @param id The id of the message to get.
* @return The Message object given by the id or null if message wasn't found.
*/
fun getById(id: String): Message?
/**
* Get all messages from the current room id.
*
* @param rid The room id.
* @return A list of Message objects for the room with given room id or an empty list.
*/
fun getByRoomId(rid: String): List<Message>
/**
* Get all messages. Use carefully!
* @return All messages or an empty list.
*/
fun getAll(): List<Message>
/**
* Save a single message object.
*
* @param The message object to saveAll.
*/
fun save(message: Message)
/**
* Save a list of messages.
*/
fun saveAll(newMessages: List<Message>)
/**
* Removes all messages.
*/
fun clear()
/**
* Remove message by id.
*
* @param id The id of the message to remove.
*/
fun removeById(id: String)
/**
* Remove all messages from a given room.
*
* @param rid The room id where messages are to be removed.
*/
fun removeByRoomId(rid: String)
}
\ No newline at end of file
package chat.rocket.android.server.infraestructure
import chat.rocket.android.server.domain.MessagesRepository
import chat.rocket.core.model.Message
class MemoryMessagesRepository : MessagesRepository {
private val messages: HashMap<String, Message> = HashMap()
override fun getById(id: String): Message? {
return messages[id]
}
override fun getByRoomId(rid: String): List<Message> {
return messages.filter { it.value.roomId == rid }.values.toList()
}
override fun getAll(): List<Message> = messages.values.toList()
override fun save(message: Message) {
messages[message.id] = message
}
override fun saveAll(newMessages: List<Message>) {
for (msg in newMessages) {
messages[msg.id] = msg
}
}
override fun clear() {
messages.clear()
}
override fun removeById(id: String) {
messages.remove(id)
}
override fun removeByRoomId(rid: String) {
val roomMessages = messages.filter { it.value.roomId == rid }.values
roomMessages.forEach {
messages.remove(it.roomId)
}
}
}
\ No newline at end of file
...@@ -6,6 +6,7 @@ import android.view.View ...@@ -6,6 +6,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.EditText import android.widget.EditText
import android.widget.TextView import android.widget.TextView
import ru.noties.markwon.Markwon
import com.jakewharton.rxbinding2.widget.RxTextView import com.jakewharton.rxbinding2.widget.RxTextView
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
...@@ -18,6 +19,13 @@ fun String.ifEmpty(value: String): String { ...@@ -18,6 +19,13 @@ fun String.ifEmpty(value: String): String {
return this return this
} }
fun CharSequence.ifEmpty(value: String): CharSequence {
if (isEmpty()) {
return value
}
return this
}
fun View.setVisible(visible: Boolean) { fun View.setVisible(visible: Boolean) {
visibility = if (visible) { visibility = if (visible) {
View.VISIBLE View.VISIBLE
...@@ -36,6 +44,16 @@ var TextView.textContent: String ...@@ -36,6 +44,16 @@ var TextView.textContent: String
text = value text = value
} }
var TextView.content: CharSequence
get() = text
set(value) {
Markwon.unscheduleDrawables(this)
Markwon.unscheduleTableRows(this)
text = value
Markwon.scheduleDrawables(this)
Markwon.scheduleTableRows(this)
}
var TextView.hintContent: String var TextView.hintContent: String
get() = hint.toString() get() = hint.toString()
set(value) { set(value) {
......
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/darkGray" />
<corners android:radius="2dp" />
<size
android:width="4dp"
android:height="4dp" />
</shape>
\ No newline at end of file
...@@ -17,5 +17,6 @@ ...@@ -17,5 +17,6 @@
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="darkGray">#a0a0a0</color>
</resources> </resources>
\ No newline at end of file
...@@ -9,4 +9,6 @@ ...@@ -9,4 +9,6 @@
<dimen name="fab_elevation">6dp</dimen> <dimen name="fab_elevation">6dp</dimen>
<dimen name="message_time_text_size">12sp</dimen>
</resources> </resources>
\ No newline at end of file
package chat.rocket.android;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
\ No newline at end of file
package chat.rocket.android
import chat.rocket.android.server.infraestructure.MemoryMessagesRepository
import chat.rocket.core.model.Message
import chat.rocket.core.model.MessageType
import org.hamcrest.CoreMatchers.notNullValue
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Before
import org.junit.Test
import org.hamcrest.CoreMatchers.`is` as isEqualTo
class MemoryMessagesRepositoryTest {
val repository = MemoryMessagesRepository()
val msg = Message(
id = "messageId",
roomId = "GENERAL",
message = "Beam me up, Scotty.",
timestamp = 1511443964815,
attachments = null,
sender = null,
avatar = null,
channels = null,
editedAt = null,
editedBy = null,
groupable = true,
mentions = null,
parseUrls = false,
senderAlias = null,
type = MessageType.MessageRemoved(),
updatedAt = 1511443964815,
urls = null
)
val msg2 = Message(
id = "messageId2",
roomId = "sandbox",
message = "Highly Illogical",
timestamp = 1511443964818,
attachments = null,
sender = null,
avatar = null,
channels = null,
editedAt = null,
editedBy = null,
groupable = true,
mentions = null,
parseUrls = false,
senderAlias = null,
type = MessageType.MessageRemoved(),
updatedAt = 1511443964818,
urls = null
)
@Before
fun setup() {
repository.clear()
}
@Test
fun `save() should save a single message`() {
assertThat(repository.getAll().size, isEqualTo(0))
repository.save(msg)
val allMessages = repository.getAll()
assertThat(allMessages.size, isEqualTo(1))
allMessages[0].apply {
assertThat(id, isEqualTo("messageId"))
assertThat(message, isEqualTo("Beam me up, Scotty."))
assertThat(roomId, isEqualTo("GENERAL"))
}
}
@Test
fun `saveAll() should all saved messages`() {
assertThat(repository.getAll().size, isEqualTo(0))
repository.saveAll(listOf(msg, msg2))
val allMessages = repository.getAll()
assertThat(allMessages.size, isEqualTo(2))
allMessages[0].apply {
assertThat(id, isEqualTo("messageId"))
assertThat(message, isEqualTo("Beam me up, Scotty."))
assertThat(roomId, isEqualTo("GENERAL"))
}
allMessages[1].apply {
assertThat(id, isEqualTo("messageId2"))
assertThat(message, isEqualTo("Highly Illogical"))
assertThat(roomId, isEqualTo("sandbox"))
}
}
@Test
fun `getById() should return a single message`() {
repository.saveAll(listOf(msg, msg2))
var singleMsg = repository.getById("messageId")
assertThat(singleMsg, notNullValue())
singleMsg!!.apply {
assertThat(id, isEqualTo("messageId"))
assertThat(message, isEqualTo("Beam me up, Scotty."))
assertThat(roomId, isEqualTo("GENERAL"))
}
singleMsg = repository.getById("messageId2")
assertThat(singleMsg, notNullValue())
singleMsg!!.apply {
assertThat(id, isEqualTo("messageId2"))
assertThat(message, isEqualTo("Highly Illogical"))
assertThat(roomId, isEqualTo("sandbox"))
}
}
@Test
fun `getByRoomId() should return all messages for room id or an empty list`() {
repository.saveAll(listOf(msg, msg2))
var roomMessages = repository.getByRoomId("faAad32fkasods2")
assertThat(roomMessages.isEmpty(), isEqualTo(true))
roomMessages = repository.getByRoomId("sandbox")
assertThat(roomMessages.size, isEqualTo(1))
roomMessages[0].apply {
assertThat(id, isEqualTo("messageId2"))
assertThat(message, isEqualTo("Highly Illogical"))
assertThat(roomId, isEqualTo("sandbox"))
}
roomMessages = repository.getByRoomId("GENERAL")
assertThat(roomMessages.size, isEqualTo(1))
roomMessages[0].apply {
assertThat(id, isEqualTo("messageId"))
assertThat(message, isEqualTo("Beam me up, Scotty."))
assertThat(roomId, isEqualTo("GENERAL"))
}
}
}
\ No newline at end of file
...@@ -27,6 +27,7 @@ ext { ...@@ -27,6 +27,7 @@ ext {
frescoImageViewer : '0.5.0', frescoImageViewer : '0.5.0',
androidSvg : '1.2.1', androidSvg : '1.2.1',
aVLoadingIndicatorView : '2.1.3', aVLoadingIndicatorView : '2.1.3',
markwon : '1.0.3',
textDrawable : '1.0.2', // Remove this library after https://github.com/RocketChat/Rocket.Chat/pull/9492 is merged textDrawable : '1.0.2', // Remove this library after https://github.com/RocketChat/Rocket.Chat/pull/9492 is merged
moshiLazyAdapters : '2.1', // Even declared on the SDK we need to add this library here because java.lang.NoClassDefFoundError: Failed resolution of: Lcom/serjltt/moshi/adapters/FallbackEnum; moshiLazyAdapters : '2.1', // Even declared on the SDK we need to add this library here because java.lang.NoClassDefFoundError: Failed resolution of: Lcom/serjltt/moshi/adapters/FallbackEnum;
...@@ -89,6 +90,9 @@ ext { ...@@ -89,6 +90,9 @@ ext {
textDrawable : "com.github.rocketchat:textdrawable:${versions.textDrawable}", textDrawable : "com.github.rocketchat:textdrawable:${versions.textDrawable}",
markwon : "ru.noties:markwon:${versions.markwon}",
markwonImageLoader : "ru.noties:markwon-image-loader:${versions.markwon}",
moshiLazyAdapters : "com.serjltt.moshi:moshi-lazy-adapters:${versions.moshiLazyAdapters}", moshiLazyAdapters : "com.serjltt.moshi:moshi-lazy-adapters:${versions.moshiLazyAdapters}",
// For testing // For testing
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment