diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/components/AppComponent.java b/Habitica/src/main/java/com/habitrpg/android/habitica/components/AppComponent.java index 7bf4dacda..0839fa46b 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/components/AppComponent.java +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/components/AppComponent.java @@ -69,6 +69,7 @@ import com.habitrpg.android.habitica.ui.fragments.setup.TaskSetupFragment; import com.habitrpg.android.habitica.ui.fragments.setup.WelcomeFragment; import com.habitrpg.android.habitica.ui.fragments.skills.SkillTasksRecyclerViewFragment; import com.habitrpg.android.habitica.ui.fragments.skills.SkillsFragment; +import com.habitrpg.android.habitica.ui.fragments.social.ChatFragment; import com.habitrpg.android.habitica.ui.fragments.social.ChatListFragment; import com.habitrpg.android.habitica.ui.fragments.social.GroupInformationFragment; import com.habitrpg.android.habitica.ui.fragments.social.GuildFragment; @@ -299,4 +300,6 @@ public interface AppComponent { void inject(@NotNull VerifyUsernameActivity verifyUsernameActivity); void inject(@NotNull GroupViewModel viewModel); + + void inject(@NotNull ChatFragment chatFragment); } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/ChatFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/ChatFragment.kt new file mode 100644 index 000000000..73f09d4bb --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/ChatFragment.kt @@ -0,0 +1,249 @@ +package com.habitrpg.android.habitica.ui.fragments.social + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.habitrpg.android.habitica.R +import com.habitrpg.android.habitica.components.AppComponent +import com.habitrpg.android.habitica.extensions.notNull +import com.habitrpg.android.habitica.helpers.RemoteConfigManager +import com.habitrpg.android.habitica.helpers.RxErrorHandler +import com.habitrpg.android.habitica.models.social.ChatMessage +import com.habitrpg.android.habitica.models.user.User +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.fragments.BaseFragment +import com.habitrpg.android.habitica.ui.helpers.SafeDefaultItemAnimator +import com.habitrpg.android.habitica.ui.viewmodels.PartyViewModel +import com.habitrpg.android.habitica.ui.views.HabiticaSnackbar.Companion.showSnackbar +import com.habitrpg.android.habitica.ui.views.HabiticaSnackbar.SnackbarDisplayType +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.functions.Consumer +import io.realm.RealmResults +import kotlinx.android.synthetic.main.fragment_chat.* +import kotlinx.android.synthetic.main.tavern_chat_new_entry_item.* +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@SuppressLint("ValidFragment") +class ChatFragment(private var viewModel: PartyViewModel) : BaseFragment(), SwipeRefreshLayout.OnRefreshListener { + + @Inject + lateinit var configManager: RemoteConfigManager + + private var isTavern: Boolean = false + internal var layoutManager: LinearLayoutManager? = null + internal var groupId: String? = null + private var user: User? = null + private var userId: String? = null + private var chatAdapter: ChatRecyclerViewAdapter? = null + private var navigatedOnceToFragment = false + private var isScrolledToTop = true + private var refreshDisposable: Disposable? = null + + fun configure(groupId: String, user: User?, isTavern: Boolean) { + this.groupId = groupId + this.user = user + if (this.user != null) { + this.userId = this.user?.id + } + this.isTavern = isTavern + } + + override fun injectFragment(component: AppComponent) { + component.inject(this) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + refreshLayout.setOnRefreshListener(this) + + layoutManager = recyclerView.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager + + if (layoutManager == null) { + layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) + recyclerView.layoutManager = layoutManager + } + + chatAdapter = ChatRecyclerViewAdapter(null, true, user, true, configManager.enableUsernameRelease()) + chatAdapter.notNull {adapter -> + compositeSubscription.add(adapter.getUserLabelClickFlowable().subscribe(Consumer { userId -> + context.notNull { FullProfileActivity.open(it, userId) } + }, RxErrorHandler.handleEmptyError())) + compositeSubscription.add(adapter.getDeleteMessageFlowable().subscribe(Consumer { this.showDeleteConfirmationDialog(it) }, RxErrorHandler.handleEmptyError())) + compositeSubscription.add(adapter.getFlagMessageClickFlowable().subscribe(Consumer { this.showFlagConfirmationDialog(it) }, RxErrorHandler.handleEmptyError())) + compositeSubscription.add(adapter.getReplyMessageEvents().subscribe(Consumer{ setReplyTo(it) }, RxErrorHandler.handleEmptyError())) + compositeSubscription.add(adapter.getCopyMessageFlowable().subscribe(Consumer { this.copyMessageToClipboard(it) }, RxErrorHandler.handleEmptyError())) + compositeSubscription.add(adapter.getLikeMessageFlowable().subscribe(Consumer { viewModel.likeMessage(it) }, RxErrorHandler.handleEmptyError())) + } + + chatBarView.sendAction = { sendChatMessage(it) } + chatBarView.maxChatLength = configManager.maxChatLength() + + recyclerView.adapter = chatAdapter + recyclerView.itemAnimator = SafeDefaultItemAnimator() + + compositeSubscription.add(viewModel.getChatMessages().firstElement().subscribe(Consumer> { this.setChatMessages(it) }, RxErrorHandler.handleEmptyError())) + + if (user?.flags?.isCommunityGuidelinesAccepted == true) { + communityGuidelinesView.visibility = View.GONE + } else { + communityGuidelinesView.setOnClickListener { _ -> + val i = Intent(Intent.ACTION_VIEW) + i.data = "https://habitica.com/static/community-guidelines".toUri() + context?.startActivity(i) + viewModel.updateUser(user, "flags.communityGuidelinesAccepted", true) + } + } + + recyclerView.addOnScrollListener(object : androidx.recyclerview.widget.RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: androidx.recyclerview.widget.RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + isScrolledToTop = layoutManager?.findFirstVisibleItemPosition() == 0 + } + }) + + refresh(false) + } + + override fun onDestroyView() { + super.onDestroyView() + stopAutoRefreshing() + } + + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + if (isVisibleToUser) { + startAutoRefreshing() + } else { + stopAutoRefreshing() + } + } + + private fun startAutoRefreshing() { + if (refreshDisposable != null && refreshDisposable?.isDisposed != true) { + refreshDisposable?.dispose() + } + refreshDisposable = Observable.interval(30, TimeUnit.SECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer { + refresh(false) + }, RxErrorHandler.handleEmptyError()) + } + + private fun stopAutoRefreshing() { + if (refreshDisposable?.isDisposed != true) { + refreshDisposable?.dispose() + refreshDisposable = null + } + } + + private fun setReplyTo(username: String?) { + val previousText = chatEditText.text.toString() + if (previousText.contains("@$username")) { + return + } + chatEditText.setText("@$username $previousText", TextView.BufferType.EDITABLE) + } + + override fun onRefresh() { + refresh(true) + } + + private fun refresh(isUserInitiated: Boolean) { + if (isUserInitiated) { + refreshLayout.isRefreshing = true + } + viewModel.retrieveGroupChat { + refreshLayout?.isRefreshing = false + if (isScrolledToTop) { + recyclerView.scrollToPosition(0) + } + } + } + + fun setNavigatedToFragment() { + navigatedOnceToFragment = true + markMessagesAsSeen() + } + + private fun markMessagesAsSeen() { + if (navigatedOnceToFragment) { + viewModel.markMessagesSeen() + } + } + + private fun copyMessageToClipboard(chatMessage: ChatMessage) { + val clipMan = activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val messageText = ClipData.newPlainText("Chat message", chatMessage.text) + clipMan?.primaryClip = messageText + val activity = activity as? MainActivity + if (activity != null) { + showSnackbar(activity.floatingMenuWrapper, getString(R.string.chat_message_copied), SnackbarDisplayType.NORMAL) + } + } + + private fun showFlagConfirmationDialog(chatMessage: ChatMessage) { + val context = context + if (context != null) { + val builder = AlertDialog.Builder(context) + builder.setMessage(R.string.chat_flag_confirmation) + .setPositiveButton(R.string.flag_confirm) { _, _ -> + viewModel.flagMessage(chatMessage) { + val activity = activity as? MainActivity + activity?.floatingMenuWrapper.notNull { + showSnackbar(it, "Flagged message by " + chatMessage.user, SnackbarDisplayType.NORMAL) + } + } + } + .setNegativeButton(R.string.action_cancel) { _, _ -> } + builder.show() + } + } + + private fun showDeleteConfirmationDialog(chatMessage: ChatMessage) { + val context = context + if (context != null) { + AlertDialog.Builder(context) + .setTitle(R.string.confirm_delete_tag_title) + .setMessage(R.string.confirm_delete_tag_message) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton(android.R.string.yes) { _, _ -> viewModel.deleteMessage(chatMessage) } + .setNegativeButton(android.R.string.no, null).show() + } + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putString("userId", this.userId) + outState.putString("groupId", this.groupId) + outState.putBoolean("isTavern", this.isTavern) + super.onSaveInstanceState(outState) + } + + private fun setChatMessages(chatMessages: RealmResults) { + chatAdapter?.updateData(chatMessages) + recyclerView.scrollToPosition(0) + + viewModel.gotNewMessages = true + + markMessagesAsSeen() + } + + private fun sendChatMessage(chatText: String) { + viewModel.postGroupChat(chatText) { + recyclerView?.scrollToPosition(0) + } + } +} diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartyFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartyFragment.kt index 2590cfc49..1c7f9c456 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartyFragment.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartyFragment.kt @@ -10,29 +10,27 @@ import androidx.fragment.app.FragmentPagerAdapter import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders import androidx.viewpager.widget.ViewPager -import com.habitrpg.android.habitica.HabiticaBaseApplication import com.habitrpg.android.habitica.R import com.habitrpg.android.habitica.components.AppComponent import com.habitrpg.android.habitica.extensions.notNull -import com.habitrpg.android.habitica.helpers.RxErrorHandler import com.habitrpg.android.habitica.models.social.Group import com.habitrpg.android.habitica.ui.activities.GroupFormActivity import com.habitrpg.android.habitica.ui.activities.PartyInviteActivity import com.habitrpg.android.habitica.ui.fragments.BaseMainFragment +import com.habitrpg.android.habitica.ui.fragments.social.ChatFragment import com.habitrpg.android.habitica.ui.fragments.social.ChatListFragment import com.habitrpg.android.habitica.ui.fragments.social.GroupInformationFragment import com.habitrpg.android.habitica.ui.helpers.bindView import com.habitrpg.android.habitica.ui.helpers.resetViews import com.habitrpg.android.habitica.ui.viewmodels.GroupViewType import com.habitrpg.android.habitica.ui.viewmodels.PartyViewModel -import io.reactivex.functions.Consumer import java.util.* class PartyFragment : BaseMainFragment() { private val viewPager: ViewPager? by bindView(R.id.viewPager) private var partyMemberListFragment: PartyMemberListFragment? = null - private var chatListFragment: ChatListFragment? = null + private var chatFragment: ChatFragment? = null private var viewPagerAdapter: androidx.fragment.app.FragmentPagerAdapter? = null private lateinit var viewModel: PartyViewModel @@ -99,7 +97,7 @@ class PartyFragment : BaseMainFragment() { partyMemberListFragment?.setPartyId(group.id) - chatListFragment?.groupId = group.id + chatFragment?.groupId = group.id this.activity?.invalidateOptionsMenu() } @@ -219,13 +217,10 @@ class PartyFragment : BaseMainFragment() { } } 1 -> { - if (chatListFragment == null) { - chatListFragment = ChatListFragment() - if (user?.hasParty() == true) { - chatListFragment?.configure(user?.party?.id ?: "", user, false) - } + if (chatFragment == null) { + chatFragment = ChatFragment(viewModel) } - fragment = chatListFragment + fragment = chatFragment } 2 -> { if (partyMemberListFragment == null) { @@ -264,13 +259,13 @@ class PartyFragment : BaseMainFragment() { viewPager?.addOnPageChangeListener(object : androidx.viewpager.widget.ViewPager.OnPageChangeListener { override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { if (position == 1) { - chatListFragment?.setNavigatedToFragment() + chatFragment?.setNavigatedToFragment() } } override fun onPageSelected(position: Int) { if (position == 1) { - chatListFragment?.setNavigatedToFragment() + chatFragment?.setNavigatedToFragment() } } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/BaseViewModel.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/BaseViewModel.kt index 92505708a..6ff4e0770 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/BaseViewModel.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/BaseViewModel.kt @@ -48,4 +48,8 @@ abstract class BaseViewModel: ViewModel() { private fun loadUserFromLocal() { disposable.add(userRepository.getUser().observeOn(AndroidSchedulers.mainThread()).subscribe(Consumer { user.value = it }, RxErrorHandler.handleEmptyError())) } + + fun updateUser(user: User?, path: String, value: Any) { + disposable.add(userRepository.updateUser(user, path, value).subscribe(Consumer { }, RxErrorHandler.handleEmptyError())) + } } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/GroupViewModel.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/GroupViewModel.kt index e39f5a359..d05dc1489 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/GroupViewModel.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/GroupViewModel.kt @@ -11,6 +11,8 @@ import com.habitrpg.android.habitica.extensions.* import com.habitrpg.android.habitica.helpers.RxErrorHandler import com.habitrpg.android.habitica.models.social.ChatMessage import com.habitrpg.android.habitica.models.social.Group +import com.habitrpg.android.habitica.ui.activities.MainActivity +import com.habitrpg.android.habitica.ui.views.HabiticaSnackbar import io.reactivex.BackpressureStrategy import io.reactivex.Flowable import io.reactivex.android.schedulers.AndroidSchedulers @@ -41,6 +43,7 @@ open class GroupViewModel: BaseViewModel() { } protected val groupIDSubject = BehaviorSubject.create>() + var gotNewMessages: Boolean = false override fun inject(component: AppComponent) { component.inject(this) @@ -112,4 +115,43 @@ open class GroupViewModel: BaseViewModel() { fun rejectGroupInvite(groupID: String) { disposable.add(socialRepository.rejectGroupInvite(groupID).subscribe(Consumer { }, RxErrorHandler.handleEmptyError())) } + + fun markMessagesSeen() { + groupIDSubject.value?.value.notNull { + if (groupViewType != GroupViewType.TAVERN && it.isNotEmpty() && gotNewMessages) { + socialRepository.markMessagesSeen(it) + } + } + } + + fun likeMessage(message: ChatMessage) { + disposable.add(socialRepository.likeMessage(message).subscribe(Consumer { }, RxErrorHandler.handleEmptyError())) + } + + fun flagMessage(chatMessage: ChatMessage, function: () -> Unit) { + disposable.add(socialRepository.flagMessage(chatMessage) + .subscribe(Consumer { + function() + }, RxErrorHandler.handleEmptyError())) + } + + fun deleteMessage(chatMessage: ChatMessage) { + disposable.add(socialRepository.deleteMessage(chatMessage).subscribe(Consumer { }, RxErrorHandler.handleEmptyError())) + } + + fun postGroupChat(chatText: String, onComplete: () -> Unit?) { + groupIDSubject.value?.value.notNull { + disposable.add(socialRepository.postGroupChat(it, chatText).subscribe(Consumer { + onComplete() + }, RxErrorHandler.handleEmptyError())) + } + } + + fun retrieveGroupChat(onComplete: () -> Unit) { + groupIDSubject.value?.value.notNull { + disposable.add(socialRepository.retrieveGroupChat(it).subscribe(Consumer { + onComplete() + }, RxErrorHandler.handleEmptyError())) + } + } } \ No newline at end of file diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/PartyViewModel.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/PartyViewModel.kt index bbe776c57..3d1c09b69 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/PartyViewModel.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/PartyViewModel.kt @@ -2,16 +2,17 @@ package com.habitrpg.android.habitica.ui.viewmodels import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations import com.habitrpg.android.habitica.components.AppComponent import com.habitrpg.android.habitica.extensions.notNull import com.habitrpg.android.habitica.helpers.RxErrorHandler import com.habitrpg.android.habitica.models.inventory.Quest import io.reactivex.functions.Consumer +import kotlinx.android.synthetic.main.fragment_chat.* class PartyViewModel: GroupViewModel() { - private val quest: MutableLiveData = MutableLiveData() - + private val quest = Transformations.map(getGroupData()) { it?.quest } internal val isQuestActive: Boolean get() = quest.value?.active == true