/* Copyright 2017 Andrew Dawson * * This file is a part of Tusky. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation; either version 3 of the * License, or (at your option) any later version. * * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ package com.keylesspalace.tusky.fragment import android.os.Bundle import android.util.Log import android.view.View import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.AccountActivity import com.keylesspalace.tusky.AccountListActivity.Type import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.* import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.view.EndlessOnScrollListener import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from import com.uber.autodispose.autoDispose import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.android.synthetic.main.fragment_account_list.* import retrofit2.Response import java.io.IOException import java.util.* import javax.inject.Inject class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, Injectable { @Inject lateinit var api: MastodonApi private lateinit var type: Type private var id: String? = null private lateinit var scrollListener: EndlessOnScrollListener private lateinit var adapter: AccountAdapter private var fetching = false private var bottomId: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) type = arguments?.getSerializable(ARG_TYPE) as Type id = arguments?.getString(ARG_ID) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) recyclerView.setHasFixedSize(true) val layoutManager = LinearLayoutManager(view.context) recyclerView.layoutManager = layoutManager (recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) adapter = when (type) { Type.BLOCKS -> BlocksAdapter(this) Type.MUTES -> MutesAdapter(this) Type.FOLLOW_REQUESTS -> FollowRequestsAdapter(this) else -> FollowAdapter(this) } recyclerView.adapter = adapter scrollListener = object : EndlessOnScrollListener(layoutManager) { override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { if (bottomId == null) { return } fetchAccounts(bottomId) } } recyclerView.addOnScrollListener(scrollListener) fetchAccounts() } override fun onViewAccount(id: String) { (activity as BaseActivity?)?.let { val intent = AccountActivity.getIntent(it, id) it.startActivityWithSlideInAnimation(intent) } } override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { if (!mute) { api.unmuteAccount(id) } else { api.muteAccount(id, notifications) } .autoDispose(from(this)) .subscribe({ onMuteSuccess(mute, id, position, notifications) }, { onMuteFailure(mute, id, notifications) }) } private fun onMuteSuccess(muted: Boolean, id: String, position: Int, notifications: Boolean) { val mutesAdapter = adapter as MutesAdapter if (muted) { mutesAdapter.updateMutingNotifications(id, notifications, position) return } val unmutedUser = mutesAdapter.removeItem(position) if (unmutedUser != null) { Snackbar.make(recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG) .setAction(R.string.action_undo) { mutesAdapter.addItem(unmutedUser, position) onMute(true, id, position, notifications) } .show() } } private fun onMuteFailure(mute: Boolean, accountId: String, notifications: Boolean) { val verb = if (mute) { if (notifications) { "mute (notifications = true)" } else { "mute (notifications = false)" } } else { "unmute" } Log.e(TAG, "Failed to $verb account id $accountId") } override fun onBlock(block: Boolean, id: String, position: Int) { if (!block) { api.unblockAccount(id) } else { api.blockAccount(id) } .autoDispose(from(this)) .subscribe({ onBlockSuccess(block, id, position) }, { onBlockFailure(block, id) }) } private fun onBlockSuccess(blocked: Boolean, id: String, position: Int) { if (blocked) { return } val blocksAdapter = adapter as BlocksAdapter val unblockedUser = blocksAdapter.removeItem(position) if (unblockedUser != null) { Snackbar.make(recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG) .setAction(R.string.action_undo) { blocksAdapter.addItem(unblockedUser, position) onBlock(true, id, position) } .show() } } private fun onBlockFailure(block: Boolean, accountId: String) { val verb = if (block) { "block" } else { "unblock" } Log.e(TAG, "Failed to $verb account accountId $accountId") } override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { if (accept) { api.authorizeFollowRequest(accountId) } else { api.rejectFollowRequest(accountId) }.observeOn(AndroidSchedulers.mainThread()) .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) .subscribe({ onRespondToFollowRequestSuccess(position) }, { throwable -> val verb = if (accept) { "accept" } else { "reject" } Log.e(TAG, "Failed to $verb account id $accountId.", throwable) }) } private fun onRespondToFollowRequestSuccess(position: Int) { val followRequestsAdapter = adapter as FollowRequestsAdapter followRequestsAdapter.removeItem(position) } private fun getFetchCallByListType(fromId: String?): Single>> { return when (type) { Type.FOLLOWS -> { val accountId = requireId(type, id) api.accountFollowing(accountId, fromId) } Type.FOLLOWERS -> { val accountId = requireId(type, id) api.accountFollowers(accountId, fromId) } Type.BLOCKS -> api.blocks(fromId) Type.MUTES -> api.mutes(fromId) Type.FOLLOW_REQUESTS -> api.followRequests(fromId) Type.REBLOGGED -> { val statusId = requireId(type, id) api.statusRebloggedBy(statusId, fromId) } Type.FAVOURITED -> { val statusId = requireId(type, id) api.statusFavouritedBy(statusId, fromId) } } } private fun requireId(type: Type, id: String?): String { return requireNotNull(id) { "id must not be null for type "+type.name } } private fun fetchAccounts(fromId: String? = null) { if (fetching) { return } fetching = true if (fromId != null) { recyclerView.post { adapter.setBottomLoading(true) } } getFetchCallByListType(fromId) .observeOn(AndroidSchedulers.mainThread()) .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) .subscribe({ response -> val accountList = response.body() if (response.isSuccessful && accountList != null) { val linkHeader = response.headers()["Link"] onFetchAccountsSuccess(accountList, linkHeader) } else { onFetchAccountsFailure(Exception(response.message())) } }, {throwable -> onFetchAccountsFailure(throwable) }) } private fun onFetchAccountsSuccess(accounts: List, linkHeader: String?) { adapter.setBottomLoading(false) val links = HttpHeaderLink.parse(linkHeader) val next = HttpHeaderLink.findByRelationType(links, "next") val fromId = next?.uri?.getQueryParameter("max_id") if (adapter.itemCount > 0) { adapter.addItems(accounts) } else { adapter.update(accounts) } if (adapter is MutesAdapter) { fetchRelationships(accounts.map { it.id }) } bottomId = fromId fetching = false if (adapter.itemCount == 0) { messageView.show() messageView.setup( R.drawable.elephant_friend_empty, R.string.message_empty, null ) } else { messageView.hide() } } private fun fetchRelationships(ids: List) { api.relationships(ids) .observeOn(AndroidSchedulers.mainThread()) .autoDispose(from(this)) .subscribe(::onFetchRelationshipsSuccess) { onFetchRelationshipsFailure(ids) } } private fun onFetchRelationshipsSuccess(relationships: List) { val mutesAdapter = adapter as MutesAdapter val mutingNotificationsMap = HashMap() relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) } mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap) } private fun onFetchRelationshipsFailure(ids: List) { Log.e(TAG, "Fetch failure for relationships of accounts: $ids") } private fun onFetchAccountsFailure(throwable: Throwable) { fetching = false Log.e(TAG, "Fetch failure", throwable) if (adapter.itemCount == 0) { messageView.show() if (throwable is IOException) { messageView.setup(R.drawable.elephant_offline, R.string.error_network) { messageView.hide() this.fetchAccounts(null) } } else { messageView.setup(R.drawable.elephant_error, R.string.error_generic) { messageView.hide() this.fetchAccounts(null) } } } } companion object { private const val TAG = "AccountList" // logging tag private const val ARG_TYPE = "type" private const val ARG_ID = "id" fun newInstance(type: Type, id: String? = null): AccountListFragment { return AccountListFragment().apply { arguments = Bundle(2).apply { putSerializable(ARG_TYPE, type) putString(ARG_ID, id) } } } } }