chats: disable pagination for chat listing, another finalization of chat bubble design

This commit is contained in:
Alibek Omarov 2020-09-13 23:49:30 +03:00
parent bcfd202bbb
commit 69f37208a5
20 changed files with 597 additions and 205 deletions

View File

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 25,
"identityHash": "322074fec4881114e2d85da67166e31f",
"identityHash": "8c97dfd1b3d04602e25139ec97d2a282",
"entities": [
{
"tableName": "TootEntity",
@ -802,7 +802,7 @@
},
{
"tableName": "ChatMessageEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `messageId` TEXT NOT NULL, `content` TEXT NOT NULL, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `attachment` TEXT, `emojis` TEXT NOT NULL, PRIMARY KEY(`localId`, `messageId`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `messageId` TEXT NOT NULL, `content` TEXT, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `attachment` TEXT, `emojis` TEXT NOT NULL, PRIMARY KEY(`localId`, `messageId`))",
"fields": [
{
"fieldPath": "localId",
@ -820,7 +820,7 @@
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
"notNull": false
},
{
"fieldPath": "chatId",
@ -867,7 +867,7 @@
"views": [],
"setupQueries": [
"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, '322074fec4881114e2d85da67166e31f')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8c97dfd1b3d04602e25139ec97d2a282')"
]
}
}

View File

@ -1,6 +1,7 @@
<resources>
<!-- HUSKY SPECIFIC STRINGS -->
<string name="chats">Chats</string>
<string name="chat_our_last_message">You: %s</string>
<string name="action_mark_as_read">Mark as read</string>
<string name="action_reply_to">Reply to</string>
@ -31,6 +32,11 @@
<string name="pref_title_hide_muted_users">Hide muted users</string>
<string name="pref_title_enable_big_emojis">Enable bigger custom emojis</string>
<string name="pref_title_enable_experimental_stickers">Enable experimental Pleroma-FE stickers(if available)</string>
<string name="attachment_type_image">Image</string>
<string name="attachment_type_video">Video</string>
<string name="attachment_type_audio">Audio</string>
<string name="attachment_type_unknown">Attachment</string>
<!-- REPLACEMENT FOR TUSKY STRINGS -->
<string name="action_toggle_visibility">Post visibility</string>
@ -87,7 +93,6 @@
<string name="title_scheduled_toot">Scheduled posts</string>
<string name="title_reblogged_by">Repeated by</string>
<string name="title_view_thread">Post</string>
<string name="chat_message_hint_text">Just landed in L.A.</string>
<!--
<string name="about_tusky_version">Husky %s</string>

View File

@ -1,27 +1,24 @@
package com.keylesspalace.tusky.adapter
import android.content.Context
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.text.TextUtils
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import android.widget.*
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.ChatMessage
import com.keylesspalace.tusky.interfaces.ChatActionListener
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.TimestampUtils
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.view.MediaPreviewImageView
import com.keylesspalace.tusky.viewdata.ChatMessageViewData
import com.keylesspalace.tusky.viewdata.ChatViewData
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.roundToInt
@ -37,21 +34,22 @@ class ChatMessagesViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val mediaOverlay: ImageView = view.findViewById(R.id.mediaOverlay)
private val attachmentLayout: FrameLayout = view.findViewById(R.id.attachmentLayout)
private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault())
private val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
private val mediaPreviewUnloaded = ColorDrawable(ThemeUtils.getColor(itemView.context, R.attr.colorBackgroundAccent))
fun setupWithChatMessage(msg: ChatMessageViewData.Concrete, chatActionListener: ChatActionListener, statusDisplayOptions: StatusDisplayOptions, payload: Any?) {
if(payload == null) {
content.text = msg.content.emojify(msg.emojis, content)
if(msg.content != null)
content.text = msg.content.emojify(msg.emojis, content)
setAttachment(msg.attachment, chatActionListener)
setCreatedAt(msg.createdAt, statusDisplayOptions)
setCreatedAt(msg.createdAt)
} else {
if(payload is List<*>) {
for (item in payload) {
if (ChatsViewHolder.Key.KEY_CREATED == item) {
setCreatedAt(msg.createdAt, statusDisplayOptions)
setCreatedAt(msg.createdAt)
}
}
}
@ -154,26 +152,8 @@ class ChatMessagesViewHolder(view: View) : RecyclerView.ViewHolder(view) {
}
}
private fun getAbsoluteTime(createdAt: Date?): String? {
if (createdAt == null) {
return "??:??:??"
}
return if (DateUtils.isToday(createdAt.time)) {
shortSdf.format(createdAt)
} else {
longSdf.format(createdAt)
}
}
private fun setCreatedAt(createdAt: Date, statusDisplayOptions: StatusDisplayOptions) {
if (statusDisplayOptions.useAbsoluteTime) {
timestamp.text = getAbsoluteTime(createdAt)
} else {
val then = createdAt.time
val now = System.currentTimeMillis()
val readout = TimestampUtils.getRelativeTimeSpanString(timestamp.context, then, now)
timestamp.text = readout
}
private fun setCreatedAt(createdAt: Date) {
timestamp.text = sdf.format(createdAt)
}
}
@ -242,6 +222,6 @@ class ChatMessagesAdapter(private val dataSource : TimelineAdapter.AdapterDataSo
}
override fun getItemId(position: Int): Long {
return dataSource.getItemAt(position).getViewDataId()
return dataSource.getItemAt(position).getViewDataId().toLong()
}
}

View File

@ -1,5 +1,6 @@
package com.keylesspalace.tusky.adapter
import android.graphics.Typeface
import android.opengl.Visibility
import android.text.TextUtils
import android.text.format.DateUtils
@ -38,16 +39,20 @@ class ChatsViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault())
fun setupWithChat(chat: ChatViewData.Concrete, listener: ChatActionListener, statusDisplayOptions: StatusDisplayOptions, payload: Any?) {
if(payload == null) {
fun setupWithChat(chat: ChatViewData.Concrete,
listener: ChatActionListener,
statusDisplayOptions: StatusDisplayOptions,
localUserId: String,
payload: Any?) {
if (payload == null) {
displayName.text = chat.account.displayName?.emojify(chat.account.emojis, displayName, true)
?: ""
userName.text = userName.context.getString(R.string.status_username_format, chat.account.username)
setUpdatedAt(chat.updatedAt, statusDisplayOptions)
setAvatar(chat.account.avatar, chat.account.bot, statusDisplayOptions)
if(chat.unread <= 0) {
if (chat.unread <= 0) {
unread.visibility = View.GONE
} else if(chat.unread > 99) {
} else if (chat.unread > 99) {
unread.text = ":)"
} else {
unread.text = chat.unread.toString()
@ -59,7 +64,7 @@ class ChatsViewHolder(view: View) : RecyclerView.ViewHolder(view) {
}
val onClickListener = View.OnClickListener {
val pos = adapterPosition
if(pos != RecyclerView.NO_POSITION)
if (pos != RecyclerView.NO_POSITION)
listener.openChat(pos)
}
@ -69,7 +74,19 @@ class ChatsViewHolder(view: View) : RecyclerView.ViewHolder(view) {
itemView.setOnClickListener(onClickListener)
chat.lastMessage?.let {
content.text = it.content.emojify(it.emojis, content, true)
var text = if (it.content != null) {
content.setTypeface(null, Typeface.NORMAL)
it.content.emojify(it.emojis, content, true)
} else if (it.attachment != null) {
content.setTypeface(null, Typeface.ITALIC)
content.resources.getString(it.attachment.describeAttachmentType())
} else ""
content.text = if(it.accountId == localUserId) {
content.resources.getString(R.string.chat_our_last_message).format(text)
} else text
}
} else {
if(payload is List<*>) {
@ -127,7 +144,8 @@ class ChatsViewHolder(view: View) : RecyclerView.ViewHolder(view) {
class ChatsAdapter(private val dataSource: TimelineAdapter.AdapterDataSource<ChatViewData>,
val statusDisplayOptions: StatusDisplayOptions,
private val chatActionListener: ChatActionListener) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val chatActionListener: ChatActionListener,
val localUserId: String) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val VIEW_TYPE_CHAT = 0
private val VIEW_TYPE_PLACEHOLDER = 1
@ -149,7 +167,8 @@ class ChatsAdapter(private val dataSource: TimelineAdapter.AdapterDataSource<Cha
if(holder is PlaceholderViewHolder) {
holder.setup(chatActionListener, (chat as ChatViewData.Placeholder).isLoading)
} else if(holder is ChatsViewHolder) {
holder.setupWithChat(chat as ChatViewData.Concrete, chatActionListener, statusDisplayOptions,
holder.setupWithChat(chat as ChatViewData.Concrete, chatActionListener,
statusDisplayOptions, localUserId,
if (payloads != null && payloads.isNotEmpty()) payloads[0] else null)
}
}
@ -175,6 +194,6 @@ class ChatsAdapter(private val dataSource: TimelineAdapter.AdapterDataSource<Cha
}
override fun getItemId(position: Int): Long {
return dataSource.getItemAt(position).getViewDataId()
return dataSource.getItemAt(position).getViewDataId().toLong()
}
}

View File

@ -11,38 +11,38 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.adapter.ChatMessagesAdapter
import com.keylesspalace.tusky.adapter.ChatMessagesViewHolder
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.adapter.TimelineAdapter
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Chat
import com.keylesspalace.tusky.entity.ChatMessage
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.interfaces.ChatActionListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.ChatMessageStatus
import com.keylesspalace.tusky.repository.ChatMesssageOrPlaceholder
import com.keylesspalace.tusky.repository.ChatRepository
import com.keylesspalace.tusky.repository.ChatStatus
import com.keylesspalace.tusky.viewdata.ChatMessageViewData
import com.keylesspalace.tusky.viewdata.ChatViewData
import kotlinx.android.synthetic.main.activity_chat.*
import kotlinx.android.synthetic.main.toolbar_basic.toolbar
import androidx.arch.core.util.Function
import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.*
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.repository.Placeholder
import com.keylesspalace.tusky.repository.TimelineRequestMode
import com.keylesspalace.tusky.util.*
import com.uber.autodispose.android.lifecycle.autoDispose
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.activity_chat.progressBar
import kotlinx.android.synthetic.main.fragment_timeline.*
import java.io.IOException
import java.lang.Exception
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class ChatActivity: BottomSheetActivity(),
Injectable, ChatActionListener {
private val TAG = "ChatsF" // logging tag
private val TAG = "ChatsActivity" // logging tag
private val LOAD_AT_ONCE = 30
@Inject
@ -54,11 +54,21 @@ class ChatActivity: BottomSheetActivity(),
lateinit var adapter: ChatMessagesAdapter
private val msgs = PairedList<ChatMessageStatus, ChatMessageViewData?>(Function<ChatMessageStatus, ChatMessageViewData?> {input ->
private val msgs = PairedList<ChatMesssageOrPlaceholder, ChatMessageViewData?>(Function<ChatMesssageOrPlaceholder, ChatMessageViewData?> { input ->
input.asRightOrNull()?.let(ViewDataUtils::chatMessageToViewData) ?:
ChatMessageViewData.Placeholder(input.asLeft().id, false)
})
private var bottomLoading = false
private var eventRegistered = false
private var isNeedRefresh = false
private var didLoadEverythingBottom = false
private var initialUpdateFailed = false
private enum class FetchEnd {
TOP, BOTTOM, MIDDLE
}
private val listUpdateCallback = object : ListUpdateCallback {
override fun onInserted(position: Int, count: Int) {
Log.d(TAG, "onInserted")
@ -119,7 +129,7 @@ class ChatActivity: BottomSheetActivity(),
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
chatId = intent.getStringExtra(ID)
val chatId = intent.getStringExtra(ID)
val avatarUrl = intent.getStringExtra(AVATAR_URL)
val displayName = intent.getStringExtra(DISPLAY_NAME)
val username = intent.getStringExtra(USERNAME)
@ -128,6 +138,7 @@ class ChatActivity: BottomSheetActivity(),
if(chatId == null || avatarUrl == null || displayName == null || username == null || emojis == null) {
throw IllegalArgumentException("Can't open ChatActivity without chat id")
}
this.chatId = chatId
if(accountManager.activeAccount == null) {
throw Exception("No active account!")
@ -164,12 +175,16 @@ class ChatActivity: BottomSheetActivity(),
val layoutManager = LinearLayoutManager(this)
layoutManager.reverseLayout = true
recycler.layoutManager = layoutManager
recycler.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
// recycler.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
recycler.adapter = adapter
tryCache()
}
private fun clearPlaceholdersForResponse(msgs: MutableList<ChatMesssageOrPlaceholder>) {
msgs.removeAll { it.isLeft() }
}
private fun tryCache() {
// Request timeline from disk to make it quick, then replace it with timeline from
// the server to update it
@ -178,21 +193,293 @@ class ChatActivity: BottomSheetActivity(),
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { msgs ->
if (msgs.size > 1) {
val mutableChats = msgs.toMutableList()
val mutableMsgs = msgs.toMutableList()
clearPlaceholdersForResponse(mutableMsgs)
this.msgs.clear()
this.msgs.addAll(mutableChats)
this.msgs.addAll(mutableMsgs)
updateAdapter()
progressBar.visibility = View.GONE
// Request statuses including current top to refresh all of them
}
updateCurrent()
loadAbove()
}
}
private fun updateCurrent() {
if (msgs.isEmpty()) {
return
}
val topId = msgs.first { it.isRight() }.asRight().id
chatsRepo.getChatMessages(chatId, topId, null, null, LOAD_AT_ONCE, TimelineRequestMode.NETWORK)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe({ messages ->
initialUpdateFailed = false
// When cached timeline is too old, we would replace it with nothing
if (messages.isNotEmpty()) {
// clear old cached statuses
if(this.msgs.isNotEmpty()) {
this.msgs.removeAll {
if(it.isRight()) {
val chat = it.asRight()
chat.id.length < topId.length || chat.id < topId
} else {
val placeholder = it.asLeft()
placeholder.id.length < topId.length || placeholder.id < topId
}
}
}
this.msgs.addAll(messages)
updateAdapter()
}
bottomLoading = false
}, {
initialUpdateFailed = true
// Indicate that we are not loading anymore
progressBar.visibility = View.GONE
})
}
private fun showNothing() {
messageView.visibility = View.VISIBLE
messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
}
private fun loadAbove() {
var firstOrNull: String? = null
var secondOrNull: String? = null
for (i in msgs.indices) {
val msg = msgs[i]
if (msg.isRight()) {
firstOrNull = msg.asRight().id
if (i + 1 < msgs.size && msgs[i + 1].isRight()) {
secondOrNull = msgs[i + 1].asRight().id
}
break
}
}
if (firstOrNull != null) {
sendFetchMessagesRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1)
} else {
sendFetchMessagesRequest(null, null, null, FetchEnd.BOTTOM, -1)
}
}
private fun sendFetchMessagesRequest(maxId: String?, sinceId: String?,
sinceIdMinusOne: String?,
fetchEnd: FetchEnd, pos: Int) {
// allow getting old statuses/fallbacks for network only for for bottom loading
val mode = if (fetchEnd == FetchEnd.BOTTOM) {
TimelineRequestMode.ANY
} else {
TimelineRequestMode.NETWORK
}
chatsRepo.getChatMessages(chatId, maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe( { result -> onFetchTimelineSuccess(result.toMutableList(), fetchEnd, pos) },
{ onFetchTimelineFailure(Exception(it), fetchEnd, pos) })
}
private fun updateAdapter() {
Log.d(TAG, "updateAdapter")
differ.submitList(msgs.pairedCopy)
}
private fun updateMessages(newMsgs: MutableList<ChatMesssageOrPlaceholder>, fullFetch: Boolean) {
if (newMsgs.isEmpty()) {
updateAdapter()
return
}
if (msgs.isEmpty()) {
msgs.addAll(newMsgs)
} else {
val lastOfNew = newMsgs[newMsgs.size - 1]
val index = msgs.indexOf(lastOfNew)
if (index >= 0) {
msgs.subList(0, index).clear()
}
val newIndex = newMsgs.indexOf(msgs[0])
if (newIndex == -1) {
if (index == -1 && fullFetch) {
newMsgs.findLast { it.isRight() }?.let {
val placeholderId = it.asRight().id.inc()
newMsgs.add(Either.Left(Placeholder(placeholderId)))
}
}
msgs.addAll(0, newMsgs)
} else {
msgs.addAll(0, newMsgs.subList(0, newIndex))
}
}
// Remove all consecutive placeholders
removeConsecutivePlaceholders()
updateAdapter()
}
private fun removeConsecutivePlaceholders() {
for (i in 0 until msgs.size - 1) {
if (msgs[i].isLeft() && msgs[i + 1].isLeft()) {
msgs.removeAt(i)
}
}
}
private fun replacePlaceholderWithMessages(newMsgs: MutableList<ChatMesssageOrPlaceholder>,
fullFetch: Boolean, pos: Int) {
val placeholder = msgs[pos]
if (placeholder.isLeft()) {
msgs.removeAt(pos)
}
if (newMsgs.isEmpty()) {
updateAdapter()
return
}
if (fullFetch) {
newMsgs.add(placeholder)
}
msgs.addAll(pos, newMsgs)
removeConsecutivePlaceholders()
updateAdapter()
}
private fun addItems(newMsgs: List<ChatMesssageOrPlaceholder>) {
if (newMsgs.isEmpty()) {
return
}
val last = msgs.findLast { it.isRight() }
// I was about to replace findStatus with indexOf but it is incorrect to compare value
// types by ID anyway and we should change equals() for Status, I think, so this makes sense
if (last != null && !newMsgs.contains(last)) {
msgs.addAll(newMsgs)
removeConsecutivePlaceholders()
updateAdapter()
}
}
private fun onFetchTimelineSuccess(msgs: MutableList<ChatMesssageOrPlaceholder>,
fetchEnd: FetchEnd, pos: Int) {
// We filled the hole (or reached the end) if the server returned less statuses than we
// we asked for.
val fullFetch = msgs.size >= LOAD_AT_ONCE
when (fetchEnd) {
FetchEnd.TOP -> {
updateMessages(msgs, fullFetch)
}
FetchEnd.MIDDLE -> {
replacePlaceholderWithMessages(msgs, fullFetch, pos)
}
FetchEnd.BOTTOM -> {
if (this.msgs.isNotEmpty() && !this.msgs.last().isRight()) {
this.msgs.removeAt(this.msgs.size - 1)
updateAdapter()
}
if (msgs.isNotEmpty() && !msgs.last().isRight()) {
// Removing placeholder if it's the last one from the cache
msgs.removeAt(msgs.size - 1)
}
val oldSize = this.msgs.size
if (this.msgs.size > 1) {
addItems(msgs)
} else {
updateMessages(msgs, fullFetch)
}
if (this.msgs.size == oldSize) {
// This may be a brittle check but seems like it works
// Can we check it using headers somehow? Do all server support them?
didLoadEverythingBottom = true
}
}
}
updateBottomLoadingState(fetchEnd)
progressBar.visibility = View.GONE
if (this.msgs.size == 0) {
showNothing()
} else {
messageView.visibility = View.GONE
}
}
private fun onRefresh() {
messageView.visibility = View.GONE
isNeedRefresh = false
if (this.initialUpdateFailed) {
updateCurrent()
}
loadAbove()
}
private fun onFetchTimelineFailure(exception: Exception, fetchEnd: FetchEnd, position: Int) {
topProgressBar.hide()
if (fetchEnd == FetchEnd.MIDDLE && !msgs[position].isRight()) {
var placeholder = msgs[position].asLeftOrNull()
val newViewData: ChatMessageViewData
if (placeholder == null) {
val msg = msgs[position - 1].asRight()
val newId = msg.id.dec()
placeholder = Placeholder(newId)
}
newViewData = ChatMessageViewData.Placeholder(placeholder.id, false)
msgs.setPairedItem(position, newViewData)
updateAdapter()
} else if (msgs.isEmpty()) {
messageView.visibility = View.VISIBLE
if (exception is IOException) {
messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
progressBar.visibility = View.VISIBLE
onRefresh()
}
} else {
messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
progressBar.visibility = View.VISIBLE
onRefresh()
}
}
}
Log.e(TAG, "Fetch Failure: " + exception.message)
updateBottomLoadingState(fetchEnd)
progressBar.visibility = View.GONE
}
private fun updateBottomLoadingState(fetchEnd: FetchEnd) {
if (fetchEnd == FetchEnd.BOTTOM) {
bottomLoading = false
}
}
override fun onLoadMore(position: Int) {
//check bounds before accessing list,
if (msgs.size >= position && position > 0) {
val fromChat = msgs[position - 1].asRightOrNull()
val toChat = msgs[position + 1].asRightOrNull()
if (fromChat == null || toChat == null) {
Log.e(TAG, "Failed to load more at $position, wrong placeholder position")
return
}
val maxMinusOne = if (msgs.size > position + 1 && msgs[position + 2].isRight()) msgs[position + 1].asRight().id else null
sendFetchMessagesRequest(fromChat.id, toChat.id, maxMinusOne,
FetchEnd.MIDDLE, position)
val (id) = msgs[position].asLeft()
val newViewData = ChatMessageViewData.Placeholder(id, true)
msgs.setPairedItem(position, newViewData)
updateAdapter()
} else {
Log.e(TAG, "error loading more")
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
@ -203,6 +490,27 @@ class ChatActivity: BottomSheetActivity(),
return super.onOptionsItemSelected(item)
}
override fun onResume() {
super.onResume()
startUpdateTimestamp()
}
/**
* Start to update adapter every minute to refresh timestamp
* If setting absoluteTimeView is false
* Auto dispose observable on pause
*/
private fun startUpdateTimestamp() {
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
if (!useAbsoluteTime) {
Observable.interval(1, TimeUnit.MINUTES)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
.subscribe { updateAdapter() }
}
}
override fun onViewAccount(id: String) {
viewAccount(id)
}

View File

@ -133,7 +133,6 @@ class MediaPreviewAdapter(
view.marqueeRepeatLimit = -1
view.setSingleLine()
view.setSelected(true)
view.maxLines = 1
view.textSize = 16.0f
view.setOnClickListener {
onMediaClick(adapterPosition, view)

View File

@ -363,7 +363,7 @@ public abstract class AppDatabase extends RoomDatabase {
"PRIMARY KEY (`localId`, `chatId`))");
database.execSQL("CREATE TABLE `ChatMessageEntity` (`localId` INTEGER NOT NULL," +
"`messageId` TEXT NOT NULL," +
"`content` TEXT NOT NULL," +
"`content` TEXT," +
"`chatId` TEXT NOT NULL," +
"`accountId` TEXT NOT NULL," +
"`createdAt` INTEGER NOT NULL," +

View File

@ -12,7 +12,7 @@ import androidx.room.Entity
data class ChatMessageEntity(
val localId: Long,
val messageId: String,
val content: String,
val content: String?,
val chatId: String,
val accountId: String,
val createdAt: Long,

View File

@ -28,7 +28,7 @@ ELSE 1 END)
AND (CASE WHEN :sinceId IS NOT NULL THEN
(LENGTH(c.chatId) > LENGTH(:sinceId) OR LENGTH(c.chatId) == LENGTH(:sinceId) AND c.chatId > :sinceId)
ELSE 1 END)
ORDER BY LENGTH(c.chatId) DESC, c.chatId DESC
ORDER BY c.updatedAt DESC
LIMIT :limit
""")
abstract fun getChatsForAccount(localId: Long, maxId: String?, sinceId: String?, limit: Int) : Single<List<ChatEntityWithAccount>>

View File

@ -60,8 +60,8 @@ class NetworkModule {
addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
//level = HttpLoggingInterceptor.Level.HEADERS
//level = HttpLoggingInterceptor.Level.BASIC
level = HttpLoggingInterceptor.Level.HEADERS
//level = HttpLoggingInterceptor.Level.BODY
})
}

View File

@ -22,6 +22,7 @@ import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.annotations.JsonAdapter
import com.google.gson.annotations.SerializedName
import com.keylesspalace.tusky.R
import kotlinx.android.parcel.Parcelize
@Parcelize
@ -62,6 +63,15 @@ data class Attachment(
}
}
fun describeAttachmentType() : Int {
return when(type) {
Type.IMAGE -> R.string.attachment_type_image
Type.VIDEO, Type.GIFV -> R.string.attachment_type_video
Type.AUDIO -> R.string.attachment_type_audio
Type.UNKNOWN -> R.string.attachment_type_unknown
}
}
/**
* The meta data of an [Attachment].
*/

View File

@ -21,12 +21,13 @@ import java.util.*
data class ChatMessage(
val id: String,
val content: Spanned,
val content: Spanned?,
@SerializedName("chat_id") val chatId: String,
@SerializedName("account_id") val accountId: String,
@SerializedName("created_at") val createdAt: Date,
val attachment: Attachment?,
val emojis: List<Emoji>
val emojis: List<Emoji>,
val card: Card?
)
data class Chat(

View File

@ -24,6 +24,7 @@ import com.keylesspalace.tusky.components.chat.ChatActivity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Chat
import com.keylesspalace.tusky.entity.NewChatMessage
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ChatActionListener
import com.keylesspalace.tusky.interfaces.RefreshableFragment
@ -51,6 +52,8 @@ import javax.inject.Inject
class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, ReselectableFragment, ChatActionListener, OnRefreshListener {
private val TAG = "ChatsF" // logging tag
private val LOAD_AT_ONCE = 30
private val BROKEN_PAGINATION_IN_BACKEND = true // break pagination until it's not fixed in plemora
@Inject
lateinit var eventHub: EventHub
@ -159,7 +162,8 @@ class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, Reselecta
preferences.getBoolean("showBotOverlay", true),
false, CardViewMode.NONE,false
)
adapter = ChatsAdapter(dataSource, statusDisplayOptions, this)
adapter = ChatsAdapter(dataSource, statusDisplayOptions, this, accountManager.activeAccount!!.accountId)
}
override fun onAttach(context: Context) {
@ -177,7 +181,6 @@ class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, Reselecta
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
swipeRefreshLayout.setOnRefreshListener(this)
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
@ -198,32 +201,28 @@ class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, Reselecta
if (isNeedRefresh) onRefresh()
}
}
private fun sendInitialRequest() {
// debug
// sendFetchChatsRequest(null, null, null, FetchEnd.BOTTOM, -1)
tryCache()
}
private fun clearPlaceholdersForResponse(chats: MutableList<Either<Placeholder, Chat>>) {
chats.removeAll { it.isLeft() }
}
private fun tryCache() {
// Request timeline from disk to make it quick, then replace it with timeline from
// the server to update it
chatRepo.getChats(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { chats ->
if (chats.size > 1) {
val mutableChats = chats.toMutableList()
clearPlaceholdersForResponse(mutableChats)
this.chats.clear()
this.chats.addAll(mutableChats)
.subscribe { newChats ->
if (newChats.size > 1) {
val mutableChats = newChats.toMutableList()
mutableChats.removeAll { it.isLeft() }
chats.clear()
chats.addAll(mutableChats)
updateAdapter()
progressBar.visibility = View.GONE
// Request statuses including current top to refresh all of them
}
updateCurrent()
loadAbove()
@ -231,41 +230,45 @@ class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, Reselecta
}
private fun updateCurrent() {
if (chats.isEmpty()) {
if (!BROKEN_PAGINATION_IN_BACKEND && chats.isEmpty()) {
return
}
val topId = chats.first { it.isRight() }.asRight().id
val topId = chats.firstOrNull { it.isRight() }?.asRight()?.id
chatRepo.getChats(topId, null, null, LOAD_AT_ONCE, TimelineRequestMode.NETWORK)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe({ chats ->
initialUpdateFailed = false
// When cached timeline is too old, we would replace it with nothing
if (chats.isNotEmpty()) {
// clear old cached statuses
if(this.chats.isNotEmpty()) {
this.chats.removeAll {
if(it.isRight()) {
val chat = it.asRight()
chat.id.length < topId.length || chat.id < topId
} else {
val placeholder = it.asLeft()
placeholder.id.length < topId.length || placeholder.id < topId
}
}
.subscribe({ newChats ->
initialUpdateFailed = false
// When cached timeline is too old, we would replace it with nothing
if (newChats.isNotEmpty()) {
// clear old cached statuses
if(BROKEN_PAGINATION_IN_BACKEND) {
chats.clear()
} else {
chats.removeAll {
if(it.isLeft()) {
val p = it.asLeft()
p.id.length < topId!!.length || p.id < topId
} else {
val c = it.asRight()
c.id.length < topId!!.length || c.id < topId
}
this.chats.addAll(chats)
updateAdapter()
}
bottomLoading = false
},
{
initialUpdateFailed = true
// Indicate that we are not loading anymore
progressBar.visibility = View.GONE
swipeRefreshLayout.isRefreshing = false
})
}
chats.addAll(newChats)
updateAdapter()
}
bottomLoading = false
// Indicate that we are not loading anymore
progressBar.visibility = View.GONE
swipeRefreshLayout.isRefreshing = false
}, {
initialUpdateFailed = true
// Indicate that we are not loading anymore
progressBar.visibility = View.GONE
swipeRefreshLayout.isRefreshing = false
})
}
private fun showNothing() {
@ -377,6 +380,11 @@ class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, Reselecta
}
private fun loadAbove() {
if(BROKEN_PAGINATION_IN_BACKEND) {
updateCurrent()
return
}
var firstOrNull: String? = null
var secondOrNull: String? = null
for (i in chats.indices) {
@ -397,6 +405,10 @@ class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, Reselecta
}
private fun onLoadMore() {
if (BROKEN_PAGINATION_IN_BACKEND)
updateCurrent()
return
if (didLoadEverythingBottom || bottomLoading) {
return
}
@ -458,7 +470,7 @@ class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, Reselecta
val newIndex = newChats.indexOf(chats[0])
if (newIndex == -1) {
if (index == -1 && fullFetch) {
newChats.findLast { it.isRight() }?.let {
newChats.last { it.isRight() }.let {
val placeholderId = it.asRight().id.inc()
newChats.add(Left(Placeholder(placeholderId)))
}
@ -723,6 +735,9 @@ class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, Reselecta
}
override fun openChat(position: Int) {
if(position < 0 || position >= chats.size)
return
val chat = chats[position].asRightOrNull()
chat?.let {
bottomSheetActivity.openChat(it)

View File

@ -1,6 +1,5 @@
package com.keylesspalace.tusky.repository
import android.text.Spanned
import android.text.SpannedString
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
@ -19,17 +18,15 @@ import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import java.io.IOException
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList
typealias ChatStatus = Either<Placeholder, Chat>
typealias ChatMessageStatus = Either<Placeholder, ChatMessage>
typealias ChatMesssageOrPlaceholder = Either<Placeholder, ChatMessage>
interface ChatRepository {
fun getChats(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int,
requestMode: TimelineRequestMode): Single<out List<ChatStatus>>
fun getChatMessages(chatId: String, maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, requestMode: TimelineRequestMode) : Single<out List<ChatMessageStatus>>
fun getChatMessages(chatId: String, maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, requestMode: TimelineRequestMode) : Single<out List<ChatMesssageOrPlaceholder>>
}
class ChatRepositoryImpl(
@ -52,10 +49,16 @@ class ChatRepositoryImpl(
}
}
override fun getChatMessages(chatId: String, maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, requestMode: TimelineRequestMode) : Single<out List<ChatMessageStatus>> {
override fun getChatMessages(chatId: String, maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, requestMode: TimelineRequestMode) : Single<out List<ChatMesssageOrPlaceholder>> {
val acc = accountManager.activeAccount ?: throw IllegalStateException()
val accountId = acc.id
/*return if (requestMode == DISK) {
getChatMessagesFromDb(chatId, accountId, maxId, sinceId, limit)
} else {
getChatMessagesFromNetwork(chatId, maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode)
}*/
return getChatMessagesFromNetwork(chatId, maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode)
}
@ -82,7 +85,7 @@ class ChatRepositoryImpl(
private fun getChatMessagesFromNetwork(chatId: String, maxId: String?, sinceId: String?,
sinceIdMinusOne: String?, limit: Int,
accountId: Long, requestMode: TimelineRequestMode
): Single<out List<ChatMessageStatus>> {
): Single<out List<ChatMesssageOrPlaceholder>> {
return mastodonApi.getChatMessages(chatId, maxId, null, sinceIdMinusOne, 0, limit + 1).map {
it.mapTo(mutableListOf(), ChatMessage::lift)
}
@ -123,6 +126,7 @@ class ChatRepositoryImpl(
}
}
private fun saveChatsToDb(accountId: Long, chats: List<Chat>,
maxId: String?, sinceId: String?
): List<ChatStatus> {
@ -209,7 +213,7 @@ fun ChatMessage.toEntity(timelineUserId: Long, gson: Gson) : ChatMessageEntity {
return ChatMessageEntity(
localId = timelineUserId,
messageId = this.id,
content = this.content.toHtml(),
content = this.content?.toHtml(),
chatId = this.chatId,
accountId = this.accountId,
createdAt = this.createdAt.time,
@ -225,19 +229,20 @@ fun Chat.toEntity(timelineUserId: Long, gson: Gson): Pair<ChatEntity, ChatMessag
accountId = this.account.id,
unread = this.unread,
updatedAt = this.updatedAt.time,
lastMessageId = this.lastMessage?.let { it.id }
lastMessageId = this.lastMessage?.id
), this.lastMessage?.toEntity(timelineUserId, gson))
}
fun ChatMessageEntity.toChatMessage(gson: Gson) : ChatMessage {
return ChatMessage(
id = this.messageId,
content = this.content.parseAsHtml().trimTrailingWhitespace(),
content = this.content?.let { it.parseAsHtml().trimTrailingWhitespace() },
chatId = this.chatId,
accountId = this.accountId,
createdAt = Date(this.createdAt),
attachment = this.attachment?.let { gson.fromJson(it, Attachment::class.java) },
emojis = gson.fromJson(this.emojis, object : TypeToken<List<Emoji>>() {}.type )
emojis = gson.fromJson(this.emojis, object : TypeToken<List<Emoji>>() {}.type ),
card = null /* don't care about card */
)
}
@ -254,6 +259,6 @@ fun ChatEntityWithAccount.toChat(gson: Gson) : ChatStatus {
).lift()
}
fun ChatMessage.lift(): ChatMessageStatus = Either.Right(this)
fun ChatMessage.lift(): ChatMesssageOrPlaceholder = Either.Right(this)
fun Chat.lift(): ChatStatus = Either.Right(this)

View File

@ -104,7 +104,8 @@ public final class ViewDataUtils {
msg.getAccountId(),
msg.getCreatedAt(),
msg.getAttachment(),
msg.getEmojis()
msg.getEmojis(),
msg.getCard()
);
}

View File

@ -1,12 +1,15 @@
package com.keylesspalace.tusky.viewdata
import android.text.Spanned
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Card
import com.keylesspalace.tusky.entity.Emoji
import java.util.*
abstract class ChatViewData {
abstract fun getViewDataId() : Long
abstract fun getViewDataId() : Int
abstract fun deepEquals(o: ChatViewData) : Boolean
class Concrete(val account : Account,
@ -14,8 +17,8 @@ abstract class ChatViewData {
val unread: Long,
val lastMessage: ChatMessageViewData.Concrete?,
val updatedAt: Date ) : ChatViewData() {
override fun getViewDataId(): Long {
return id.hashCode().toLong()
override fun getViewDataId(): Int {
return id.hashCode()
}
override fun deepEquals(o: ChatViewData): Boolean {
@ -30,34 +33,55 @@ abstract class ChatViewData {
override fun hashCode(): Int {
return Objects.hash(account, id, unread, lastMessage, updatedAt)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return deepEquals(other as Concrete)
}
}
class Placeholder(val id: String, val isLoading: Boolean) : ChatViewData() {
override fun getViewDataId(): Long {
return id.hashCode().toLong()
override fun getViewDataId(): Int {
return id.hashCode()
}
override fun deepEquals(o: ChatViewData): Boolean {
if( o !is Placeholder ) return false
return o.isLoading == isLoading && o.id == id
}
override fun hashCode(): Int {
var result = if (isLoading) 1 else 0
result = 31 * result + id.hashCode()
return result
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return deepEquals(other as Placeholder)
}
}
}
abstract class ChatMessageViewData {
abstract fun getViewDataId() : Long
abstract fun getViewDataId() : Int
abstract fun deepEquals(o: ChatMessageViewData) : Boolean
class Concrete(val id: String,
val content: Spanned,
val content: Spanned?,
val chatId: String,
val accountId: String,
val createdAt: Date,
val attachment: Attachment?,
val emojis: List<Emoji>) : ChatMessageViewData()
val emojis: List<Emoji>,
val card: Card?) : ChatMessageViewData()
{
override fun getViewDataId(): Long {
return id.hashCode().toLong()
override fun getViewDataId(): Int {
return id.hashCode()
}
override fun deepEquals(o: ChatMessageViewData): Boolean {
@ -70,21 +94,42 @@ abstract class ChatMessageViewData {
&& Objects.equals(o.createdAt, createdAt)
&& Objects.equals(o.attachment, attachment)
&& Objects.equals(o.emojis, emojis)
&& Objects.equals(o.card, card)
}
override fun hashCode() : Int {
return Objects.hash(id, content, chatId, accountId, createdAt, attachment)
return Objects.hash(id, content, chatId, accountId, createdAt, attachment, card)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return deepEquals(other as Concrete)
}
}
class Placeholder(val id: String, val isLoading: Boolean) : ChatMessageViewData() {
override fun getViewDataId(): Long {
return id.hashCode().toLong()
override fun getViewDataId(): Int {
return id.hashCode()
}
override fun deepEquals(o: ChatMessageViewData): Boolean {
if( o !is Placeholder) return false
return o.isLoading == isLoading && o.id == id
}
override fun hashCode(): Int {
var result = if (isLoading) 1 else 0
result = 31 * result + id.hashCode()
return result
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return deepEquals(other as Placeholder)
}
}
}

View File

@ -157,7 +157,7 @@
android:textSize="?attr/status_text_large"
android:singleLine="false"
android:background="@null"
android:hint="@string/chat_message_hint_text"
android:hint="@string/hint_compose"
app:layout_constraintEnd_toStartOf="@+id/emojiButton"
app:layout_constraintRight_toLeftOf="@id/emojiButton"
app:layout_constraintStart_toEndOf="@+id/attachmentButton"

View File

@ -5,29 +5,31 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="@dimen/chat_between_messages_padding"
android:paddingBottom="@dimen/chat_between_messages_padding"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="@dimen/chat_message_h_padding"
android:layout_gravity="end">
android:layout_marginEnd="@dimen/chat_message_h_padding"
android:layout_marginBottom="@dimen/chat_message_v_padding">
<LinearLayout
android:id="@+id/contentLayout"
android:layout_width="wrap_content"
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/message_background"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
android:backgroundTint="@color/colorBackgroundAccent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
app:layout_constraintWidth_max="wrap"
app:layout_constraintWidth_percent="0.8"
>
<FrameLayout
android:id="@+id/attachmentLayout"
android:layout_width="match_parent"
android:layout_width="@dimen/chat_message_max_width"
android:layout_height="@dimen/chat_media_preview_item_height"
android:layout_marginTop="@dimen/chat_message_h_padding"
android:visibility="gone"
tools:visibility="visible">
tools:visibility="visible"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
>
<com.keylesspalace.tusky.view.MediaPreviewImageView
android:id="@+id/attachment"
@ -42,32 +44,33 @@
android:scaleType="center"
app:srcCompat="@drawable/ic_play_indicator"
tools:ignore="ContentDescription" />
</FrameLayout>
<androidx.emoji.widget.EmojiTextView
android:id="@+id/content"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingTop="@dimen/chat_message_v_padding"
android:paddingEnd="@dimen/chat_message_h_padding"
android:paddingBottom="@dimen/chat_message_v_padding"
android:textColor="@color/textColorPrimary"
android:textSize="?attr/status_text_large"
tools:text="AAAAAAAA" />
</LinearLayout>
<TextView
android:id="@+id/datetime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="@dimen/chat_message_h_padding"
app:layout_constraintEnd_toStartOf="@id/contentLayout"
app:layout_constraintBottom_toBottomOf="@id/contentLayout"
tools:text="12:39"
/>
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="50dp"
android:paddingBottom="@dimen/chat_message_v_padding"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintBottom_toBottomOf="@id/datetime"
app:layout_constraintStart_toStartOf="parent"
tools:text="MeowMeowMeow" />
<TextView
android:id="@+id/datetime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="@dimen/chat_message_h_padding"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:text="12:39"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -5,38 +5,37 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="@dimen/chat_between_messages_padding"
android:paddingBottom="@dimen/chat_between_messages_padding"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="@dimen/chat_message_h_padding"
android:layout_gravity="start">
android:layout_marginStart="@dimen/chat_message_h_padding"
android:layout_marginBottom="@dimen/chat_message_h_padding">
<LinearLayout
android:id="@+id/contentLayout"
android:layout_width="wrap_content"
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/message_background"
android:backgroundTint="?attr/colorPrimaryDark"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/datetime"
app:layout_constraintWidth_max="wrap"
app:layout_constraintWidth_percent="0.8"
>
<FrameLayout
android:id="@+id/attachmentLayout"
android:layout_width="match_parent"
android:layout_width="@dimen/chat_message_max_width"
android:layout_height="@dimen/chat_media_preview_item_height"
android:layout_marginTop="@dimen/chat_message_h_padding"
android:visibility="gone"
tools:visibility="visible">
tools:visibility="visible"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
>
<com.keylesspalace.tusky.view.MediaPreviewImageView
android:id="@+id/attachment"
android:layout_width="match_parent"
android:layout_height="@dimen/chat_media_preview_item_height"
tools:src="@drawable/elephant_friend_empty"
/>
tools:src="@drawable/elephant_friend_empty" />
<ImageView
android:id="@+id/mediaOverlay"
@ -45,32 +44,33 @@
android:scaleType="center"
app:srcCompat="@drawable/ic_play_indicator"
tools:ignore="ContentDescription" />
</FrameLayout>
<androidx.emoji.widget.EmojiTextView
android:id="@+id/content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingTop="@dimen/chat_message_v_padding"
android:paddingEnd="@dimen/chat_message_h_padding"
android:paddingBottom="@dimen/chat_message_v_padding"
android:textSize="?attr/status_text_large"
android:textColor="@color/textColorPrimary"
app:layout_constrainedWidth="true"
tools:text="What you guys are referring to as Linux, is in fact, GNU/Linux, or as I've recently taken to calling it, GNU plus Linux. Linux is not an operating system unto itself, but rather another free component of a fully functioning GNU system made useful by the GNU corelibs, shell utilities and vital system components comprising a full OS as defined by POSIX. Many computer users run a modified version of the GNU system every day, without realizing it. Through a peculiar turn of events, the version of GNU which is widely used today is often called Linux, and many of its users are not aware that it is basically the GNU system, developed by the GNU Project. There really is a Linux, and these people are using it, but it is just a part of the system they use. Linux is the kernel: the program in the system that allocates the machine's resources to the other programs that you run. The kernel is an essential part of an operating system, but useless by itself; it can only function in the context of a complete operating system. Linux is normally used in combination with the GNU operating system: the whole system is basically GNU with Linux added, or GNU/Linux. All the so-called Linux distributions are really distributions of GNU/Linux. Thank you for taking your time to cooperate with with me, your friendly GNU+Linux neighbor, Richard Stallman." />
</LinearLayout>
android:textSize="?attr/status_text_large"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="50dp"
android:paddingBottom="@dimen/chat_message_v_padding"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintBottom_toBottomOf="@id/datetime"
app:layout_constraintStart_toStartOf="parent"
tools:text="MeowMeowMeow" />
<TextView
android:id="@+id/datetime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="@dimen/chat_message_h_padding"
app:layout_constraintStart_toEndOf="@id/contentLayout"
app:layout_constraintBottom_toBottomOf="@id/contentLayout"
tools:text="12:39"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/datetime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="@dimen/chat_message_h_padding"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:text="12:39"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -62,4 +62,5 @@
<dimen name="chat_small_padding">8dp</dimen>
<dimen name="chat_radius_fix">12dp</dimen>
<dimen name="chat_media_preview_item_height">160dp</dimen>
<dimen name="chat_message_max_width">300dp</dimen>
</resources>