mirror of
https://github.com/sudoxnym/habitica-android.git
synced 2026-05-19 20:29:02 +00:00
Implement pagination in inbox. Fixes #1205
This commit is contained in:
parent
a9b1a54dbc
commit
5e6f4fe45b
20 changed files with 604 additions and 321 deletions
|
|
@ -132,7 +132,10 @@ dependencies {
|
|||
implementation "androidx.lifecycle:lifecycle-common-java8:2.0.0"
|
||||
implementation 'android.arch.navigation:navigation-fragment-ktx:1.0.0'
|
||||
implementation 'android.arch.navigation:navigation-ui-ktx:1.0.0'
|
||||
implementation "androidx.paging:paging-runtime-ktx:2.1.0"
|
||||
implementation 'com.plattysoft.leonids:LeonidsLib:1.3.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0'
|
||||
|
||||
implementation project(':shared')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:fresco="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:parentTag="FrameLayout">
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.habitrpg.android.habitica.ui.views.NPCBannerView
|
||||
android:id="@+id/npcBannerView"
|
||||
|
|
@ -39,4 +38,4 @@
|
|||
android:layout_marginBottom="4dp"
|
||||
style="@style/Body1"
|
||||
tools:text="Welcome to the Market! Stock up on new gear or buy rare eggs and potions. Check in periodically for new stock." />
|
||||
</merge>
|
||||
</FrameLayout>
|
||||
|
|
@ -154,7 +154,7 @@
|
|||
android:label="@string/sidebar_about" />
|
||||
<fragment
|
||||
android:id="@+id/inboxFragment"
|
||||
android:name="com.habitrpg.android.habitica.ui.fragments.social.InboxFragment"
|
||||
android:name="com.habitrpg.android.habitica.ui.fragments.social.InboxOverviewFragment"
|
||||
android:label="@string/sidebar_inbox" >
|
||||
<action
|
||||
android:id="@+id/openInboxDetail"
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ import com.habitrpg.android.habitica.models.members.Member
|
|||
import com.habitrpg.android.habitica.models.responses.*
|
||||
import com.habitrpg.android.habitica.models.shops.Shop
|
||||
import com.habitrpg.android.habitica.models.shops.ShopItem
|
||||
import com.habitrpg.android.habitica.models.social.Challenge
|
||||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import com.habitrpg.android.habitica.models.social.FindUsernameResult
|
||||
import com.habitrpg.android.habitica.models.social.Group
|
||||
import com.habitrpg.android.habitica.models.social.*
|
||||
import com.habitrpg.android.habitica.models.tasks.Task
|
||||
import com.habitrpg.android.habitica.models.tasks.TaskList
|
||||
import com.habitrpg.android.habitica.models.user.Items
|
||||
|
|
@ -33,8 +30,10 @@ interface ApiService {
|
|||
val user: Flowable<HabitResponse<User>>
|
||||
|
||||
|
||||
@get:GET("inbox/messages")
|
||||
val inboxMessages: Flowable<HabitResponse<List<ChatMessage>>>
|
||||
@GET("inbox/messages")
|
||||
fun getInboxMessages(@Query("conversation") uuid: String, @Query("page") page: Int): Flowable<HabitResponse<List<ChatMessage>>>
|
||||
@GET("inbox/conversations")
|
||||
fun getInboxConversations(): Flowable<HabitResponse<List<InboxConversation>>>
|
||||
|
||||
|
||||
@get:GET("tasks/user")
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ import com.habitrpg.android.habitica.ui.fragments.social.GroupInformationFragmen
|
|||
import com.habitrpg.android.habitica.ui.fragments.social.GuildDetailFragment;
|
||||
import com.habitrpg.android.habitica.ui.fragments.social.GuildFragment;
|
||||
import com.habitrpg.android.habitica.ui.fragments.social.GuildsOverviewFragment;
|
||||
import com.habitrpg.android.habitica.ui.fragments.social.InboxFragment;
|
||||
import com.habitrpg.android.habitica.ui.fragments.social.InboxOverviewFragment;
|
||||
import com.habitrpg.android.habitica.ui.fragments.social.InboxMessageListFragment;
|
||||
import com.habitrpg.android.habitica.ui.fragments.social.PublicGuildsFragment;
|
||||
import com.habitrpg.android.habitica.ui.fragments.social.QuestDetailFragment;
|
||||
|
|
@ -92,6 +92,7 @@ import com.habitrpg.android.habitica.ui.fragments.social.party.PartyInviteFragme
|
|||
import com.habitrpg.android.habitica.ui.fragments.tasks.TaskRecyclerViewFragment;
|
||||
import com.habitrpg.android.habitica.ui.fragments.tasks.TasksFragment;
|
||||
import com.habitrpg.android.habitica.ui.viewmodels.GroupViewModel;
|
||||
import com.habitrpg.android.habitica.ui.viewmodels.InboxViewModel;
|
||||
import com.habitrpg.android.habitica.ui.viewmodels.NotificationsViewModel;
|
||||
import com.habitrpg.android.habitica.ui.views.shops.PurchaseDialog;
|
||||
import com.habitrpg.android.habitica.ui.views.social.ChatBarView;
|
||||
|
|
@ -200,7 +201,7 @@ public interface UserComponent {
|
|||
|
||||
void inject(PreferencesFragment preferencesFragment);
|
||||
|
||||
void inject(InboxFragment inboxFragment);
|
||||
void inject(InboxOverviewFragment inboxFragment);
|
||||
|
||||
void inject(InboxMessageListFragment inboxMessageListFragment);
|
||||
|
||||
|
|
@ -313,4 +314,6 @@ public interface UserComponent {
|
|||
void inject(@NotNull GuildDetailFragment guildDetailFragment);
|
||||
|
||||
void inject(@NotNull AchievementsFragment achievementsFragment);
|
||||
|
||||
void inject(@NotNull InboxViewModel inboxViewModel);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,7 @@ import com.habitrpg.android.habitica.models.members.Member
|
|||
import com.habitrpg.android.habitica.models.responses.*
|
||||
import com.habitrpg.android.habitica.models.shops.Shop
|
||||
import com.habitrpg.android.habitica.models.shops.ShopItem
|
||||
import com.habitrpg.android.habitica.models.social.Challenge
|
||||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import com.habitrpg.android.habitica.models.social.FindUsernameResult
|
||||
import com.habitrpg.android.habitica.models.social.Group
|
||||
import com.habitrpg.android.habitica.models.social.*
|
||||
import com.habitrpg.android.habitica.models.tasks.Task
|
||||
import com.habitrpg.android.habitica.models.tasks.TaskList
|
||||
import com.habitrpg.android.habitica.models.user.Items
|
||||
|
|
@ -223,7 +220,8 @@ interface ApiClient {
|
|||
fun hasAuthenticationKeys(): Boolean
|
||||
|
||||
fun retrieveUser(withTasks: Boolean): Flowable<User>
|
||||
fun retrieveInboxMessages(): Flowable<List<ChatMessage>>
|
||||
fun retrieveInboxMessages(uuid: String, page: Int): Flowable<List<ChatMessage>>
|
||||
fun retrieveInboxConversations(): Flowable<List<InboxConversation>>
|
||||
|
||||
fun <T> configureApiCallObserver(): FlowableTransformer<HabitResponse<T>, T>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ import com.habitrpg.android.habitica.models.Achievement
|
|||
import com.habitrpg.android.habitica.models.inventory.Quest
|
||||
import com.habitrpg.android.habitica.models.members.Member
|
||||
import com.habitrpg.android.habitica.models.responses.PostChatMessageResult
|
||||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import com.habitrpg.android.habitica.models.social.FindUsernameResult
|
||||
import com.habitrpg.android.habitica.models.social.Group
|
||||
import com.habitrpg.android.habitica.models.social.GroupMembership
|
||||
import com.habitrpg.android.habitica.models.social.*
|
||||
import com.habitrpg.android.habitica.models.user.User
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.Single
|
||||
|
|
@ -47,9 +44,10 @@ interface SocialRepository : BaseRepository {
|
|||
|
||||
|
||||
fun getInboxMessages(replyToUserID: String?): Flowable<RealmResults<ChatMessage>>
|
||||
fun retrieveInboxMessages(): Flowable<List<ChatMessage>>
|
||||
fun getInboxOverviewList(): Flowable<RealmResults<ChatMessage>>
|
||||
fun postPrivateMessage(messageObject: HashMap<String, String>): Flowable<List<ChatMessage>>
|
||||
fun retrieveInboxMessages(uuid: String, page: Int): Flowable<List<ChatMessage>>
|
||||
fun retrieveInboxConversations(): Flowable<List<InboxConversation>>
|
||||
fun getInboxConversations(): Flowable<RealmResults<InboxConversation>>
|
||||
fun postPrivateMessage(recipientId: String, messageObject: HashMap<String, String>): Flowable<List<ChatMessage>>
|
||||
fun postPrivateMessage(recipientId: String, message: String): Flowable<List<ChatMessage>>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,10 +25,7 @@ import com.habitrpg.android.habitica.models.members.Member
|
|||
import com.habitrpg.android.habitica.models.responses.*
|
||||
import com.habitrpg.android.habitica.models.shops.Shop
|
||||
import com.habitrpg.android.habitica.models.shops.ShopItem
|
||||
import com.habitrpg.android.habitica.models.social.Challenge
|
||||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import com.habitrpg.android.habitica.models.social.FindUsernameResult
|
||||
import com.habitrpg.android.habitica.models.social.Group
|
||||
import com.habitrpg.android.habitica.models.social.*
|
||||
import com.habitrpg.android.habitica.models.tasks.Task
|
||||
import com.habitrpg.android.habitica.models.tasks.TaskList
|
||||
import com.habitrpg.android.habitica.models.user.Items
|
||||
|
|
@ -245,8 +242,12 @@ class ApiClientImpl//private OnHabitsAPIResult mResultListener;
|
|||
return userObservable
|
||||
}
|
||||
|
||||
override fun retrieveInboxMessages(): Flowable<List<ChatMessage>> {
|
||||
return apiService.inboxMessages.compose(configureApiCallObserver())
|
||||
override fun retrieveInboxMessages(uuid: String, page: Int): Flowable<List<ChatMessage>> {
|
||||
return apiService.getInboxMessages(uuid, page).compose(configureApiCallObserver())
|
||||
}
|
||||
|
||||
override fun retrieveInboxConversations(): Flowable<List<InboxConversation>> {
|
||||
return apiService.getInboxConversations().compose(configureApiCallObserver())
|
||||
}
|
||||
|
||||
override fun hasAuthenticationKeys(): Boolean {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@ import com.habitrpg.android.habitica.models.Achievement
|
|||
import com.habitrpg.android.habitica.models.inventory.Quest
|
||||
import com.habitrpg.android.habitica.models.members.Member
|
||||
import com.habitrpg.android.habitica.models.responses.PostChatMessageResult
|
||||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import com.habitrpg.android.habitica.models.social.FindUsernameResult
|
||||
import com.habitrpg.android.habitica.models.social.Group
|
||||
import com.habitrpg.android.habitica.models.social.GroupMembership
|
||||
import com.habitrpg.android.habitica.models.social.*
|
||||
import com.habitrpg.android.habitica.models.user.User
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.Single
|
||||
|
|
@ -201,28 +198,34 @@ class SocialRepositoryImpl(localRepository: SocialLocalRepository, apiClient: Ap
|
|||
|
||||
override fun getPublicGuilds(): Flowable<RealmResults<Group>> = localRepository.getPublicGuilds()
|
||||
|
||||
override fun getInboxOverviewList(): Flowable<RealmResults<ChatMessage>> = localRepository.getInboxOverviewList(userID)
|
||||
override fun getInboxConversations(): Flowable<RealmResults<InboxConversation>> = localRepository.getInboxConversation(userID)
|
||||
|
||||
override fun getInboxMessages(replyToUserID: String?): Flowable<RealmResults<ChatMessage>> = localRepository.getInboxMessages(userID, replyToUserID)
|
||||
|
||||
override fun retrieveInboxMessages(): Flowable<List<ChatMessage>> {
|
||||
return apiClient.retrieveInboxMessages().doOnNext { messages ->
|
||||
override fun retrieveInboxMessages(uuid: String, page: Int): Flowable<List<ChatMessage>> {
|
||||
return apiClient.retrieveInboxMessages(uuid, page).doOnNext { messages ->
|
||||
messages.forEach {
|
||||
it.isInboxMessage = true
|
||||
}
|
||||
localRepository.saveInboxMessages(userID, messages)
|
||||
localRepository.saveInboxMessages(userID, uuid, messages, page)
|
||||
}
|
||||
}
|
||||
|
||||
override fun postPrivateMessage(messageObject: HashMap<String, String>): Flowable<List<ChatMessage>> {
|
||||
return apiClient.postPrivateMessage(messageObject).flatMap { retrieveInboxMessages() }
|
||||
override fun retrieveInboxConversations(): Flowable<List<InboxConversation>> {
|
||||
return apiClient.retrieveInboxConversations().doOnNext { conversations ->
|
||||
localRepository.saveInboxConversations(userID, conversations)
|
||||
}
|
||||
}
|
||||
|
||||
override fun postPrivateMessage(recipientId: String, messageObject: HashMap<String, String>): Flowable<List<ChatMessage>> {
|
||||
return apiClient.postPrivateMessage(messageObject).flatMap { retrieveInboxMessages(recipientId, 0) }
|
||||
}
|
||||
|
||||
override fun postPrivateMessage(recipientId: String, message: String): Flowable<List<ChatMessage>> {
|
||||
val messageObject = HashMap<String, String>()
|
||||
messageObject["message"] = message
|
||||
messageObject["toUserId"] = recipientId
|
||||
return postPrivateMessage(messageObject)
|
||||
return postPrivateMessage(recipientId, messageObject)
|
||||
}
|
||||
|
||||
override fun getGroupMembers(id: String): Flowable<RealmResults<Member>> = localRepository.getGroupMembers(id)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.habitrpg.android.habitica.models.members.Member
|
|||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import com.habitrpg.android.habitica.models.social.Group
|
||||
import com.habitrpg.android.habitica.models.social.GroupMembership
|
||||
import com.habitrpg.android.habitica.models.social.InboxConversation
|
||||
import com.habitrpg.android.habitica.models.user.User
|
||||
import io.reactivex.Flowable
|
||||
import io.realm.RealmResults
|
||||
|
|
@ -42,8 +43,9 @@ interface SocialLocalRepository : BaseLocalRepository {
|
|||
|
||||
fun getInboxMessages(userId: String, replyToUserID: String?): Flowable<RealmResults<ChatMessage>>
|
||||
|
||||
fun getInboxOverviewList(userId: String): Flowable<RealmResults<ChatMessage>>
|
||||
fun getInboxConversation(userId: String): Flowable<RealmResults<InboxConversation>>
|
||||
fun saveGroupMemberships(userID: String?, memberships: List<GroupMembership>)
|
||||
fun saveInboxMessages(userID: String, messages: List<ChatMessage>)
|
||||
fun saveInboxMessages(userID: String, recipientID: String, messages: List<ChatMessage>, page: Int)
|
||||
fun saveInboxConversations(userID: String, conversations: List<InboxConversation>)
|
||||
fun getChatMessage(messageID: String): Flowable<ChatMessage>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,9 +49,11 @@ class RealmSocialLocalRepository(realm: Realm) : RealmBaseLocalRepository(realm)
|
|||
}
|
||||
}
|
||||
|
||||
override fun saveInboxMessages(userID: String, messages: List<ChatMessage>) {
|
||||
override fun saveInboxMessages(userID: String, recipientID: String, messages: List<ChatMessage>, page: Int) {
|
||||
messages.forEach { it.userID = userID }
|
||||
realm.executeTransaction { realm.insertOrUpdate(messages) }
|
||||
val existingMessages = realm.where(ChatMessage::class.java).equalTo("isInboxMessage", true).findAll()
|
||||
if (page != 0) return
|
||||
val existingMessages = realm.where(ChatMessage::class.java).equalTo("isInboxMessage", true).equalTo("uuid", recipientID).findAll()
|
||||
val messagesToRemove = ArrayList<ChatMessage>()
|
||||
for (existingMessage in existingMessages) {
|
||||
val isStillMember = messages.any { existingMessage.id == it.id }
|
||||
|
|
@ -64,6 +66,22 @@ class RealmSocialLocalRepository(realm: Realm) : RealmBaseLocalRepository(realm)
|
|||
}
|
||||
}
|
||||
|
||||
override fun saveInboxConversations(userID: String, conversations: List<InboxConversation>) {
|
||||
conversations.forEach { it.userID = userID }
|
||||
realm.executeTransaction { realm.insertOrUpdate(conversations) }
|
||||
val existingConversations = realm.where(InboxConversation::class.java).findAll()
|
||||
val conversationsToRemove = ArrayList<InboxConversation>()
|
||||
for (existingMessage in existingConversations) {
|
||||
val isStillMember = conversations.any { existingMessage.uuid == it.uuid }
|
||||
if (!isStillMember) {
|
||||
conversationsToRemove.add(existingMessage)
|
||||
}
|
||||
}
|
||||
realm.executeTransaction {
|
||||
conversationsToRemove.forEach { it.deleteFromRealm() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun saveGroupMemberships(userID: String?, memberships: List<GroupMembership>) {
|
||||
realm.executeTransaction { realm.insertOrUpdate(memberships) }
|
||||
if (userID != null) {
|
||||
|
|
@ -238,21 +256,21 @@ class RealmSocialLocalRepository(realm: Realm) : RealmBaseLocalRepository(realm)
|
|||
return party != null && party.isValid
|
||||
}
|
||||
|
||||
override fun getInboxMessages(userId: String, replyToUserID: String?): Flowable<RealmResults<ChatMessage>> {
|
||||
override fun getInboxMessages(userID: String, replyToUserID: String?): Flowable<RealmResults<ChatMessage>> {
|
||||
return realm.where(ChatMessage::class.java)
|
||||
.equalTo("isInboxMessage", true)
|
||||
.equalTo("uuid", replyToUserID)
|
||||
.equalTo("userID", userID)
|
||||
.sort("timestamp", Sort.DESCENDING)
|
||||
.findAll()
|
||||
.asFlowable()
|
||||
.filter { it.isLoaded }
|
||||
}
|
||||
|
||||
override fun getInboxOverviewList(userId: String): Flowable<RealmResults<ChatMessage>> {
|
||||
return realm.where(ChatMessage::class.java)
|
||||
.equalTo("isInboxMessage", true)
|
||||
override fun getInboxConversation(userID: String): Flowable<RealmResults<InboxConversation>> {
|
||||
return realm.where(InboxConversation::class.java)
|
||||
.equalTo("userID", userID)
|
||||
.sort("timestamp", Sort.DESCENDING)
|
||||
.distinct("uuid")
|
||||
.findAll()
|
||||
.asFlowable()
|
||||
.filter { it.isLoaded }
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
package com.habitrpg.android.habitica.extensions
|
||||
|
||||
import android.content.res.Resources
|
||||
import com.habitrpg.android.habitica.R
|
||||
import java.util.*
|
||||
|
||||
class DateUtils {
|
||||
|
||||
companion object {
|
||||
fun minutesInMs(minutes: Int): Int {
|
||||
private fun minutesInMs(minutes: Int): Int {
|
||||
return minutes * 60 * 1000
|
||||
}
|
||||
|
||||
|
|
@ -13,5 +17,25 @@ class DateUtils {
|
|||
}
|
||||
}
|
||||
|
||||
fun Date.getAgoString(res: Resources): String {
|
||||
return this.time.getAgoString(res)
|
||||
}
|
||||
|
||||
fun Long.getAgoString(res: Resources): String {
|
||||
val diff = Date().time - this
|
||||
|
||||
val diffMinutes = diff / (60 * 1000) % 60
|
||||
val diffHours = diff / (60 * 60 * 1000) % 24
|
||||
val diffDays = diff / (24 * 60 * 60 * 1000)
|
||||
|
||||
return when {
|
||||
diffDays != 0L -> if (diffDays == 1L) {
|
||||
res.getString(R.string.ago_1day)
|
||||
} else res.getString(R.string.ago_days, diffDays)
|
||||
diffHours != 0L -> if (diffHours == 1L) {
|
||||
res.getString(R.string.ago_1hour)
|
||||
} else res.getString(R.string.ago_hours, diffHours)
|
||||
diffMinutes == 1L -> res.getString(R.string.ago_1Minute)
|
||||
else -> res.getString(R.string.ago_minutes, diffMinutes)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
package com.habitrpg.android.habitica.models.social
|
||||
|
||||
import android.content.res.Resources
|
||||
|
||||
import com.habitrpg.android.habitica.R
|
||||
import com.habitrpg.android.habitica.models.user.ContributorInfo
|
||||
|
||||
import java.util.Date
|
||||
|
||||
import io.realm.RealmList
|
||||
import io.realm.RealmObject
|
||||
import io.realm.annotations.Ignore
|
||||
|
|
@ -35,6 +29,7 @@ open class ChatMessage : RealmObject() {
|
|||
var flagCount: Int = 0
|
||||
|
||||
var uuid: String? = null
|
||||
var userID: String? = null
|
||||
|
||||
var contributor: ContributorInfo? = null
|
||||
|
||||
|
|
@ -60,25 +55,6 @@ open class ChatMessage : RealmObject() {
|
|||
val formattedUsername: String?
|
||||
get() = if (username != null) "@$username" else null
|
||||
|
||||
fun getAgoString(res: Resources): String {
|
||||
val diff = Date().time - (timestamp ?: 0)
|
||||
|
||||
val diffMinutes = diff / (60 * 1000) % 60
|
||||
val diffHours = diff / (60 * 60 * 1000) % 24
|
||||
val diffDays = diff / (24 * 60 * 60 * 1000)
|
||||
|
||||
return when {
|
||||
diffDays != 0L -> if (diffDays == 1L) {
|
||||
res.getString(R.string.ago_1day)
|
||||
} else res.getString(R.string.ago_days, diffDays)
|
||||
diffHours != 0L -> if (diffHours == 1L) {
|
||||
res.getString(R.string.ago_1hour)
|
||||
} else res.getString(R.string.ago_hours, diffHours)
|
||||
diffMinutes == 1L -> res.getString(R.string.ago_1Minute)
|
||||
else -> res.getString(R.string.ago_minutes, diffMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
fun userLikesMessage(userId: String?): Boolean {
|
||||
return likes?.any { userId == it.id } ?: false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
package com.habitrpg.android.habitica.models.social
|
||||
|
||||
import com.habitrpg.android.habitica.models.user.ContributorInfo
|
||||
import io.realm.RealmObject
|
||||
import io.realm.annotations.PrimaryKey
|
||||
import java.util.*
|
||||
|
||||
open class InboxConversation : RealmObject() {
|
||||
|
||||
@PrimaryKey
|
||||
var combinedID: String = ""
|
||||
var uuid: String = ""
|
||||
set(value) {
|
||||
field = value
|
||||
combinedID = userID + value
|
||||
}
|
||||
var userID: String = ""
|
||||
set(value) {
|
||||
field = value
|
||||
combinedID = value + uuid
|
||||
}
|
||||
var username: String? = null
|
||||
var user: String? = null
|
||||
var timestamp: Date? = null
|
||||
var contributor: ContributorInfo? = null
|
||||
var userStyles: UserStyles? = null
|
||||
var text: String? = null
|
||||
|
||||
val formattedUsername: String?
|
||||
get() = if (username?.isNotEmpty() == true) "@$username" else null
|
||||
}
|
||||
|
|
@ -1,35 +1,17 @@
|
|||
package com.habitrpg.android.habitica.ui.adapter.social
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.habitrpg.android.habitica.R
|
||||
import com.habitrpg.android.habitica.extensions.dpToPx
|
||||
import com.habitrpg.android.habitica.extensions.inflate
|
||||
import com.habitrpg.android.habitica.extensions.setScaledPadding
|
||||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import com.habitrpg.android.habitica.models.user.User
|
||||
import com.habitrpg.android.habitica.ui.AvatarView
|
||||
import com.habitrpg.android.habitica.ui.helpers.DataBindingUtils
|
||||
import com.habitrpg.android.habitica.ui.helpers.MarkdownParser
|
||||
import com.habitrpg.android.habitica.ui.helpers.bindView
|
||||
import com.habitrpg.android.habitica.ui.views.HabiticaEmojiTextView
|
||||
import com.habitrpg.android.habitica.ui.views.HabiticaIconsHelper
|
||||
import com.habitrpg.android.habitica.ui.views.social.UsernameLabel
|
||||
import com.habitrpg.android.habitica.ui.viewHolders.ChatRecyclerViewHolder
|
||||
import io.reactivex.BackpressureStrategy
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.Maybe
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import io.reactivex.subjects.PublishSubject
|
||||
import io.realm.OrderedRealmCollection
|
||||
import io.realm.RealmRecyclerViewAdapter
|
||||
|
|
@ -63,11 +45,23 @@ class ChatRecyclerViewAdapter(data: OrderedRealmCollection<ChatMessage>?, autoUp
|
|||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
data?.let {
|
||||
if (it[position].isSystemMessage) {
|
||||
(holder as? SystemChatMessageViewHolder)?.bind(it[position])
|
||||
data?.let { data ->
|
||||
if (data[position].isSystemMessage) {
|
||||
(holder as? SystemChatMessageViewHolder)?.bind(data[position])
|
||||
} else {
|
||||
(holder as? ChatRecyclerViewHolder)?.bind(it[position], uuid)
|
||||
val chatHolder = holder as? ChatRecyclerViewHolder ?: return
|
||||
val message = data[position]
|
||||
chatHolder.bind(message,
|
||||
uuid,
|
||||
user,
|
||||
expandedMessageId == message.id)
|
||||
chatHolder.onShouldExpand = { expandMessage(message.id, position) }
|
||||
chatHolder.onLikeMessage = { likeMessageEvents.onNext(it) }
|
||||
chatHolder.onOpenProfile = { userLabelClickEvents.onNext(it) }
|
||||
chatHolder.onReply = { replyMessageEvents.onNext(it) }
|
||||
chatHolder.onCopyMessage = { copyMessageEvents.onNext(it) }
|
||||
chatHolder.onFlagMessage = { flagMessageEvents.onNext(it) }
|
||||
chatHolder.onDeleteMessage = { deleteMessageEvents.onNext(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -100,195 +94,21 @@ class ChatRecyclerViewAdapter(data: OrderedRealmCollection<ChatMessage>?, autoUp
|
|||
return copyMessageEvents.toFlowable(BackpressureStrategy.DROP)
|
||||
}
|
||||
|
||||
inner class SystemChatMessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val textView: TextView by bindView(R.id.text_view)
|
||||
|
||||
fun bind(chatMessage: ChatMessage?) {
|
||||
textView.text = chatMessage?.text?.removePrefix("`")?.removeSuffix("`")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
inner class ChatRecyclerViewHolder(itemView: View, private var userId: String, private val isTavern: Boolean) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
private val messageWrapper: ViewGroup by bindView(R.id.message_wrapper)
|
||||
private val avatarView: AvatarView by bindView(R.id.avatar_view)
|
||||
private val userLabel: UsernameLabel by bindView(R.id.user_label)
|
||||
private val messageText: HabiticaEmojiTextView by bindView(R.id.message_text)
|
||||
private val sublineTextView: TextView by bindView(R.id.subline_textview)
|
||||
private val likeBackground: LinearLayout by bindView(R.id.like_background_layout)
|
||||
private val tvLikes: TextView by bindView(R.id.tvLikes)
|
||||
private val buttonsWrapper: ViewGroup by bindView(R.id.buttons_wrapper)
|
||||
private val replyButton: Button by bindView(R.id.reply_button)
|
||||
private val copyButton: Button by bindView(R.id.copy_button)
|
||||
private val reportButton: Button by bindView(R.id.report_button)
|
||||
private val deleteButton: Button by bindView(R.id.delete_button)
|
||||
private val modView: TextView by bindView(R.id.mod_view)
|
||||
|
||||
val context: Context = itemView.context
|
||||
val res: Resources = itemView.resources
|
||||
private var chatMessage: ChatMessage? = null
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener {
|
||||
expandMessage()
|
||||
}
|
||||
tvLikes.setOnClickListener { chatMessage?.let { likeMessageEvents.onNext(it) } }
|
||||
messageText.setOnClickListener { expandMessage() }
|
||||
messageText.movementMethod = LinkMovementMethod.getInstance()
|
||||
userLabel.setOnClickListener { chatMessage?.uuid?.let {userLabelClickEvents.onNext(it) } }
|
||||
avatarView.setOnClickListener { chatMessage?.uuid?.let {userLabelClickEvents.onNext(it) } }
|
||||
replyButton.setOnClickListener {
|
||||
if (chatMessage?.username != null) {
|
||||
chatMessage?.username?.let { replyMessageEvents.onNext(it) }
|
||||
} else {
|
||||
chatMessage?.user?.let { replyMessageEvents.onNext(it) }
|
||||
}
|
||||
}
|
||||
replyButton.setCompoundDrawablesWithIntrinsicBounds(BitmapDrawable(res, HabiticaIconsHelper.imageOfChatReplyIcon()),
|
||||
null, null, null)
|
||||
copyButton.setOnClickListener { chatMessage?.let { copyMessageEvents.onNext(it) } }
|
||||
copyButton.setCompoundDrawablesWithIntrinsicBounds(BitmapDrawable(res, HabiticaIconsHelper.imageOfChatCopyIcon()),
|
||||
null, null, null)
|
||||
reportButton.setOnClickListener { chatMessage?.let { flagMessageEvents.onNext(it) } }
|
||||
reportButton.setCompoundDrawablesWithIntrinsicBounds(BitmapDrawable(res, HabiticaIconsHelper.imageOfChatReportIcon()),
|
||||
null, null, null)
|
||||
deleteButton.setOnClickListener { chatMessage?.let { deleteMessageEvents.onNext(it) } }
|
||||
deleteButton.setCompoundDrawablesWithIntrinsicBounds(BitmapDrawable(res, HabiticaIconsHelper.imageOfChatDeleteIcon()),
|
||||
null, null, null)
|
||||
}
|
||||
|
||||
fun bind(msg: ChatMessage, uuid: String) {
|
||||
chatMessage = msg
|
||||
userId = uuid
|
||||
|
||||
setLikeProperties()
|
||||
|
||||
val wasSent = messageWasSent()
|
||||
|
||||
val name = user?.profile?.name
|
||||
if (wasSent) {
|
||||
userLabel.tier = user?.contributor?.level ?: 0
|
||||
userLabel.username = name
|
||||
if (user?.username != null) {
|
||||
@SuppressLint("SetTextI18n")
|
||||
sublineTextView.text = "${user?.formattedUsername} ∙ ${msg.getAgoString(res)}"
|
||||
} else {
|
||||
sublineTextView.text = msg.getAgoString(res)
|
||||
}
|
||||
} else {
|
||||
userLabel.tier = msg.contributor?.level ?: 0
|
||||
userLabel.username = msg.user
|
||||
if (msg.username != null) {
|
||||
@SuppressLint("SetTextI18n")
|
||||
sublineTextView.text = "${msg.formattedUsername} ∙ ${msg.getAgoString(res)}"
|
||||
} else {
|
||||
sublineTextView.text = msg.getAgoString(res)
|
||||
}
|
||||
}
|
||||
when {
|
||||
userLabel.tier == 8 -> {
|
||||
modView.visibility = View.VISIBLE
|
||||
modView.text = context.getString(R.string.moderator)
|
||||
modView.background = ContextCompat.getDrawable(context, R.drawable.pill_bg_blue)
|
||||
modView.setScaledPadding(context, 12, 4, 12, 4)
|
||||
}
|
||||
userLabel.tier == 9 -> {
|
||||
modView.visibility = View.VISIBLE
|
||||
modView.text = context.getString(R.string.staff)
|
||||
modView.background = ContextCompat.getDrawable(context, R.drawable.pill_bg_purple_300)
|
||||
modView.setScaledPadding(context, 12, 4, 12, 4)
|
||||
}
|
||||
else -> modView.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (wasSent) {
|
||||
avatarView.visibility = View.GONE
|
||||
itemView.setPadding(64.dpToPx(context), itemView.paddingTop, itemView.paddingRight, itemView.paddingBottom)
|
||||
} else {
|
||||
val displayMetrics = res.displayMetrics
|
||||
val dpWidth = displayMetrics.widthPixels / displayMetrics.density
|
||||
if (dpWidth > 350) {
|
||||
avatarView.visibility = View.VISIBLE
|
||||
msg.userStyles?.let {
|
||||
avatarView.setAvatar(it)
|
||||
}
|
||||
} else {
|
||||
avatarView.visibility = View.GONE
|
||||
}
|
||||
itemView.setPadding(16.dpToPx(context), itemView.paddingTop, itemView.paddingRight, itemView.paddingBottom)
|
||||
}
|
||||
|
||||
messageText.text = chatMessage?.parsedText
|
||||
if (msg.parsedText == null) {
|
||||
messageText.text = chatMessage?.text
|
||||
Maybe.just(chatMessage?.text ?: "")
|
||||
.map { MarkdownParser.parseMarkdown(it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ parsedText ->
|
||||
chatMessage?.parsedText = parsedText
|
||||
messageText.text = chatMessage?.parsedText
|
||||
}, { it.printStackTrace() })
|
||||
}
|
||||
|
||||
val username = user?.formattedUsername
|
||||
messageWrapper.background = if ((name != null && msg.text?.contains("@$name") == true) || (username != null && msg.text?.contains(username) == true)) {
|
||||
ContextCompat.getDrawable(context, R.drawable.layout_rounded_bg_brand_700)
|
||||
} else {
|
||||
ContextCompat.getDrawable(context, R.drawable.layout_rounded_bg)
|
||||
}
|
||||
messageWrapper.setScaledPadding(context, 8, 8, 8, 8)
|
||||
|
||||
if (expandedMessageId == msg.id) {
|
||||
buttonsWrapper.visibility = View.VISIBLE
|
||||
deleteButton.visibility = if (shouldShowDelete()) View.VISIBLE else View.GONE
|
||||
replyButton.visibility = if (chatMessage?.isInboxMessage == true) View.GONE else View.VISIBLE
|
||||
} else {
|
||||
buttonsWrapper.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun messageWasSent(): Boolean {
|
||||
return chatMessage?.sent == true || chatMessage?.uuid == userId
|
||||
}
|
||||
|
||||
private fun setLikeProperties() {
|
||||
likeBackground.visibility = if (isTavern) View.VISIBLE else View.INVISIBLE
|
||||
@SuppressLint("SetTextI18n")
|
||||
tvLikes.text = "+" + chatMessage?.likeCount
|
||||
|
||||
val backgroundColorRes: Int
|
||||
val foregroundColorRes: Int
|
||||
|
||||
if (chatMessage?.likeCount != 0) {
|
||||
if (chatMessage?.userLikesMessage(userId) == true) {
|
||||
backgroundColorRes = R.color.tavern_userliked_background
|
||||
foregroundColorRes = R.color.tavern_userliked_foreground
|
||||
} else {
|
||||
backgroundColorRes = R.color.tavern_somelikes_background
|
||||
foregroundColorRes = R.color.tavern_somelikes_foreground
|
||||
}
|
||||
} else {
|
||||
backgroundColorRes = R.color.tavern_nolikes_background
|
||||
foregroundColorRes = R.color.tavern_nolikes_foreground
|
||||
}
|
||||
|
||||
DataBindingUtils.setRoundedBackground(likeBackground, ContextCompat.getColor(context, backgroundColorRes))
|
||||
tvLikes.setTextColor(ContextCompat.getColor(context, foregroundColorRes))
|
||||
}
|
||||
|
||||
private fun shouldShowDelete(): Boolean {
|
||||
return chatMessage?.isSystemMessage != true && (chatMessage?.uuid == userId || user?.contributor?.admin == true || chatMessage?.isInboxMessage == true)
|
||||
}
|
||||
|
||||
private fun expandMessage() {
|
||||
expandedMessageId = if (expandedMessageId == chatMessage?.id) {
|
||||
null
|
||||
} else {
|
||||
chatMessage?.id
|
||||
}
|
||||
notifyItemChanged(adapterPosition)
|
||||
private fun expandMessage(id: String, position: Int) {
|
||||
expandedMessageId = if (expandedMessageId == id) {
|
||||
null
|
||||
} else {
|
||||
id
|
||||
}
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
class SystemChatMessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val textView: TextView by bindView(R.id.text_view)
|
||||
|
||||
fun bind(chatMessage: ChatMessage?) {
|
||||
textView.text = chatMessage?.text?.removePrefix("`")?.removeSuffix("`")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package com.habitrpg.android.habitica.ui.adapter.social
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.habitrpg.android.habitica.R
|
||||
import com.habitrpg.android.habitica.extensions.inflate
|
||||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import com.habitrpg.android.habitica.models.user.User
|
||||
import com.habitrpg.android.habitica.ui.viewHolders.ChatRecyclerViewHolder
|
||||
import io.reactivex.BackpressureStrategy
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.subjects.PublishSubject
|
||||
|
||||
class InboxAdapter(private var user: User?) : PagedListAdapter<ChatMessage, ChatRecyclerViewHolder>(DIFF_CALLBACK) {
|
||||
private var expandedMessageId: String? = null
|
||||
|
||||
private val likeMessageEvents = PublishSubject.create<ChatMessage>()
|
||||
private val userLabelClickEvents = PublishSubject.create<String>()
|
||||
private val deleteMessageEvents = PublishSubject.create<ChatMessage>()
|
||||
private val flagMessageEvents = PublishSubject.create<ChatMessage>()
|
||||
private val replyMessageEvents = PublishSubject.create<String>()
|
||||
private val copyMessageEvents = PublishSubject.create<ChatMessage>()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatRecyclerViewHolder {
|
||||
return ChatRecyclerViewHolder(parent.inflate(R.layout.tavern_chat_item), user?.id ?: "", false)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ChatRecyclerViewHolder, position: Int) {
|
||||
val message = getItem(position) ?: return
|
||||
|
||||
holder.bind(message,
|
||||
user?.id ?: "",
|
||||
user,
|
||||
expandedMessageId == message.id)
|
||||
holder.onShouldExpand = { expandMessage(message.id, position) }
|
||||
holder.onLikeMessage = { likeMessageEvents.onNext(it) }
|
||||
holder.onOpenProfile = { userLabelClickEvents.onNext(it) }
|
||||
holder.onReply = { replyMessageEvents.onNext(it) }
|
||||
holder.onCopyMessage = { copyMessageEvents.onNext(it) }
|
||||
holder.onFlagMessage = { flagMessageEvents.onNext(it) }
|
||||
holder.onDeleteMessage = { deleteMessageEvents.onNext(it) }
|
||||
}
|
||||
|
||||
fun getLikeMessageFlowable(): Flowable<ChatMessage> {
|
||||
return likeMessageEvents.toFlowable(BackpressureStrategy.DROP)
|
||||
}
|
||||
|
||||
fun getUserLabelClickFlowable(): Flowable<String> {
|
||||
return userLabelClickEvents.toFlowable(BackpressureStrategy.DROP)
|
||||
}
|
||||
|
||||
fun getFlagMessageClickFlowable(): Flowable<ChatMessage> {
|
||||
return flagMessageEvents.toFlowable(BackpressureStrategy.DROP)
|
||||
}
|
||||
|
||||
fun getDeleteMessageFlowable(): Flowable<ChatMessage> {
|
||||
return deleteMessageEvents.toFlowable(BackpressureStrategy.DROP)
|
||||
}
|
||||
|
||||
fun getReplyMessageEvents(): Flowable<String> {
|
||||
return replyMessageEvents.toFlowable(BackpressureStrategy.DROP)
|
||||
}
|
||||
|
||||
fun getCopyMessageFlowable(): Flowable<ChatMessage> {
|
||||
return copyMessageEvents.toFlowable(BackpressureStrategy.DROP)
|
||||
}
|
||||
|
||||
private fun expandMessage(id: String, position: Int) {
|
||||
expandedMessageId = if (expandedMessageId == id) {
|
||||
null
|
||||
} else {
|
||||
id
|
||||
}
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFF_CALLBACK = object :
|
||||
DiffUtil.ItemCallback<ChatMessage>() {
|
||||
// Concert details may have changed if reloaded from the database,
|
||||
// but ID is fixed.
|
||||
override fun areItemsTheSame(oldConcert: ChatMessage,
|
||||
newConcert: ChatMessage) = oldConcert.id == newConcert.id
|
||||
|
||||
override fun areContentsTheSame(oldConcert: ChatMessage,
|
||||
newConcert: ChatMessage) = oldConcert == newConcert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.habitrpg.android.habitica.MainNavDirections
|
||||
import com.habitrpg.android.habitica.R
|
||||
import com.habitrpg.android.habitica.components.UserComponent
|
||||
|
|
@ -18,10 +20,12 @@ import com.habitrpg.android.habitica.helpers.RxErrorHandler
|
|||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import com.habitrpg.android.habitica.ui.activities.FullProfileActivity
|
||||
import com.habitrpg.android.habitica.ui.activities.MainActivity
|
||||
import com.habitrpg.android.habitica.ui.adapter.social.ChatRecyclerViewAdapter
|
||||
import com.habitrpg.android.habitica.ui.adapter.social.InboxAdapter
|
||||
import com.habitrpg.android.habitica.ui.fragments.BaseMainFragment
|
||||
import com.habitrpg.android.habitica.ui.helpers.KeyboardUtil
|
||||
import com.habitrpg.android.habitica.ui.helpers.SafeDefaultItemAnimator
|
||||
import com.habitrpg.android.habitica.ui.viewmodels.InboxViewModel
|
||||
import com.habitrpg.android.habitica.ui.viewmodels.InboxViewModelFactory
|
||||
import com.habitrpg.android.habitica.ui.views.HabiticaSnackbar
|
||||
import com.habitrpg.android.habitica.ui.views.HabiticaSnackbar.Companion.showSnackbar
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
|
|
@ -39,10 +43,12 @@ class InboxMessageListFragment : BaseMainFragment(), androidx.swiperefreshlayout
|
|||
@Inject
|
||||
lateinit var configManager: AppConfigManager
|
||||
|
||||
private var chatAdapter: ChatRecyclerViewAdapter? = null
|
||||
private var chatAdapter: InboxAdapter? = null
|
||||
private var chatRoomUser: String? = null
|
||||
private var replyToUserUUID: String? = null
|
||||
|
||||
private var viewModel: InboxViewModel? = null
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?): View? {
|
||||
this.hidesToolbar = true
|
||||
|
|
@ -59,11 +65,13 @@ class InboxMessageListFragment : BaseMainFragment(), androidx.swiperefreshlayout
|
|||
val args = InboxMessageListFragmentArgs.fromBundle(it)
|
||||
setReceivingUser(args.username, args.userID)
|
||||
}
|
||||
viewModel = ViewModelProviders.of(this, InboxViewModelFactory(replyToUserUUID ?: "")).get(InboxViewModel::class.java)
|
||||
|
||||
val layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this.getActivity())
|
||||
recyclerView.layoutManager = layoutManager
|
||||
|
||||
chatAdapter = ChatRecyclerViewAdapter(null, true, user, false)
|
||||
chatAdapter = InboxAdapter(user)
|
||||
viewModel?.messages?.observe(this, Observer { chatAdapter?.submitList(it) })
|
||||
recyclerView.adapter = chatAdapter
|
||||
recyclerView.itemAnimator = SafeDefaultItemAnimator()
|
||||
chatAdapter?.let { adapter ->
|
||||
|
|
@ -78,8 +86,6 @@ class InboxMessageListFragment : BaseMainFragment(), androidx.swiperefreshlayout
|
|||
chatBarView.sendAction = { sendMessage(it) }
|
||||
chatBarView.maxChatLength = configManager.maxChatLength()
|
||||
|
||||
loadMessages()
|
||||
|
||||
communityGuidelinesView.visibility = View.GONE
|
||||
}
|
||||
|
||||
|
|
@ -90,14 +96,6 @@ class InboxMessageListFragment : BaseMainFragment(), androidx.swiperefreshlayout
|
|||
super.onAttach(context)
|
||||
}
|
||||
|
||||
private fun loadMessages() {
|
||||
if (user?.isManaged == true) {
|
||||
compositeSubscription.add(socialRepository.getInboxMessages(replyToUserUUID)
|
||||
.firstElement()
|
||||
.subscribe(Consumer { this.chatAdapter?.updateData(it) }, RxErrorHandler.handleEmptyError()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
socialRepository.close()
|
||||
super.onDestroy()
|
||||
|
|
@ -107,16 +105,17 @@ class InboxMessageListFragment : BaseMainFragment(), androidx.swiperefreshlayout
|
|||
component.inject(this)
|
||||
}
|
||||
|
||||
private fun refreshUserInbox() {
|
||||
this.swipeRefreshLayout?.isRefreshing = true
|
||||
compositeSubscription.add(this.socialRepository.retrieveInboxMessages()
|
||||
private fun refreshConversation() {
|
||||
compositeSubscription.add(this.socialRepository.retrieveInboxMessages(replyToUserUUID ?: "", 0)
|
||||
.subscribe(Consumer {}, RxErrorHandler.handleEmptyError(), Action {
|
||||
swipeRefreshLayout?.isRefreshing = false
|
||||
viewModel?.invalidateDataSource()
|
||||
}))
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
this.refreshUserInbox()
|
||||
this.swipeRefreshLayout?.isRefreshing = true
|
||||
this.refreshConversation()
|
||||
}
|
||||
|
||||
private fun sendMessage(chatText: String) {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ import android.widget.TextView
|
|||
import com.habitrpg.android.habitica.R
|
||||
import com.habitrpg.android.habitica.components.UserComponent
|
||||
import com.habitrpg.android.habitica.data.SocialRepository
|
||||
import com.habitrpg.android.habitica.extensions.getAgoString
|
||||
import com.habitrpg.android.habitica.helpers.AppConfigManager
|
||||
import com.habitrpg.android.habitica.helpers.MainNavigationController
|
||||
import com.habitrpg.android.habitica.helpers.RxErrorHandler
|
||||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import com.habitrpg.android.habitica.models.social.InboxConversation
|
||||
import com.habitrpg.android.habitica.modules.AppModule
|
||||
import com.habitrpg.android.habitica.ui.AvatarView
|
||||
import com.habitrpg.android.habitica.ui.fragments.BaseMainFragment
|
||||
|
|
@ -27,7 +28,7 @@ import kotlinx.android.synthetic.main.fragment_inbox.*
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
|
||||
class InboxFragment : BaseMainFragment(), androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener, View.OnClickListener {
|
||||
class InboxOverviewFragment : BaseMainFragment(), androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener, View.OnClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var socialRepository: SocialRepository
|
||||
|
|
@ -54,10 +55,11 @@ class InboxFragment : BaseMainFragment(), androidx.swiperefreshlayout.widget.Swi
|
|||
inbox_refresh_layout?.setOnRefreshListener(this)
|
||||
|
||||
loadMessages()
|
||||
retrieveMessages()
|
||||
}
|
||||
|
||||
private fun loadMessages() {
|
||||
compositeSubscription.add(socialRepository.getInboxOverviewList().subscribe(Consumer<RealmResults<ChatMessage>> {
|
||||
compositeSubscription.add(socialRepository.getInboxConversations().subscribe(Consumer<RealmResults<InboxConversation>> {
|
||||
setInboxMessages(it)
|
||||
}, RxErrorHandler.handleEmptyError()))
|
||||
}
|
||||
|
|
@ -68,9 +70,7 @@ class InboxFragment : BaseMainFragment(), androidx.swiperefreshlayout.widget.Swi
|
|||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
val id = item.itemId
|
||||
|
||||
when (id) {
|
||||
when (item.itemId) {
|
||||
R.id.send_message -> {
|
||||
openNewMessageDialog()
|
||||
return true
|
||||
|
|
@ -103,15 +103,20 @@ class InboxFragment : BaseMainFragment(), androidx.swiperefreshlayout.widget.Swi
|
|||
component.inject(this)
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
inbox_refresh_layout.isRefreshing = true
|
||||
compositeSubscription.add(this.socialRepository.retrieveInboxMessages()
|
||||
.subscribe(Consumer<List<ChatMessage>> {
|
||||
|
||||
private fun retrieveMessages() {
|
||||
compositeSubscription.add(this.socialRepository.retrieveInboxConversations()
|
||||
.subscribe(Consumer<List<InboxConversation>> {
|
||||
inbox_refresh_layout.isRefreshing = false
|
||||
}, RxErrorHandler.handleEmptyError()))
|
||||
}
|
||||
|
||||
private fun setInboxMessages(messages: RealmResults<ChatMessage>) {
|
||||
override fun onRefresh() {
|
||||
inbox_refresh_layout.isRefreshing = true
|
||||
retrieveMessages()
|
||||
}
|
||||
|
||||
private fun setInboxMessages(messages: RealmResults<InboxConversation>) {
|
||||
if (inbox_messages == null) {
|
||||
return
|
||||
}
|
||||
|
|
@ -123,15 +128,15 @@ class InboxFragment : BaseMainFragment(), androidx.swiperefreshlayout.widget.Swi
|
|||
for (message in messages) {
|
||||
val entry = inflater?.inflate(R.layout.item_inbox_overview, inbox_messages, false)
|
||||
val avatarView = entry?.findViewById(R.id.avatar_view) as? AvatarView
|
||||
//message.userStyles?.let { avatarView?.setAvatar(it) }
|
||||
message.userStyles?.let { avatarView?.setAvatar(it) }
|
||||
avatarView?.visibility = View.GONE
|
||||
val displayNameTextView = entry?.findViewById(R.id.display_name_textview) as? UsernameLabel
|
||||
displayNameTextView?.username = message.user
|
||||
displayNameTextView?.tier = message.contributor?.level ?: 0
|
||||
val timestampTextView = entry?.findViewById(R.id.timestamp_textview) as? TextView
|
||||
timestampTextView?.text = message.getAgoString(resources)
|
||||
timestampTextView?.text = message.timestamp?.getAgoString(resources)
|
||||
val usernameTextView = entry?.findViewById(R.id.username_textview) as? TextView
|
||||
if (message.username != null) {
|
||||
if (message.username?.isNotEmpty() == true) {
|
||||
usernameTextView?.text = message.formattedUsername
|
||||
usernameTextView?.visibility = View.VISIBLE
|
||||
} else {
|
||||
|
|
@ -156,7 +161,7 @@ class InboxFragment : BaseMainFragment(), androidx.swiperefreshlayout.widget.Swi
|
|||
}
|
||||
|
||||
private fun openInboxMessages(userID: String, username: String) {
|
||||
MainNavigationController.navigate(InboxFragmentDirections.openInboxDetail(userID, username))
|
||||
MainNavigationController.navigate(InboxOverviewFragmentDirections.openInboxDetail(userID, username))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
package com.habitrpg.android.habitica.ui.viewHolders
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.habitrpg.android.habitica.R
|
||||
import com.habitrpg.android.habitica.extensions.dpToPx
|
||||
import com.habitrpg.android.habitica.extensions.getAgoString
|
||||
import com.habitrpg.android.habitica.extensions.setScaledPadding
|
||||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import com.habitrpg.android.habitica.models.user.User
|
||||
import com.habitrpg.android.habitica.ui.AvatarView
|
||||
import com.habitrpg.android.habitica.ui.helpers.DataBindingUtils
|
||||
import com.habitrpg.android.habitica.ui.helpers.MarkdownParser
|
||||
import com.habitrpg.android.habitica.ui.helpers.bindView
|
||||
import com.habitrpg.android.habitica.ui.views.HabiticaEmojiTextView
|
||||
import com.habitrpg.android.habitica.ui.views.HabiticaIconsHelper
|
||||
import com.habitrpg.android.habitica.ui.views.social.UsernameLabel
|
||||
import io.reactivex.Maybe
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
|
||||
class ChatRecyclerViewHolder(itemView: View, private var userId: String, private val isTavern: Boolean) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
private val messageWrapper: ViewGroup by bindView(R.id.message_wrapper)
|
||||
private val avatarView: AvatarView by bindView(R.id.avatar_view)
|
||||
private val userLabel: UsernameLabel by bindView(R.id.user_label)
|
||||
private val messageText: HabiticaEmojiTextView by bindView(R.id.message_text)
|
||||
private val sublineTextView: TextView by bindView(R.id.subline_textview)
|
||||
private val likeBackground: LinearLayout by bindView(R.id.like_background_layout)
|
||||
private val tvLikes: TextView by bindView(R.id.tvLikes)
|
||||
private val buttonsWrapper: ViewGroup by bindView(R.id.buttons_wrapper)
|
||||
private val replyButton: Button by bindView(R.id.reply_button)
|
||||
private val copyButton: Button by bindView(R.id.copy_button)
|
||||
private val reportButton: Button by bindView(R.id.report_button)
|
||||
private val deleteButton: Button by bindView(R.id.delete_button)
|
||||
private val modView: TextView by bindView(R.id.mod_view)
|
||||
|
||||
val context: Context = itemView.context
|
||||
val res: Resources = itemView.resources
|
||||
private var chatMessage: ChatMessage? = null
|
||||
private var user: User? = null
|
||||
|
||||
var onShouldExpand: (() -> Unit)? = null
|
||||
var onLikeMessage: ((ChatMessage) -> Unit)? = null
|
||||
var onOpenProfile: ((String) -> Unit)? = null
|
||||
var onReply: ((String) -> Unit)? = null
|
||||
var onCopyMessage: ((ChatMessage) -> Unit)? = null
|
||||
var onFlagMessage: ((ChatMessage) -> Unit)? = null
|
||||
var onDeleteMessage: ((ChatMessage) -> Unit)? = null
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener {
|
||||
onShouldExpand?.invoke()
|
||||
}
|
||||
tvLikes.setOnClickListener { chatMessage?.let { onLikeMessage?.invoke(it) } }
|
||||
messageText.setOnClickListener { onShouldExpand?.invoke() }
|
||||
messageText.movementMethod = LinkMovementMethod.getInstance()
|
||||
userLabel.setOnClickListener { chatMessage?.uuid?.let { onOpenProfile?.invoke(it) } }
|
||||
avatarView.setOnClickListener { chatMessage?.uuid?.let { onOpenProfile?.invoke(it) } }
|
||||
replyButton.setOnClickListener {
|
||||
if (chatMessage?.username != null) {
|
||||
chatMessage?.username?.let { onReply?.invoke(it) }
|
||||
} else {
|
||||
chatMessage?.user?.let { onReply?.invoke(it) }
|
||||
}
|
||||
}
|
||||
replyButton.setCompoundDrawablesWithIntrinsicBounds(BitmapDrawable(res, HabiticaIconsHelper.imageOfChatReplyIcon()),
|
||||
null, null, null)
|
||||
copyButton.setOnClickListener { chatMessage?.let { onCopyMessage?.invoke(it) } }
|
||||
copyButton.setCompoundDrawablesWithIntrinsicBounds(BitmapDrawable(res, HabiticaIconsHelper.imageOfChatCopyIcon()),
|
||||
null, null, null)
|
||||
reportButton.setOnClickListener { chatMessage?.let { onFlagMessage?.invoke(it) } }
|
||||
reportButton.setCompoundDrawablesWithIntrinsicBounds(BitmapDrawable(res, HabiticaIconsHelper.imageOfChatReportIcon()),
|
||||
null, null, null)
|
||||
deleteButton.setOnClickListener { chatMessage?.let { onDeleteMessage?.invoke(it) } }
|
||||
deleteButton.setCompoundDrawablesWithIntrinsicBounds(BitmapDrawable(res, HabiticaIconsHelper.imageOfChatDeleteIcon()),
|
||||
null, null, null)
|
||||
}
|
||||
|
||||
fun bind(msg: ChatMessage, uuid: String, user: User?, isExpanded: Boolean) {
|
||||
chatMessage = msg
|
||||
this.user = user
|
||||
userId = uuid
|
||||
|
||||
setLikeProperties()
|
||||
|
||||
val wasSent = messageWasSent()
|
||||
|
||||
val name = user?.profile?.name
|
||||
if (wasSent) {
|
||||
userLabel.tier = user?.contributor?.level ?: 0
|
||||
userLabel.username = name
|
||||
if (user?.username != null) {
|
||||
@SuppressLint("SetTextI18n")
|
||||
sublineTextView.text = "${user.formattedUsername} ∙ ${msg.timestamp?.getAgoString(res)}"
|
||||
} else {
|
||||
sublineTextView.text = msg.timestamp?.getAgoString(res)
|
||||
}
|
||||
} else {
|
||||
userLabel.tier = msg.contributor?.level ?: 0
|
||||
userLabel.username = msg.user
|
||||
if (msg.username != null) {
|
||||
@SuppressLint("SetTextI18n")
|
||||
sublineTextView.text = "${msg.formattedUsername} ∙ ${msg.timestamp?.getAgoString(res)}"
|
||||
} else {
|
||||
sublineTextView.text = msg.timestamp?.getAgoString(res)
|
||||
}
|
||||
}
|
||||
when {
|
||||
userLabel.tier == 8 -> {
|
||||
modView.visibility = View.VISIBLE
|
||||
modView.text = context.getString(R.string.moderator)
|
||||
modView.background = ContextCompat.getDrawable(context, R.drawable.pill_bg_blue)
|
||||
modView.setScaledPadding(context, 12, 4, 12, 4)
|
||||
}
|
||||
userLabel.tier == 9 -> {
|
||||
modView.visibility = View.VISIBLE
|
||||
modView.text = context.getString(R.string.staff)
|
||||
modView.background = ContextCompat.getDrawable(context, R.drawable.pill_bg_purple_300)
|
||||
modView.setScaledPadding(context, 12, 4, 12, 4)
|
||||
}
|
||||
else -> modView.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (wasSent) {
|
||||
avatarView.visibility = View.GONE
|
||||
itemView.setPadding(64.dpToPx(context), itemView.paddingTop, itemView.paddingRight, itemView.paddingBottom)
|
||||
} else {
|
||||
val displayMetrics = res.displayMetrics
|
||||
val dpWidth = displayMetrics.widthPixels / displayMetrics.density
|
||||
if (dpWidth > 350) {
|
||||
avatarView.visibility = View.VISIBLE
|
||||
msg.userStyles?.let {
|
||||
avatarView.setAvatar(it)
|
||||
}
|
||||
} else {
|
||||
avatarView.visibility = View.GONE
|
||||
}
|
||||
itemView.setPadding(16.dpToPx(context), itemView.paddingTop, itemView.paddingRight, itemView.paddingBottom)
|
||||
}
|
||||
|
||||
messageText.text = chatMessage?.parsedText
|
||||
if (msg.parsedText == null) {
|
||||
messageText.text = chatMessage?.text
|
||||
Maybe.just(chatMessage?.text ?: "")
|
||||
.map { MarkdownParser.parseMarkdown(it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ parsedText ->
|
||||
chatMessage?.parsedText = parsedText
|
||||
messageText.text = chatMessage?.parsedText
|
||||
}, { it.printStackTrace() })
|
||||
}
|
||||
|
||||
val username = user?.formattedUsername
|
||||
messageWrapper.background = if ((name != null && msg.text?.contains("@$name") == true) || (username != null && msg.text?.contains(username) == true)) {
|
||||
ContextCompat.getDrawable(context, R.drawable.layout_rounded_bg_brand_700)
|
||||
} else {
|
||||
ContextCompat.getDrawable(context, R.drawable.layout_rounded_bg)
|
||||
}
|
||||
messageWrapper.setScaledPadding(context, 8, 8, 8, 8)
|
||||
|
||||
if (isExpanded) {
|
||||
buttonsWrapper.visibility = View.VISIBLE
|
||||
deleteButton.visibility = if (shouldShowDelete()) View.VISIBLE else View.GONE
|
||||
replyButton.visibility = if (chatMessage?.isInboxMessage == true) View.GONE else View.VISIBLE
|
||||
} else {
|
||||
buttonsWrapper.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun messageWasSent(): Boolean {
|
||||
return chatMessage?.sent == true || chatMessage?.uuid == userId
|
||||
}
|
||||
|
||||
private fun setLikeProperties() {
|
||||
likeBackground.visibility = if (isTavern) View.VISIBLE else View.INVISIBLE
|
||||
@SuppressLint("SetTextI18n")
|
||||
tvLikes.text = "+" + chatMessage?.likeCount
|
||||
|
||||
val backgroundColorRes: Int
|
||||
val foregroundColorRes: Int
|
||||
|
||||
if (chatMessage?.likeCount != 0) {
|
||||
if (chatMessage?.userLikesMessage(userId) == true) {
|
||||
backgroundColorRes = R.color.tavern_userliked_background
|
||||
foregroundColorRes = R.color.tavern_userliked_foreground
|
||||
} else {
|
||||
backgroundColorRes = R.color.tavern_somelikes_background
|
||||
foregroundColorRes = R.color.tavern_somelikes_foreground
|
||||
}
|
||||
} else {
|
||||
backgroundColorRes = R.color.tavern_nolikes_background
|
||||
foregroundColorRes = R.color.tavern_nolikes_foreground
|
||||
}
|
||||
|
||||
DataBindingUtils.setRoundedBackground(likeBackground, ContextCompat.getColor(context, backgroundColorRes))
|
||||
tvLikes.setTextColor(ContextCompat.getColor(context, foregroundColorRes))
|
||||
}
|
||||
|
||||
private fun shouldShowDelete(): Boolean {
|
||||
return chatMessage?.isSystemMessage != true && (chatMessage?.uuid == userId || user?.contributor?.admin == true || chatMessage?.isInboxMessage == true)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package com.habitrpg.android.habitica.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.paging.DataSource
|
||||
import androidx.paging.PagedList
|
||||
import androidx.paging.PositionalDataSource
|
||||
import androidx.paging.toLiveData
|
||||
import com.habitrpg.android.habitica.components.UserComponent
|
||||
import com.habitrpg.android.habitica.data.SocialRepository
|
||||
import com.habitrpg.android.habitica.helpers.RxErrorHandler
|
||||
import com.habitrpg.android.habitica.models.social.ChatMessage
|
||||
import io.reactivex.Flowable
|
||||
import io.reactivex.functions.Consumer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.ceil
|
||||
|
||||
|
||||
class InboxViewModel(recipientID: String) : BaseViewModel() {
|
||||
@Inject
|
||||
lateinit var socialRepository: SocialRepository
|
||||
|
||||
private val config = PagedList.Config.Builder()
|
||||
.setPageSize(10)
|
||||
.setEnablePlaceholders(false)
|
||||
.build()
|
||||
|
||||
private val dataSourceFactory = MessagesDataSourceFactory(socialRepository, recipientID)
|
||||
val messages: LiveData<PagedList<ChatMessage>> = dataSourceFactory.toLiveData(config)
|
||||
|
||||
override fun inject(component: UserComponent) {
|
||||
component.inject(this)
|
||||
}
|
||||
|
||||
fun invalidateDataSource() {
|
||||
dataSourceFactory.sourceLiveData.value?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private class MessagesDataSource(val socialRepository: SocialRepository, val recipientID: String):
|
||||
PositionalDataSource<ChatMessage>() {
|
||||
private var lastFetchWasEnd = false
|
||||
override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<ChatMessage>) {
|
||||
if (lastFetchWasEnd) {
|
||||
callback.onResult(emptyList())
|
||||
return
|
||||
}
|
||||
GlobalScope.launch(Dispatchers.Main.immediate) {
|
||||
val page = ceil(params.startPosition.toFloat() / params.loadSize.toFloat()).toInt()
|
||||
socialRepository.retrieveInboxMessages(recipientID, page)
|
||||
.subscribe(Consumer {
|
||||
if (it.size != 10) lastFetchWasEnd = true
|
||||
callback.onResult(it)
|
||||
}, RxErrorHandler.handleEmptyError())
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<ChatMessage>) {
|
||||
lastFetchWasEnd = false
|
||||
GlobalScope.launch(Dispatchers.Main.immediate) {
|
||||
socialRepository.getInboxMessages(recipientID).firstElement()
|
||||
.flatMapPublisher {
|
||||
if (it.size == 0) {
|
||||
socialRepository.retrieveInboxMessages(recipientID, 0)
|
||||
.doOnNext {
|
||||
messages -> if (messages.size != 10) lastFetchWasEnd = true
|
||||
}
|
||||
} else {
|
||||
Flowable.just(it)
|
||||
}
|
||||
}
|
||||
.subscribe(Consumer {
|
||||
callback.onResult(it, 0)
|
||||
}, RxErrorHandler.handleEmptyError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class MessagesDataSourceFactory(val socialRepository: SocialRepository, val recipientID: String) :
|
||||
DataSource.Factory<Int, ChatMessage>() {
|
||||
val sourceLiveData = MutableLiveData<MessagesDataSource>()
|
||||
var latestSource: MessagesDataSource = MessagesDataSource(socialRepository, recipientID)
|
||||
override fun create(): DataSource<Int, ChatMessage> {
|
||||
latestSource = MessagesDataSource(socialRepository, recipientID)
|
||||
sourceLiveData.postValue(latestSource)
|
||||
return latestSource
|
||||
}
|
||||
}
|
||||
|
||||
class InboxViewModelFactory(private val recipientID: String) : ViewModelProvider.Factory {
|
||||
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return InboxViewModel(recipientID) as T
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue