Commit cc24c585 authored by Lucio Maciel's avatar Lucio Maciel

Merge branch 'develop' into feature/db-save-messages

parents 76b6fe16 123d05d2
......@@ -16,8 +16,8 @@ android {
applicationId "chat.rocket.android"
minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk
versionCode 2042
versionName "2.6.0"
versionCode 2043
versionName "2.6.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled true
......
package chat.rocket.android.chatroom.adapter
import android.annotation.SuppressLint
import android.text.SpannableStringBuilder
import android.text.style.ImageSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import chat.rocket.android.R
import chat.rocket.android.chatroom.adapter.EmojiSuggestionsAdapter.EmojiSuggestionViewHolder
import chat.rocket.android.chatroom.uimodel.suggestion.EmojiSuggestionUiModel
import chat.rocket.android.emoji.EmojiParser
import chat.rocket.android.emoji.internal.isCustom
import chat.rocket.android.suggestions.model.SuggestionModel
import chat.rocket.android.suggestions.strategy.trie.TrieCompletionStrategy
import chat.rocket.android.suggestions.ui.BaseSuggestionViewHolder
import chat.rocket.android.suggestions.ui.SuggestionsAdapter
import com.bumptech.glide.Glide
import kotlinx.android.synthetic.main.suggestion_emoji_item.view.*
class EmojiSuggestionsAdapter : SuggestionsAdapter<EmojiSuggestionViewHolder>(
token = ":",
completionStrategy = TrieCompletionStrategy()
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiSuggestionViewHolder {
val inflater = LayoutInflater.from(parent.context)
val view = inflater.inflate(R.layout.suggestion_emoji_item, parent,false)
return EmojiSuggestionViewHolder(view)
}
class EmojiSuggestionViewHolder(view: View) : BaseSuggestionViewHolder(view) {
@SuppressLint("SetTextI18n")
override fun bind(item: SuggestionModel, itemClickListener: SuggestionsAdapter.ItemClickListener?) {
item as EmojiSuggestionUiModel
with(itemView) {
text_emoji_shortname.text = ":${item.text}"
if (item.emoji.isCustom()) {
view_flipper_emoji.displayedChild = 1
val sp = SpannableStringBuilder().append(item.emoji.shortname)
Glide.with(context).asDrawable().load(item.emoji.url).into(image_emoji)
} else {
text_emoji.text = EmojiParser.parse(context, item.emoji.unicode)
view_flipper_emoji.displayedChild = 0
}
setOnClickListener {
itemClickListener?.onClick(item)
}
}
}
}
}
package chat.rocket.android.chatroom.adapter
import android.animation.ValueAnimator
import android.text.method.LinkMovementMethod
import android.view.View
import android.view.animation.LinearInterpolator
import androidx.core.view.isVisible
import chat.rocket.android.R
import chat.rocket.android.chatroom.uimodel.MessageAttachmentUiModel
import chat.rocket.android.emoji.EmojiReactionListener
import kotlinx.android.synthetic.main.item_message_attachment.view.*
class MessageAttachmentViewHolder(
itemView: View,
listener: ActionsListener,
reactionListener: EmojiReactionListener? = null
itemView: View,
listener: ActionsListener,
reactionListener: EmojiReactionListener? = null
) : BaseViewHolder<MessageAttachmentUiModel>(itemView, listener, reactionListener) {
private var expanded = true
init {
with(itemView) {
setupActionMenu(attachment_container)
......@@ -21,9 +27,77 @@ class MessageAttachmentViewHolder(
override fun bindViews(data: MessageAttachmentUiModel) {
with(itemView) {
val collapsedHeight = context.resources.getDimensionPixelSize(R.dimen.quote_collapsed_height)
val viewMore = context.getString(R.string.msg_view_more)
val viewLess = context.getString(R.string.msg_view_less)
text_message_time.text = data.time
text_sender.text = data.senderName
text_content.text = data.content
text_view_more.text = viewLess
text_content.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
override fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int,
oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
val textMeasuredHeight = bottom - top
if (collapsedHeight >= textMeasuredHeight) {
text_view_more.isVisible = false
text_content.removeOnLayoutChangeListener(this)
return
}
text_view_more.isVisible = true
val expandAnimation = ValueAnimator
.ofInt(collapsedHeight, textMeasuredHeight)
.setDuration(300)
expandAnimation.interpolator = LinearInterpolator()
val collapseAnimation = ValueAnimator
.ofInt(textMeasuredHeight, collapsedHeight)
.setDuration(300)
collapseAnimation.interpolator = LinearInterpolator()
val lp = text_content.layoutParams
expandAnimation.addUpdateListener {
val value = it.animatedValue as Int
lp.height = value
text_content.layoutParams = lp
expanded = if (value == textMeasuredHeight) {
text_view_more.text = viewLess
true
} else {
text_view_more.text = viewMore
false
}
}
collapseAnimation.addUpdateListener {
val value = it.animatedValue as Int
lp.height = value
text_content.layoutParams = lp
expanded = if (value == textMeasuredHeight) {
text_view_more.text = viewLess
true
} else {
text_view_more.text = viewMore
false
}
}
text_view_more.setOnClickListener {
if (expandAnimation.isRunning) return@setOnClickListener
if (expanded) {
collapseAnimation.start()
} else {
expandAnimation.start()
}
}
text_content.removeOnLayoutChangeListener(this)
}
})
}
}
}
\ No newline at end of file
......@@ -13,9 +13,7 @@ class MessageReplyViewHolder(
) : BaseViewHolder<MessageReplyUiModel>(itemView, listener, reactionListener) {
init {
with(itemView) {
setupActionMenu(itemView)
}
setupActionMenu(itemView)
}
override fun bindViews(data: MessageReplyUiModel) {
......
......@@ -13,10 +13,12 @@ import chat.rocket.android.chatroom.uimodel.RoomUiModel
import chat.rocket.android.chatroom.uimodel.UiModelMapper
import chat.rocket.android.chatroom.uimodel.suggestion.ChatRoomSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.CommandSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.EmojiSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.PeopleSuggestionUiModel
import chat.rocket.android.core.behaviours.showMessage
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.db.DatabaseManager
import chat.rocket.android.emoji.EmojiRepository
import chat.rocket.android.helper.MessageHelper
import chat.rocket.android.helper.UserHelper
import chat.rocket.android.infrastructure.LocalRepository
......@@ -34,7 +36,6 @@ import chat.rocket.android.server.domain.useRealName
import chat.rocket.android.server.infraestructure.ConnectionManagerFactory
import chat.rocket.android.server.infraestructure.state
import chat.rocket.android.util.extension.compressImageAndGetByteArray
import chat.rocket.android.util.extension.compressImageAndGetInputStream
import chat.rocket.android.util.extension.launchUI
import chat.rocket.android.util.extensions.avatarUrl
import chat.rocket.android.util.retryIO
......@@ -80,9 +81,7 @@ import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.withContext
import org.threeten.bp.Instant
import timber.log.Timber
import java.io.InputStream
import java.util.*
import java.util.zip.DeflaterInputStream
import javax.inject.Inject
class ChatRoomPresenter @Inject constructor(
......@@ -180,10 +179,10 @@ class ChatRoomPresenter @Inject constructor(
val localMessages = messagesRepository.getByRoomId(chatRoomId)
val oldMessages = mapper.map(
localMessages, RoomUiModel(
roles = chatRoles,
// FIXME: Why are we fixing isRoom attribute to true here?
isBroadcast = chatIsBroadcast, isRoom = true
)
roles = chatRoles,
// FIXME: Why are we fixing isRoom attribute to true here?
isBroadcast = chatIsBroadcast, isRoom = true
)
)
val lastSyncDate = messagesRepository.getLastSyncDate(chatRoomId)
if (oldMessages.isNotEmpty() && lastSyncDate != null) {
......@@ -420,11 +419,12 @@ class ChatRoomPresenter @Inject constructor(
}
}
}
} catch (ex: Exception) {
Timber.d(ex, "Error uploading file")
when (ex) {
is RocketChatException -> view.showMessage(ex)
else -> view.showGenericErrorMessage()
} catch (ex: RocketChatException) {
Timber.d(ex)
ex.message?.let {
view.showMessage(it)
}.ifNull {
view.showGenericErrorMessage()
}
} finally {
view.hideLoading()
......@@ -600,9 +600,9 @@ class ChatRoomPresenter @Inject constructor(
replyMarkdown = "[ ]($currentServer/$chatRoomType/$room?msg=$id) $mention ",
quotedMessage = mapper.map(
message, RoomUiModel(
roles = chatRoles,
isBroadcast = chatIsBroadcast
)
roles = chatRoles,
isBroadcast = chatIsBroadcast
)
).last().preview?.message ?: ""
)
}
......@@ -871,7 +871,7 @@ class ChatRoomPresenter @Inject constructor(
}
it.chatRoom.name == name || it.chatRoom.fullname == name
}.map {
with (it.chatRoom) {
with(it.chatRoom) {
ChatRoom(
id = id,
subscriptionId = subscriptionId,
......@@ -1011,6 +1011,20 @@ class ChatRoomPresenter @Inject constructor(
}
}
fun loadEmojis() {
launchUI(strategy) {
val emojiSuggestionUiModels = EmojiRepository.getAll().map {
EmojiSuggestionUiModel(
text = it.shortname.replaceFirst(":", ""),
pinned = false,
emoji = it,
searchList = listOf(it.shortname)
)
}
view.populateEmojiSuggestions(emojis = emojiSuggestionUiModels)
}
}
fun runCommand(text: String, roomId: String) {
launchUI(strategy) {
try {
......@@ -1106,8 +1120,8 @@ class ChatRoomPresenter @Inject constructor(
launchUI(strategy) {
val viewModelStreamedMessage = mapper.map(
streamedMessage, RoomUiModel(
roles = chatRoles, isBroadcast = chatIsBroadcast, isRoom = true
)
roles = chatRoles, isBroadcast = chatIsBroadcast, isRoom = true
)
)
val roomMessages = messagesRepository.getByRoomId(streamedMessage.roomId)
......@@ -1129,4 +1143,33 @@ class ChatRoomPresenter @Inject constructor(
navigator.toMessageInformation(messageId = messageId)
}
}
}
\ No newline at end of file
/**
* Save unfinished message, when user left chat room without sending a message. It also clears
* saved message from local repository when unfinishedMessage is blank.
*
* @param chatRoomId Chat room Id.
* @param unfinishedMessage The unfinished message to save.
*/
fun saveUnfinishedMessage(chatRoomId: String, unfinishedMessage: String) {
val key = "${currentServer}_${LocalRepository.UNFINISHED_MSG_KEY}$chatRoomId"
if (unfinishedMessage.isNotBlank()) {
localRepository.save(key, unfinishedMessage)
} else {
localRepository.clear(key)
}
}
/**
* Get unfinished message from local repository, when user left chat room without
* sending a message and now the user is back.
*
* @param chatRoomId Chat room Id.
*
* @return Returns the unfinished message.
*/
fun getUnfinishedMessage(chatRoomId: String): String {
val key = "${currentServer}_${LocalRepository.UNFINISHED_MSG_KEY}$chatRoomId"
return localRepository.get(key) ?: ""
}
}
......@@ -3,6 +3,7 @@ package chat.rocket.android.chatroom.presentation
import chat.rocket.android.chatroom.uimodel.BaseUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.ChatRoomSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.CommandSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.EmojiSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.PeopleSuggestionUiModel
import chat.rocket.android.core.behaviours.LoadingView
import chat.rocket.android.core.behaviours.MessageView
......@@ -127,6 +128,9 @@ interface ChatRoomView : LoadingView, MessageView {
fun populatePeopleSuggestions(members: List<PeopleSuggestionUiModel>)
fun populateRoomSuggestions(chatRooms: List<ChatRoomSuggestionUiModel>)
fun populateEmojiSuggestions(emojis: List<EmojiSuggestionUiModel>)
/**
* This user has joined the chat callback.
*
......
......@@ -6,13 +6,11 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.Handler
import android.os.SystemClock
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.ImageSpan
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.Menu
......@@ -37,6 +35,7 @@ import chat.rocket.android.analytics.AnalyticsManager
import chat.rocket.android.analytics.event.ScreenViewEvent
import chat.rocket.android.chatroom.adapter.ChatRoomAdapter
import chat.rocket.android.chatroom.adapter.CommandSuggestionsAdapter
import chat.rocket.android.chatroom.adapter.EmojiSuggestionsAdapter
import chat.rocket.android.chatroom.adapter.PEOPLE
import chat.rocket.android.chatroom.adapter.PeopleSuggestionsAdapter
import chat.rocket.android.chatroom.adapter.RoomSuggestionsAdapter
......@@ -47,6 +46,7 @@ import chat.rocket.android.chatroom.uimodel.BaseUiModel
import chat.rocket.android.chatroom.uimodel.MessageUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.ChatRoomSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.CommandSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.EmojiSuggestionUiModel
import chat.rocket.android.chatroom.uimodel.suggestion.PeopleSuggestionUiModel
import chat.rocket.android.draw.main.ui.DRAWING_BYTE_ARRAY_EXTRA_DATA
import chat.rocket.android.draw.main.ui.DrawingActivity
......@@ -63,7 +63,6 @@ import chat.rocket.android.helper.ImageHelper
import chat.rocket.android.helper.KeyboardHelper
import chat.rocket.android.helper.MessageParser
import chat.rocket.android.util.extension.asObservable
import chat.rocket.android.util.extension.launchUI
import chat.rocket.android.util.extensions.circularRevealOrUnreveal
import chat.rocket.android.util.extensions.fadeIn
import chat.rocket.android.util.extensions.fadeOut
......@@ -76,8 +75,6 @@ import chat.rocket.android.util.extensions.ui
import chat.rocket.common.model.RoomType
import chat.rocket.common.model.roomTypeOf
import chat.rocket.core.internal.realtime.socket.model.State
import chat.rocket.core.model.ChatRoom
import com.bumptech.glide.load.resource.gif.GifDrawable
import dagger.android.support.AndroidSupportInjection
import io.reactivex.Observable
import io.reactivex.disposables.CompositeDisposable
......@@ -86,8 +83,6 @@ import kotlinx.android.synthetic.main.fragment_chat_room.*
import kotlinx.android.synthetic.main.message_attachment_options.*
import kotlinx.android.synthetic.main.message_composer.*
import kotlinx.android.synthetic.main.message_list.*
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.launch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
......@@ -258,6 +253,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
recycler_view.removeOnLayoutChangeListener(layoutChangeListener)
presenter.disconnect()
presenter.saveUnfinishedMessage(chatRoomId, text_message.text.toString())
handler.removeCallbacksAndMessages(null)
unsubscribeComposeTextMessage()
......@@ -476,15 +472,15 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
ui {
when (usernameList.size) {
1 -> text_typing_status.text =
SpannableStringBuilder()
.bold { append(usernameList[0]) }
.append(getString(R.string.msg_is_typing))
SpannableStringBuilder()
.bold { append(usernameList[0]) }
.append(getString(R.string.msg_is_typing))
2 -> text_typing_status.text =
SpannableStringBuilder()
.bold { append(usernameList[0]) }
.append(getString(R.string.msg_and))
.bold { append(usernameList[1]) }
.append(getString(R.string.msg_are_typing))
SpannableStringBuilder()
.bold { append(usernameList[0]) }
.append(getString(R.string.msg_and))
.bold { append(usernameList[1]) }
.append(getString(R.string.msg_are_typing))
else -> text_typing_status.text = getString(R.string.msg_several_users_are_typing)
}
......@@ -562,11 +558,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
}
}
override fun showReplyingAction(
username: String,
replyMarkdown: String,
quotedMessage: String
) {
override fun showReplyingAction(username: String, replyMarkdown: String, quotedMessage: String) {
ui {
citation = replyMarkdown
actionSnackbar.title = username
......@@ -616,6 +608,12 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
}
}
override fun populateEmojiSuggestions(emojis: List<EmojiSuggestionUiModel>) {
ui {
suggestions_view.addItems(":", emojis)
}
}
override fun copyToClipboard(message: String) {
ui {
val clipboard = it.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
......@@ -775,10 +773,6 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
button_show_attachment_options.alpha = 1f
button_show_attachment_options.isVisible = true
activity?.supportFragmentManager?.addOnBackStackChangedListener {
println("attach")
}
activity?.supportFragmentManager?.registerFragmentLifecycleCallbacks(
object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentAttached(
......@@ -796,6 +790,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
)
subscribeComposeTextMessage()
getUnfinishedMessage()
emojiKeyboardPopup = EmojiKeyboardPopup(activity!!, activity!!.findViewById(R.id.fragment_container))
emojiKeyboardPopup.listener = this
text_message.listener = object : ComposerEditText.ComposerEditTextListener {
......@@ -842,16 +837,16 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
}, 400)
}
button_add_reaction.setOnClickListener { view ->
button_add_reaction.setOnClickListener { _ ->
openEmojiKeyboardPopup()
}
button_drawing.setOnClickListener {
activity?.let {
if (!ImageHelper.canWriteToExternalStorage(it)) {
ImageHelper.checkWritingPermission(it)
activity?.let { fragmentActivity ->
if (!ImageHelper.canWriteToExternalStorage(fragmentActivity)) {
ImageHelper.checkWritingPermission(fragmentActivity)
} else {
val intent = Intent(it, DrawingActivity::class.java)
val intent = Intent(fragmentActivity, DrawingActivity::class.java)
startActivityForResult(intent, REQUEST_CODE_FOR_DRAW)
}
}
......@@ -863,12 +858,26 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
}
}
private fun getUnfinishedMessage() {
val unfinishedMessage = presenter.getUnfinishedMessage(chatRoomId)
if (unfinishedMessage.isNotBlank()) {
text_message.setText(unfinishedMessage)
val orientation = resources.configuration.orientation
if (orientation == Configuration.ORIENTATION_PORTRAIT) {
KeyboardHelper.showSoftKeyboard(text_message)
} else {
//TODO show keyboard in full screen mode when landscape orientation
}
}
}
private fun setupSuggestionsView() {
suggestions_view.anchorTo(text_message)
.setMaximumHeight(resources.getDimensionPixelSize(R.dimen.suggestions_box_max_height))
.addTokenAdapter(PeopleSuggestionsAdapter(context!!))
.addTokenAdapter(CommandSuggestionsAdapter())
.addTokenAdapter(RoomSuggestionsAdapter())
.addTokenAdapter(EmojiSuggestionsAdapter())
.addSuggestionProviderAction("@") { query ->
if (query.isNotEmpty()) {
presenter.spotlight(query, PEOPLE, true)
......@@ -879,10 +888,14 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
presenter.loadChatRooms()
}
}
.addSuggestionProviderAction("/") { _ ->
.addSuggestionProviderAction("/") {
presenter.loadCommands()
}
.addSuggestionProviderAction(":") {
presenter.loadEmojis()
}
presenter.loadEmojis()
presenter.loadCommands()
}
......
package chat.rocket.android.chatroom.ui
import org.commonmark.ext.gfm.strikethrough.Strikethrough
import org.commonmark.node.Node
import org.commonmark.node.Text
import org.commonmark.parser.delimiter.DelimiterProcessor
import org.commonmark.parser.delimiter.DelimiterRun
class StrikethroughDelimiterProcessor : DelimiterProcessor {
override fun getOpeningCharacter(): Char {
return '~'
}
override fun getClosingCharacter(): Char {
return '~'
}
override fun getMinLength(): Int {
return 1
}
override fun getDelimiterUse(opener: DelimiterRun, closer: DelimiterRun): Int {
return if (opener.length() >= 2 && closer.length() >= 2) {
// Use exactly two delimiters even if we have more, and don't care about internal openers/closers.
2
} else {
1
}
}
override fun process(opener: Text, closer: Text, delimiterCount: Int) {
// Wrap nodes between delimiters in strikethrough.
val strikethrough = Strikethrough()
var tmp: Node? = opener.next
while (tmp != null && tmp !== closer) {
val next = tmp.next
strikethrough.appendChild(tmp)
tmp = next
}
opener.insertAfter(strikethrough)
}
}
package chat.rocket.android.chatroom.uimodel.suggestion
import chat.rocket.android.emoji.Emoji
import chat.rocket.android.suggestions.model.SuggestionModel
import chat.rocket.common.model.UserStatus
class EmojiSuggestionUiModel(
text: String,
pinned: Boolean = false,
val emoji: Emoji,
searchList: List<String>
) : SuggestionModel(text, searchList, pinned) {
override fun toString(): String {
return "EmojiSuggestionUiModel(text='$text', searchList='$searchList', pinned=$pinned)"
}
}
......@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Context
import android.graphics.Rect
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
......@@ -50,4 +51,4 @@ object KeyboardHelper {
inputMethodManager.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, InputMethodManager.SHOW_IMPLICIT)
}
}
}
\ No newline at end of file
}
......@@ -5,6 +5,7 @@ import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.text.Spannable
import android.text.Spanned
import android.text.style.ClickableSpan
import android.text.style.ImageSpan
......@@ -13,6 +14,7 @@ import android.util.Patterns
import android.view.View
import androidx.core.content.res.ResourcesCompat
import chat.rocket.android.R
import chat.rocket.android.chatroom.ui.StrikethroughDelimiterProcessor
import chat.rocket.android.emoji.EmojiParser
import chat.rocket.android.emoji.EmojiRepository
import chat.rocket.android.emoji.EmojiTypefaceSpan
......@@ -21,16 +23,23 @@ import chat.rocket.android.server.domain.useRealName
import chat.rocket.android.util.extensions.openTabbedUrl
import chat.rocket.common.model.SimpleUser
import chat.rocket.core.model.Message
import org.commonmark.Extension
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.node.AbstractVisitor
import org.commonmark.node.Document
import org.commonmark.node.Emphasis
import org.commonmark.node.ListItem
import org.commonmark.node.Node
import org.commonmark.node.OrderedList
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text
import ru.noties.markwon.Markwon
import org.commonmark.parser.Parser
import ru.noties.markwon.SpannableBuilder
import ru.noties.markwon.SpannableConfiguration
import ru.noties.markwon.renderer.SpannableMarkdownVisitor
import ru.noties.markwon.tasklist.TaskListExtension
import java.util.*
import javax.inject.Inject
class MessageParser @Inject constructor(
......@@ -39,8 +48,6 @@ class MessageParser @Inject constructor(
private val settings: PublicSettings
) {
private val parser = Markwon.createParser()
/**
* Render markdown and other rules on message to rich text with spans.
*
......@@ -52,6 +59,16 @@ class MessageParser @Inject constructor(
fun render(message: Message, selfUsername: String? = null): CharSequence {
var text: String = message.message
val mentions = mutableListOf<String>()
val parser = Parser.Builder()
.extensions(Arrays.asList<Extension>(
StrikethroughExtension.create(),
TablesExtension.create(),
TaskListExtension.create()
))
.customDelimiterProcessor(StrikethroughDelimiterProcessor())
.build()
message.mentions?.forEach {
val mention = getMention(it)
mentions.add(mention)
......@@ -59,12 +76,17 @@ class MessageParser @Inject constructor(
text = text.replace("@${it.username}", mention)
}
}
val builder = SpannableBuilder()
val content = EmojiRepository.shortnameToUnicode(text)
val parentNode = parser.parse(toLenientMarkdown(content))
val parentNode = parser.parse(content)
parentNode.accept(EmphasisVisitor())
parentNode.accept(StrongEmphasisVisitor())
parentNode.accept(MarkdownVisitor(configuration, builder))
parentNode.accept(LinkVisitor(builder))
parentNode.accept(EmojiVisitor(context, configuration, builder))
message.mentions?.let {
parentNode.accept(MentionVisitor(context, builder, mentions, selfUsername))
}
......@@ -72,13 +94,6 @@ class MessageParser @Inject constructor(
return builder.text()
}
// 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()}_" }
}
private fun getMention(user: SimpleUser): String {
return if (settings.useRealName()) {
user.name ?: "@${user.username}"
......@@ -87,6 +102,31 @@ class MessageParser @Inject constructor(
}
}
class EmphasisVisitor : AbstractVisitor() {
override fun visit(emphasis: Emphasis) {
if (emphasis.openingDelimiter == "*" && emphasis.firstChild != null) {
val child = emphasis.firstChild
val strongEmphasis = StrongEmphasis()
strongEmphasis.appendChild(child)
emphasis.insertBefore(strongEmphasis)
emphasis.unlink()
}
}
}
class StrongEmphasisVisitor : AbstractVisitor() {
override fun visit(strongEmphasis: StrongEmphasis) {
if (strongEmphasis.openingDelimiter == "__" && strongEmphasis.firstChild != null) {
val child = strongEmphasis.firstChild
val emphasis = Emphasis()
emphasis.appendChild(child)
strongEmphasis.insertBefore(emphasis)
strongEmphasis.unlink()
}
}
}
class MentionVisitor(
context: Context,
private val builder: SpannableBuilder,
......@@ -98,28 +138,28 @@ class MessageParser @Inject constructor(
private val othersBackgroundColor = ResourcesCompat.getColor(context.resources, android.R.color.transparent, context.theme)
private val myselfTextColor = ResourcesCompat.getColor(context.resources, R.color.colorWhite, context.theme)
private val myselfBackgroundColor = ResourcesCompat.getColor(context.resources, R.color.colorAccent, context.theme)
private val mentionPadding = context.resources.getDimensionPixelSize(R.dimen.padding_mention).toFloat()
private val mentionRadius = context.resources.getDimensionPixelSize(R.dimen.radius_mention).toFloat()
private val padding = context.resources.getDimensionPixelSize(R.dimen.padding_mention).toFloat()
private val radius = context.resources.getDimensionPixelSize(R.dimen.radius_mention).toFloat()
override fun visit(t: Text) {
val text = t.literal
override fun visit(document: Document) {
val text = builder.text()
val mentionsList = mentions.toMutableList().also {
it.add("@all")
it.add("@here")
}.toList()
}.distinct()
mentionsList.forEach {
val mentionMe = it == currentUser || it == "@all" || it == "@here"
var offset = text.indexOf(it, 0, true)
var offset = text.indexOf(string = it, startIndex = 0, ignoreCase = false)
while (offset > -1) {
val textColor = if (mentionMe) myselfTextColor else othersTextColor
val backgroundColor = if (mentionMe) myselfBackgroundColor else othersBackgroundColor
val usernameSpan = MentionSpan(backgroundColor, textColor, mentionRadius, mentionPadding,
val usernameSpan = MentionSpan(backgroundColor, textColor, radius, padding,
mentionMe)
// Add 1 to end offset to include the @.
val end = offset + it.length + 1
builder.setSpan(usernameSpan, offset, end, 0)
offset = text.indexOf("@$it", end, true)
val end = offset + it.length
builder.setSpan(usernameSpan, offset, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
offset = text.indexOf(string = "@$it", startIndex = end, ignoreCase = false)
}
}
}
......@@ -179,7 +219,7 @@ class MessageParser @Inject constructor(
}
private fun newLine() {
if (builder.length() > 0 && '\n' != builder.lastChar()) {
if (builder.isNotEmpty() && '\n' != builder.lastChar()) {
builder.append('\n')
}
}
......@@ -204,7 +244,6 @@ class MessageParser @Inject constructor(
consumed.add(link)
}
}
visitChildren(text)
}
}
......
......@@ -27,6 +27,7 @@ interface LocalRepository {
const val SETTINGS_KEY = "settings_"
const val PERMISSIONS_KEY = "permissions_"
const val USER_KEY = "user_"
const val UNFINISHED_MSG_KEY = "unfinished_msg_"
const val CURRENT_USERNAME_KEY = "username_"
const val LAST_CHATROOMS_REFRESH = "_chatrooms_refresh"
}
......
......@@ -5,13 +5,14 @@
android:id="@+id/attachment_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:paddingBottom="@dimen/message_item_top_and_bottom_padding"
android:paddingEnd="@dimen/screen_edge_left_and_right_padding"
android:paddingStart="@dimen/screen_edge_left_and_right_padding"
android:paddingTop="@dimen/message_item_top_and_bottom_padding">
android:paddingTop="@dimen/message_item_top_and_bottom_padding"
android:paddingEnd="@dimen/screen_edge_left_and_right_padding"
android:paddingBottom="@dimen/message_item_top_and_bottom_padding">
<View
android:id="@+id/quote_bar"
......@@ -19,7 +20,7 @@
android:layout_height="0dp"
android:layout_marginStart="56dp"
android:background="@drawable/quote_vertical_gray_bar"
app:layout_constraintBottom_toTopOf="@+id/recycler_view_reactions"
app:layout_constraintBottom_toTopOf="@+id/text_view_more"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
......@@ -28,11 +29,11 @@
style="@style/Sender.Name.TextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="@color/colorPrimary"
tools:text="Ronald Perkins"
app:layout_constraintStart_toEndOf="@+id/quote_bar"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginStart="8dp" />
tools:text="Ronald Perkins" />
<TextView
android:id="@+id/text_message_time"
......@@ -40,28 +41,39 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
tools:text="11:45 PM"
app:layout_constraintBottom_toBottomOf="@+id/text_sender"
app:layout_constraintStart_toEndOf="@+id/text_sender"
app:layout_constraintTop_toTopOf="@+id/text_sender"
app:layout_constraintBottom_toBottomOf="@+id/text_sender"/>
tools:text="11:45 PM" />
<TextView
android:id="@+id/text_content"
style="@style/Message.Quote.TextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
app:layout_constraintBottom_toTopOf="@id/text_view_more"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/text_sender"
app:layout_constraintTop_toBottomOf="@+id/text_sender"
tools:text="This is a multiline chat message from Bertie that will take more than just one line of text. I have sure that everything is amazing!" />
<TextView
android:id="@+id/text_view_more"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="end"
android:textColor="@color/darkGray"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="@+id/text_content"
app:layout_constraintStart_toStartOf="@+id/quote_bar"
app:layout_constraintTop_toBottomOf="@+id/text_content"
tools:text="Visualizar mais" />
<include
layout="@layout/layout_reactions"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@+id/quote_bar"
app:layout_constraintTop_toBottomOf="@+id/text_content" />
app:layout_constraintTop_toBottomOf="@+id/text_view_more" />
</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
</androidx.constraintlayout.widget.ConstraintLayout>
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="2dp"
android:layout_marginBottom="2dp"
android:background="@color/suggestion_background_color">
<ViewFlipper
android:id="@+id/view_flipper_emoji"
android:layout_width="24dp"
android:layout_height="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/text_emoji"
android:layout_width="24dp"
android:layout_height="24dp"
android:textColor="@android:color/black"
tools:text=":)" />
<ImageView
android:id="@+id/image_emoji"
android:layout_width="24dp"
android:layout_height="24dp"
tools:src="@tools:sample/avatars" />
</ViewFlipper>
<TextView
android:id="@+id/text_emoji_shortname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:maxLines="1"
android:textColor="@color/colorBlack"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/view_flipper_emoji"
app:layout_constraintTop_toTopOf="parent"
tools:text=":grinning:" />
</androidx.constraintlayout.widget.ConstraintLayout>
......@@ -142,6 +142,10 @@
<string name="msg_message_copied">Nachricht kopiert</string>
<string name="msg_delete_message">Lösche Nachricht</string>
<string name="msg_delete_description">Sind Sie sicher, dass Sie diese Nachricht löschen wollen?</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_more">view more</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_less">view less</string>
<!-- Preferences messages -->
<string name="msg_analytics_tracking">Analytics tracking</string> <!-- TODO Add translation -->
......
......@@ -139,6 +139,10 @@
<string name="msg_member_already_added">Ya has seleccionado este usuario</string>
<string name="msg_member_not_found">Miembro no encontrado</string>
<string name="msg_channel_created_successfully">Canal creado con éxito</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_more">view more</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_less">view less</string>
<!-- Preferences messages -->
<string name="msg_analytics_tracking">Analytics tracking</string> <!-- TODO Add translation -->
......
......@@ -131,6 +131,10 @@
<string name="msg_send">envoyer</string>
<string name="msg_delete_message">Supprimer Message</string>
<string name="msg_delete_description">Êtes-vous sûr de vouloir supprimer ce message</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_more">view more</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_less">view less</string>
<!-- Create channel messages -->
<string name="msg_private_channel">Privé</string>
......
......@@ -145,6 +145,10 @@
<string name="msg_member_already_added">आपने पहले से ही इस यूजर को चुन चुके है।</string>
<string name="msg_member_not_found">सदस्य नहीं मिला</string>
<string name="msg_channel_created_successfully">चैनल सफलतापूर्वक बनाया गया</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_more">view more</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_less">view less</string>
<!-- Preferences messages -->
<string name="msg_analytics_tracking">एनालिटिक्स ट्रैकिंग</string>
......
......@@ -132,6 +132,10 @@
<string name="msg_file_description">ファイルの説明</string>
<string name="msg_send">送信</string>
<string name="msg_sent_attachment">添付ファイルを送信しました</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_more">view more</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_less">view less</string>
<!-- Create channel messages -->
<string name="msg_private_channel">プライベート</string>
......
......@@ -130,9 +130,10 @@
<string name="msg_upload_file">Subir arquivo</string>
<string name="msg_file_description">Descrição do arquivo</string>
<string name="msg_send">Enviar</string>
// TODO: Add proper translation.
<string name="msg_delete_message">Delete Message</string>
<string name="msg_delete_description">Are you sure you want to delete this message</string>
<string name="msg_delete_message">Remove mensagem</string>
<string name="msg_delete_description">Tem certeza que quer apagar esta mensagem?</string>
<string name="msg_view_more">visualizar mais</string>
<string name="msg_view_less">visualizar menos</string>
<!-- Create channel messages -->
<string name="msg_private_channel">Privado</string>
......
......@@ -130,6 +130,10 @@
<string name="msg_delete_description">Вы уверены, что хотите удалить это сообщение?</string>
<string name="msg_channel_name">Название канала</string>
<string name="msg_search">Поиск</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_more">view more</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_less">view less</string>
<!-- Create channel messages -->
<string name="msg_private_channel">Приватный</string>
......
......@@ -129,6 +129,10 @@
<string name="msg_send">Надіслати</string>
<string name="msg_delete_message">Видалити повідомлення</string>
<string name="msg_delete_description">Ви впевнені, що хочете видалити це повідомлення?</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_more">view more</string>
<!-- TODO - Add proper translation -->
<string name="msg_view_less">view less</string>
<!-- Create channel messages -->
<string name="msg_private_channel">Приватний</string>
......
......@@ -44,4 +44,6 @@
<dimen name="viewer_toolbar_padding">16dp</dimen>
<dimen name="viewer_toolbar_title">16sp</dimen>
<dimen name="quote_collapsed_height">32dp</dimen>
</resources>
\ No newline at end of file
......@@ -159,6 +159,8 @@ https://github.com/RocketChat/java-code-styles/blob/master/CODING_STYLE.md#strin
<string name="msg_message_copied">Message copied</string>
<string name="msg_delete_message">Delete Message</string>
<string name="msg_delete_description">Are you sure you want to delete this message</string>
<string name="msg_view_more">view more</string>
<string name="msg_view_less">view less</string>
<!-- Preferences messages -->
<string name="msg_analytics_tracking">Analytics tracking</string>
......
......@@ -48,7 +48,7 @@ ext {
kotshi : '1.0.4',
frescoImageViewer : '0.5.1',
markwon : '1.1.1',
markwon : '2.0.0',
aVLoadingIndicatorView: '2.1.3',
glide : '4.8.0',
......
......@@ -146,7 +146,7 @@ object EmojiRepository {
*
* @return All emojis for all categories.
*/
internal suspend fun getAll(): List<Emoji> = withContext(CommonPool) {
suspend fun getAll(): List<Emoji> = withContext(CommonPool) {
return@withContext db.emojiDao().loadAllEmojis()
}
......
package yampsample.leonardoaramaki.github.com.suggestions;
import android.content.Context;
import android.support.test.InstrumentationRegistry;
import android.support.test.runner.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getTargetContext();
assertEquals("yampsample.leonardoaramaki.github.com.suggestions.test", appContext.getPackageName());
}
}
package chat.rocket.android.suggestions.model
abstract class SuggestionModel(val text: String, // This is the text key for searches, must be unique.
val searchList: List<String> = emptyList(), // Where to search for matches.
val pinned: Boolean = false /* If pinned item will have priority to show */) {
abstract class SuggestionModel(
val text: String, // This is the text key for searches, must be unique.
val searchList: List<String> = emptyList(), // Where to search for matches.
val pinned: Boolean = false /* If pinned item will have priority to show */
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SuggestionModel) return false
......
......@@ -18,7 +18,9 @@ class TrieCompletionStrategy : CompletionStrategy {
return item
}
override fun autocompleteItems(prefix: String) = trie.autocompleteItems(prefix)
override fun autocompleteItems(prefix: String): List<SuggestionModel> {
return trie.autocompleteItems(prefix)
}
override fun addAll(list: List<SuggestionModel>) {
items.addAll(list)
......
......@@ -34,10 +34,7 @@ internal class Trie {
val sanitizedWord = word.trim().toLowerCase()
var current = root
sanitizedWord.forEach { ch ->
val child = current.getChild(ch)
if (child == null) {
return false
}
val child = current.getChild(ch) ?: return false
current = child
}
if (current.isLeaf) {
......@@ -63,7 +60,7 @@ internal class Trie {
lastNode = lastNode?.getChild(ch)
if (lastNode == null) return emptyList()
}
return lastNode!!.getItems()
return lastNode!!.getItems().take(5).toList()
}
fun getCount() = count
......
package chat.rocket.android.suggestions.strategy.trie.data
import chat.rocket.android.suggestions.model.SuggestionModel
import kotlin.coroutines.experimental.buildSequence
internal class TrieNode(
internal var data: Char,
internal var parent: TrieNode? = null,
internal var isLeaf: Boolean = false,
internal var item: SuggestionModel? = null
) {
internal class TrieNode(internal var data: Char,
internal var parent: TrieNode? = null,
internal var isLeaf: Boolean = false,
internal var item: SuggestionModel? = null) {
val children = hashMapOf<Char, TrieNode>()
fun getChild(c: Char): TrieNode? {
......@@ -28,19 +32,17 @@ internal class TrieNode(internal var data: Char,
return list
}
class X : SuggestionModel("")
fun getItems(): Sequence<SuggestionModel> = buildSequence {
fun getItems(): List<SuggestionModel> {
val list = arrayListOf<SuggestionModel>()
if (isLeaf) {
list.add(item!!)
yield(item!!)
}
children.forEach { node ->
node.value.let {
list.addAll(it.getItems())
yieldAll(it.getItems())
}
}
return list
}
override fun toString(): String = if (parent == null) "" else "${parent.toString()}$data"
......
......@@ -10,17 +10,9 @@ import kotlin.properties.Delegates
abstract class SuggestionsAdapter<VH : BaseSuggestionViewHolder>(
val token: String,
val constraint: Int = CONSTRAINT_UNBOUND,
threshold: Int = MAX_RESULT_COUNT) : RecyclerView.Adapter<VH>() {
companion object {
// Any number of results.
const val RESULT_COUNT_UNLIMITED = -1
// Trigger suggestions only if on the line start.
const val CONSTRAINT_BOUND_TO_START = 0
// Trigger suggestions from anywhere.
const val CONSTRAINT_UNBOUND = 1
// Maximum number of results to display by default.
private const val MAX_RESULT_COUNT = 5
}
completionStrategy: CompletionStrategy? = null,
threshold: Int = MAX_RESULT_COUNT
) : RecyclerView.Adapter<VH>() {
private var itemType: Type? = null
private var itemClickListener: ItemClickListener? = null
......@@ -30,7 +22,7 @@ abstract class SuggestionsAdapter<VH : BaseSuggestionViewHolder>(
// Maximum number of results/suggestions to display.
private var resultsThreshold: Int = if (threshold > 0) threshold else RESULT_COUNT_UNLIMITED
// The strategy used for suggesting completions.
private val strategy: CompletionStrategy = StringMatchingCompletionStrategy(resultsThreshold)
private val strategy: CompletionStrategy = completionStrategy ?: StringMatchingCompletionStrategy(resultsThreshold)
// Current input term to look up for suggestions.
private var currentTerm: String by Delegates.observable("") { _, _, newTerm ->
val items = strategy.autocompleteItems(newTerm)
......@@ -105,4 +97,15 @@ abstract class SuggestionsAdapter<VH : BaseSuggestionViewHolder>(
interface ItemClickListener {
fun onClick(item: SuggestionModel)
}
}
\ No newline at end of file
companion object {
// Any number of results.
const val RESULT_COUNT_UNLIMITED = -1
// Trigger suggestions only if on the line start.
const val CONSTRAINT_BOUND_TO_START = 0
// Trigger suggestions from anywhere.
const val CONSTRAINT_UNBOUND = 1
// Maximum number of results to display by default.
private const val MAX_RESULT_COUNT = 5
}
}
......@@ -149,15 +149,14 @@ class SuggestionsView : FrameLayout, TextWatcher {
}
fun addTokenAdapter(adapter: SuggestionsAdapter<*>): SuggestionsView {
adaptersByToken.getOrPut(adapter.token, { adapter })
adaptersByToken.getOrPut(adapter.token) { adapter }
return this
}
fun addItems(token: String, list: List<SuggestionModel>): SuggestionsView {
if (list.isNotEmpty()) {
val adapter = adapter(token)
localProvidersByToken.getOrPut(token, { hashMapOf() })
.put(adapter.term(), list)
localProvidersByToken.getOrPut(token) { hashMapOf() }.put(adapter.term(), list)
if (completionOffset.get() > NO_STATE_INDEX && adapter.itemCount == 0) expand()
adapter.addItems(list)
}
......
package yampsample.leonardoaramaki.github.com.suggestions;
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() {
assertEquals(4, 2 + 2);
}
}
\ No newline at end of file
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