Redesign report activity (#1295)

* Report activity core

* Implement navigation

* Implement navigation

* Update strings

* Revert manifest formatting

* Implement Done page

* Add landscape layout

* Implement Note fragment

* Create component

* Implement simple status adapter

* Format code

* Add date/time to report statuses

* Refactor status view holder

* Refactor code

* Refactor ViewPager

* Replace MaterialButton with Button

* Remove unneeded string

* Update Text and Check views style

* Remove old ReportActivity and rename Report2Activity to ReportActivity

* Hide "report to remote instance" checkbox for local accounts

* Add account, hashtag and links click handler

* Add media preview

* Add sensitive content support

* Add status expand/collapse support

* Update adapter to user adapterPosition instead of stored status

* Updated checked change handling

* Add polls support to report screen

* Add copyright

* Set buttonTint at CheckBox

* Exclude reblogs from statuses for reports

* Change final page check mark size

* Update report note screen

* Fix typos

* Remove unused params from api endpoint

* Replace .visibility with show()/hide()

* Replace Date().time with System.currentTime...

* Add line spacing

* Fix close button tint issue

* Updated status adapter
This commit is contained in:
pandasoft0 2019-06-09 17:55:34 +03:00 committed by Konrad Pozniak
commit c335651b6b
39 changed files with 2726 additions and 416 deletions

View file

@ -0,0 +1,162 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.Spanned
import android.view.MenuItem
import androidx.appcompat.content.res.AppCompatResources
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.adapter.ReportPagerAdapter
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.HtmlUtils
import com.keylesspalace.tusky.util.ThemeUtils
import dagger.android.AndroidInjector
import dagger.android.DispatchingAndroidInjector
import dagger.android.support.HasSupportFragmentInjector
import kotlinx.android.synthetic.main.activity_report.*
import kotlinx.android.synthetic.main.toolbar_basic.*
import javax.inject.Inject
class ReportActivity : BottomSheetActivity(), HasSupportFragmentInjector {
@Inject
lateinit var dispatchingFragmentInjector: DispatchingAndroidInjector<Fragment>
@Inject
lateinit var viewModelFactory: ViewModelFactory
private lateinit var viewModel: ReportViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProviders.of(this, viewModelFactory)[ReportViewModel::class.java]
val accountId = intent?.getStringExtra(ACCOUNT_ID)
val accountUserName = intent?.getStringExtra(ACCOUNT_USERNAME)
if (accountId.isNullOrBlank() || accountUserName.isNullOrBlank()) {
throw IllegalStateException("accountId ($accountId) or accountUserName ($accountUserName) is null")
}
viewModel.init(accountId, accountUserName,
intent?.getStringExtra(STATUS_ID), intent?.getStringExtra(STATUS_CONTENT))
setContentView(R.layout.activity_report)
setSupportActionBar(toolbar)
val closeIcon = AppCompatResources.getDrawable(this, R.drawable.ic_close_24dp)
ThemeUtils.setDrawableTint(this, closeIcon!!, R.attr.compose_close_button_tint)
supportActionBar?.apply {
title = getString(R.string.report_username_format, viewModel.accountUserName)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
setHomeAsUpIndicator(closeIcon)
}
initViewPager()
if (savedInstanceState == null) {
viewModel.navigateTo(Screen.Statuses)
}
subscribeObservables()
}
private fun initViewPager() {
wizard.adapter = ReportPagerAdapter(supportFragmentManager)
}
private fun subscribeObservables() {
viewModel.navigation.observe(this, Observer { screen ->
if (screen != null) {
viewModel.navigated()
when (screen) {
Screen.Statuses -> showStatusesPage()
Screen.Note -> showNotesPage()
Screen.Done -> showDonePage()
Screen.Back -> showPreviousScreen()
Screen.Finish -> closeScreen()
}
}
})
viewModel.checkUrl.observe(this, Observer {
if (!it.isNullOrBlank()) {
viewModel.urlChecked()
viewUrl(it)
}
})
}
private fun showPreviousScreen() {
when (wizard.currentItem) {
0 -> closeScreen()
1 -> showStatusesPage()
}
}
private fun showDonePage() {
wizard.currentItem = 2
}
private fun showNotesPage() {
wizard.currentItem = 1
}
private fun closeScreen() {
finish()
}
private fun showStatusesPage() {
wizard.currentItem = 0
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
closeScreen()
return true
}
}
return super.onOptionsItemSelected(item)
}
companion object {
private const val ACCOUNT_ID = "account_id"
private const val ACCOUNT_USERNAME = "account_username"
private const val STATUS_ID = "status_id"
private const val STATUS_CONTENT = "status_content"
@JvmStatic
fun getIntent(context: Context, accountId: String, userName: String, statusId: String, statusContent: Spanned) =
Intent(context, ReportActivity::class.java)
.apply {
putExtra(ACCOUNT_ID, accountId)
putExtra(ACCOUNT_USERNAME, userName)
putExtra(STATUS_ID, statusId)
putExtra(STATUS_CONTENT, HtmlUtils.toHtml(statusContent))
}
}
override fun supportFragmentInjector(): AndroidInjector<Fragment> = dispatchingFragmentInjector
}

View file

@ -0,0 +1,224 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.paging.PagedList
import com.keylesspalace.tusky.components.report.adapter.StatusesRepository
import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.*
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import javax.inject.Inject
class ReportViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val statusesRepository: StatusesRepository) : ViewModel() {
private val disposables = CompositeDisposable()
private val navigationMutable = MutableLiveData<Screen>()
val navigation: LiveData<Screen> = navigationMutable
private val muteStateMutable = MutableLiveData<Resource<Boolean>>()
val muteState: LiveData<Resource<Boolean>> = muteStateMutable
private val blockStateMutable = MutableLiveData<Resource<Boolean>>()
val blockState: LiveData<Resource<Boolean>> = blockStateMutable
private val reportingStateMutable = MutableLiveData<Resource<Boolean>>()
var reportingState: LiveData<Resource<Boolean>> = reportingStateMutable
private val checkUrlMutable = MutableLiveData<String>()
val checkUrl: LiveData<String> = checkUrlMutable
private val repoResult = MutableLiveData<BiListing<Status>>()
val statuses: LiveData<PagedList<Status>> = Transformations.switchMap(repoResult) { it.pagedList }
val networkStateAfter: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.networkStateAfter }
val networkStateBefore: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.networkStateBefore }
val networkStateRefresh: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.refreshState }
private val selectedIds = HashSet<String>()
val statusViewState = StatusViewState()
var reportNote: String? = null
var isRemoteNotify = false
private var statusContent: String? = null
private var statusId: String? = null
lateinit var accountUserName: String
lateinit var accountId: String
var isRemoteAccount: Boolean = false
var remoteServer: String? = null
fun init(accountId: String, userName: String, statusId: String?, statusContent: String?) {
this.accountId = accountId
this.accountUserName = userName
this.statusId = statusId
statusId?.let {
selectedIds.add(it)
}
this.statusContent = statusContent
isRemoteAccount = userName.contains('@')
if (isRemoteAccount) {
remoteServer = userName.substring(userName.indexOf('@') + 1)
}
obtainRelationship()
repoResult.value = statusesRepository.getStatuses(accountId, statusId, disposables)
}
override fun onCleared() {
super.onCleared()
disposables.clear()
}
fun navigateTo(screen: Screen) {
navigationMutable.value = screen
}
fun navigated() {
navigationMutable.value = null
}
private fun obtainRelationship() {
val ids = listOf(accountId)
muteStateMutable.value = Loading()
blockStateMutable.value = Loading()
disposables.add(
mastodonApi.relationshipsObservable(ids)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ data ->
updateRelationship(data.getOrNull(0))
},
{
updateRelationship(null)
}
))
}
private fun updateRelationship(relationship: Relationship?) {
if (relationship != null) {
muteStateMutable.value = Success(relationship.muting)
blockStateMutable.value = Success(relationship.blocking)
} else {
muteStateMutable.value = Error(false)
blockStateMutable.value = Error(false)
}
}
fun toggleMute() {
val single: Single<Relationship> = if (muteStateMutable.value?.data == true) {
mastodonApi.unmuteAccountObservable(accountId)
} else {
mastodonApi.muteAccountObservable(accountId)
}
muteStateMutable.value = Loading()
disposables.add(
single
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ relationship ->
muteStateMutable.value = Success(relationship?.muting == true)
},
{ error ->
muteStateMutable.value = Error(false, error.message)
}
))
}
fun toggleBlock() {
val single: Single<Relationship> = if (blockStateMutable.value?.data == true) {
mastodonApi.unblockAccountObservable(accountId)
} else {
mastodonApi.blockAccountObservable(accountId)
}
blockStateMutable.value = Loading()
disposables.add(
single
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ relationship ->
blockStateMutable.value = Success(relationship?.blocking == true)
},
{ error ->
blockStateMutable.value = Error(false, error.message)
}
))
}
fun doReport() {
reportingStateMutable.value = Loading()
disposables.add(
mastodonApi.reportObservable(accountId, selectedIds.toList(), reportNote, if (isRemoteAccount) isRemoteNotify else null)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
reportingStateMutable.value = Success(true)
},
{ error ->
reportingStateMutable.value = Error(cause = error)
}
)
)
}
fun retryStatusLoad() {
repoResult.value?.retry?.invoke()
}
fun refreshStatuses() {
repoResult.value?.refresh?.invoke()
}
fun checkClickedUrl(url: String?) {
checkUrlMutable.value = url
}
fun urlChecked() {
checkUrlMutable.value = null
}
fun setStatusChecked(status: Status, checked: Boolean) {
if (checked)
selectedIds.add(status.id)
else
selectedIds.remove(status.id)
}
fun isStatusChecked(id: String): Boolean {
return selectedIds.contains(id)
}
fun isStatusesSelected(): Boolean {
return selectedIds.isNotEmpty()
}
}

View file

@ -0,0 +1,24 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report
enum class Screen {
Statuses,
Note,
Done,
Back,
Finish
}

View file

@ -0,0 +1,28 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.adapter
import android.view.View
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener
import java.util.ArrayList
interface AdapterHandler: LinkListener {
fun showMedia(v: View?, status: Status?, idx: Int)
fun setStatusChecked(status: Status, isChecked: Boolean)
fun isStatusChecked(id: String): Boolean
}

View file

@ -0,0 +1,36 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.adapter
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment
import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment
import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment
class ReportPagerAdapter(manager: FragmentManager) : FragmentPagerAdapter(manager) {
override fun getItem(position: Int): Fragment {
return when (position) {
0 -> ReportStatusesFragment.newInstance()
1 -> ReportNoteFragment.newInstance()
2 -> ReportDoneFragment.newInstance()
else -> throw IllegalArgumentException("Unknown page index: $position")
}
}
override fun getCount(): Int = 3
}

View file

@ -0,0 +1,166 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.adapter
import android.text.Spanned
import android.text.TextUtils
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER
import com.keylesspalace.tusky.util.StatusViewHelper.Companion.NO_INPUT_FILTER
import kotlinx.android.synthetic.main.item_report_status.view.*
import java.util.*
class StatusViewHolder(itemView: View,
private val useAbsoluteTime: Boolean,
private val mediaPreviewEnabled: Boolean,
private val viewState: StatusViewState,
private val adapterHandler: AdapterHandler,
private val getStatusForPosition: (Int) -> Status?) : RecyclerView.ViewHolder(itemView) {
private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height)
private val statusViewHelper = StatusViewHelper(itemView)
private val previewListener = object : StatusViewHelper.MediaPreviewListener {
override fun onViewMedia(v: View?, idx: Int) {
status()?.let { status ->
adapterHandler.showMedia(v, status, idx)
}
}
override fun onContentHiddenChange(isShowing: Boolean) {
status()?.id?.let { id ->
viewState.setMediaShow(id, isShowing)
}
}
}
init {
itemView.statusSelection.setOnCheckedChangeListener { _, isChecked ->
status()?.let { status ->
adapterHandler.setStatusChecked(status, isChecked)
}
}
}
fun bind(status: Status) {
itemView.statusSelection.isChecked = adapterHandler.isStatusChecked(status.id)
updateTextView()
val sensitive = status.sensitive
statusViewHelper.setMediasPreview(mediaPreviewEnabled, status.attachments, sensitive, previewListener,
viewState.isMediaShow(status.id, status.sensitive),
mediaViewHeight)
statusViewHelper.setupPollReadonly(status.poll, status.emojis, useAbsoluteTime)
setCreatedAt(status.createdAt)
}
private fun updateTextView() {
status()?.let { status ->
setupCollapsedState(status.isCollapsible(), viewState.isCollapsed(status.id, true),
viewState.isContentShow(status.id, status.sensitive), status.spoilerText)
if (status.spoilerText.isBlank()) {
setTextVisible(true, status.content, status.mentions, status.emojis, adapterHandler)
itemView.statusContentWarningButton.hide()
itemView.statusContentWarningDescription.hide()
} else {
val emojiSpoiler = CustomEmojiHelper.emojifyString(status.spoilerText, status.emojis, itemView.statusContentWarningDescription)
itemView.statusContentWarningDescription.text = emojiSpoiler
itemView.statusContentWarningDescription.show()
itemView.statusContentWarningButton.show()
itemView.statusContentWarningButton.isChecked = viewState.isContentShow(status.id, true)
itemView.statusContentWarningButton.setOnCheckedChangeListener { _, isViewChecked ->
status()?.let { status ->
itemView.statusContentWarningDescription.invalidate()
viewState.setContentShow(status.id, isViewChecked)
setTextVisible(isViewChecked, status.content, status.mentions, status.emojis, adapterHandler)
}
}
setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.emojis, adapterHandler)
}
}
}
private fun setTextVisible(expanded: Boolean,
content: Spanned,
mentions: Array<Status.Mention>?,
emojis: List<Emoji>,
listener: LinkListener) {
if (expanded) {
val emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, itemView.statusContent)
LinkHelper.setClickableText(itemView.statusContent, emojifiedText, mentions, listener)
} else {
LinkHelper.setClickableMentions(itemView.statusContent, mentions, listener)
}
if (itemView.statusContent.text.isNullOrBlank()) {
itemView.statusContent.hide()
} else {
itemView.statusContent.show()
}
}
private fun setCreatedAt(createdAt: Date?) {
if (useAbsoluteTime) {
itemView.timestampInfo.text = statusViewHelper.getAbsoluteTime(createdAt)
} else {
itemView.timestampInfo.text = if (createdAt != null) {
val then = createdAt.time
val now = System.currentTimeMillis()
DateUtils.getRelativeTimeSpanString(itemView.timestampInfo.context, then, now)
} else {
// unknown minutes~
"?m"
}
}
}
private fun setupCollapsedState(collapsible: Boolean, collapsed: Boolean, expanded: Boolean, spoilerText: String) {
/* input filter for TextViews have to be set before text */
if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
itemView.buttonToggleContent.setOnCheckedChangeListener { _, isChecked ->
status()?.let { status ->
viewState.setCollapsed(status.id, isChecked)
updateTextView()
}
}
itemView.buttonToggleContent.show()
if (collapsed) {
itemView.buttonToggleContent.isChecked = true
itemView.statusContent.filters = COLLAPSE_INPUT_FILTER
} else {
itemView.buttonToggleContent.isChecked = false
itemView.statusContent.filters = NO_INPUT_FILTER
}
} else {
itemView.buttonToggleContent.hide()
itemView.statusContent.filters = NO_INPUT_FILTER
}
}
private fun status() = getStatusForPosition(adapterPosition)
}

View file

@ -0,0 +1,62 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.entity.Status
class StatusesAdapter(private val useAbsoluteTime: Boolean,
private val mediaPreviewEnabled: Boolean,
private val statusViewState: StatusViewState,
private val adapterHandler: AdapterHandler)
: PagedListAdapter<Status, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
private val statusForPosition: (Int) -> Status? = { position: Int ->
if (position != RecyclerView.NO_POSITION) getItem(position) else null
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return StatusViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_report_status, parent, false),
useAbsoluteTime, mediaPreviewEnabled, statusViewState, adapterHandler, statusForPosition)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
getItem(position)?.let { status ->
(holder as? StatusViewHolder)?.bind(status)
}
}
companion object {
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Status>() {
override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean =
oldItem == newItem
override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean =
oldItem.id == newItem.id
}
}
}

View file

@ -0,0 +1,145 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.adapter
import android.annotation.SuppressLint
import androidx.lifecycle.MutableLiveData
import androidx.paging.ItemKeyedDataSource
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.NetworkState
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.functions.BiFunction
import java.util.concurrent.Executor
class StatusesDataSource(private val accountId: String,
private val mastodonApi: MastodonApi,
private val disposables: CompositeDisposable,
private val retryExecutor: Executor) : ItemKeyedDataSource<String, Status>() {
val networkStateAfter = MutableLiveData<NetworkState>()
val networkStateBefore = MutableLiveData<NetworkState>()
private var retryAfter: (() -> Any)? = null
private var retryBefore: (() -> Any)? = null
private var retryInitial: (() -> Any)? = null
val initialLoad = MutableLiveData<NetworkState>()
fun retryAllFailed() {
var prevRetry = retryInitial
retryInitial = null
prevRetry?.let {
retryExecutor.execute {
it.invoke()
}
}
prevRetry = retryAfter
retryAfter = null
prevRetry?.let {
retryExecutor.execute {
it.invoke()
}
}
prevRetry = retryBefore
retryBefore = null
prevRetry?.let {
retryExecutor.execute {
it.invoke()
}
}
}
@SuppressLint("CheckResult")
override fun loadInitial(params: LoadInitialParams<String>, callback: LoadInitialCallback<Status>) {
networkStateAfter.postValue(NetworkState.LOADED)
networkStateBefore.postValue(NetworkState.LOADED)
retryAfter = null
retryBefore = null
retryInitial = null
initialLoad.postValue(NetworkState.LOADING)
mastodonApi.statusObservable(params.requestedInitialKey).zipWith(
mastodonApi.accountStatusesObservable(accountId, params.requestedInitialKey, null, params.requestedLoadSize - 1, true),
BiFunction { status: Status, list: List<Status> ->
val ret = ArrayList<Status>()
ret.add(status)
ret.addAll(list)
return@BiFunction ret
})
.doOnSubscribe {
disposables.add(it)
}
.subscribe(
{
callback.onResult(it)
initialLoad.postValue(NetworkState.LOADED)
},
{
retryInitial = {
loadInitial(params, callback)
}
initialLoad.postValue(NetworkState.error(it.message))
}
)
}
@SuppressLint("CheckResult")
override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<Status>) {
networkStateAfter.postValue(NetworkState.LOADING)
retryAfter = null
mastodonApi.accountStatusesObservable(accountId, params.key, null, params.requestedLoadSize, true)
.doOnSubscribe {
disposables.add(it)
}
.subscribe(
{
callback.onResult(it)
networkStateAfter.postValue(NetworkState.LOADED)
},
{
retryAfter = {
loadAfter(params, callback)
}
networkStateAfter.postValue(NetworkState.error(it.message))
}
)
}
@SuppressLint("CheckResult")
override fun loadBefore(params: LoadParams<String>, callback: LoadCallback<Status>) {
networkStateBefore.postValue(NetworkState.LOADING)
retryBefore = null
mastodonApi.accountStatusesObservable(accountId, null, params.key, params.requestedLoadSize, true)
.doOnSubscribe {
disposables.add(it)
}
.subscribe(
{
callback.onResult(it)
networkStateBefore.postValue(NetworkState.LOADED)
},
{
retryBefore = {
loadBefore(params, callback)
}
networkStateBefore.postValue(NetworkState.error(it.message))
}
)
}
override fun getKey(item: Status): String = item.id
}

View file

@ -0,0 +1,36 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.adapter
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.disposables.CompositeDisposable
import java.util.concurrent.Executor
class StatusesDataSourceFactory(
private val accountId: String,
private val mastodonApi: MastodonApi,
private val disposables: CompositeDisposable,
private val retryExecutor: Executor) : DataSource.Factory<String, Status>() {
val sourceLiveData = MutableLiveData<StatusesDataSource>()
override fun create(): DataSource<String, Status> {
val source = StatusesDataSource(accountId, mastodonApi, disposables, retryExecutor)
sourceLiveData.postValue(source)
return source
}
}

View file

@ -0,0 +1,61 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.adapter
import androidx.lifecycle.Transformations
import androidx.paging.Config
import androidx.paging.toLiveData
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.BiListing
import com.keylesspalace.tusky.util.Listing
import io.reactivex.disposables.CompositeDisposable
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StatusesRepository @Inject constructor(private val mastodonApi: MastodonApi) {
private val executor = Executors.newSingleThreadExecutor()
fun getStatuses(accountId: String, initialStatus: String?, disposables: CompositeDisposable, pageSize: Int = 20): BiListing<Status> {
val sourceFactory = StatusesDataSourceFactory(accountId, mastodonApi, disposables, executor)
val livePagedList = sourceFactory.toLiveData(
config = Config(pageSize = pageSize, enablePlaceholders = false, initialLoadSizeHint = pageSize * 2),
fetchExecutor = executor, initialLoadKey = initialStatus
)
return BiListing(
pagedList = livePagedList,
networkStateBefore = Transformations.switchMap(sourceFactory.sourceLiveData) {
it.networkStateBefore
},
networkStateAfter = Transformations.switchMap(sourceFactory.sourceLiveData) {
it.networkStateAfter
},
retry = {
sourceFactory.sourceLiveData.value?.retryAllFailed()
},
refresh = {
sourceFactory.sourceLiveData.value?.invalidate()
},
refreshState = Transformations.switchMap(sourceFactory.sourceLiveData) {
it.initialLoad
}
)
}
}

View file

@ -0,0 +1,111 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.report.Screen
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import kotlinx.android.synthetic.main.fragment_report_done.*
import javax.inject.Inject
class ReportDoneFragment : Fragment(), Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private lateinit var viewModel: ReportViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProviders.of(requireActivity(), viewModelFactory)[ReportViewModel::class.java]
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_report_done, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
textReported.text = getString(R.string.report_sent_success, viewModel.accountUserName)
handleClicks()
subscribeObservables()
}
private fun subscribeObservables() {
viewModel.muteState.observe(viewLifecycleOwner, Observer {
if (it !is Loading) {
buttonMute.show()
progressMute.show()
} else {
buttonMute.hide()
progressMute.hide()
}
buttonMute.setText(when {
it.data == true -> R.string.action_unmute
else -> R.string.action_mute
})
})
viewModel.blockState.observe(viewLifecycleOwner, Observer {
if (it !is Loading) {
buttonBlock.show()
progressBlock.show()
}
else{
buttonBlock.hide()
progressBlock.hide()
}
buttonBlock.setText(when {
it.data == true -> R.string.action_unblock
else -> R.string.action_block
})
})
}
private fun handleClicks() {
buttonDone.setOnClickListener {
viewModel.navigateTo(Screen.Finish)
}
buttonBlock.setOnClickListener {
viewModel.toggleBlock()
}
buttonMute.setOnClickListener {
viewModel.toggleMute()
}
}
companion object {
fun newInstance() = ReportDoneFragment()
}
}

View file

@ -0,0 +1,141 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.report.Screen
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.*
import kotlinx.android.synthetic.main.fragment_report_note.*
import java.io.IOException
import javax.inject.Inject
class ReportNoteFragment : Fragment(), Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private lateinit var viewModel: ReportViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProviders.of(requireActivity(), viewModelFactory)[ReportViewModel::class.java]
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_report_note, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
fillViews()
handleChanges()
handleClicks()
subscribeObservables()
}
private fun handleChanges() {
editNote.doAfterTextChanged {
viewModel.reportNote = it?.toString()
}
checkIsNotifyRemote.setOnCheckedChangeListener { _, isChecked ->
viewModel.isRemoteNotify = isChecked
}
}
private fun fillViews() {
editNote.setText(viewModel.reportNote)
if (viewModel.isRemoteAccount){
checkIsNotifyRemote.show()
reportDescriptionRemoteInstance.show()
}
else{
checkIsNotifyRemote.hide()
reportDescriptionRemoteInstance.hide()
}
if (viewModel.isRemoteAccount)
checkIsNotifyRemote.text = getString(R.string.report_remote_instance, viewModel.remoteServer)
checkIsNotifyRemote.isChecked = viewModel.isRemoteNotify
}
private fun subscribeObservables() {
viewModel.reportingState.observe(viewLifecycleOwner, Observer {
when (it) {
is Success -> viewModel.navigateTo(Screen.Done)
is Loading -> showLoading()
is Error -> showError(it.cause)
}
})
}
private fun showError(error: Throwable?) {
editNote.isEnabled = true
checkIsNotifyRemote.isEnabled = true
buttonReport.isEnabled = true
buttonBack.isEnabled = true
progressBar.hide()
Snackbar.make(buttonBack, if (error is IOException) R.string.error_network else R.string.error_generic, Snackbar.LENGTH_LONG)
.apply {
setAction(R.string.action_retry) {
sendReport()
}
}
.show()
}
private fun sendReport() {
viewModel.doReport()
}
private fun showLoading() {
buttonReport.isEnabled = false
buttonBack.isEnabled = false
editNote.isEnabled = false
checkIsNotifyRemote.isEnabled = false
progressBar.show()
}
private fun handleClicks() {
buttonBack.setOnClickListener {
viewModel.navigateTo(Screen.Back)
}
buttonReport.setOnClickListener {
sendReport()
}
}
companion object {
fun newInstance() = ReportNoteFragment()
}
}

View file

@ -0,0 +1,216 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.fragments
import android.os.Bundle
import android.preference.PreferenceManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.paging.PagedList
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.report.Screen
import com.keylesspalace.tusky.components.report.adapter.AdapterHandler
import com.keylesspalace.tusky.components.report.adapter.StatusesAdapter
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import kotlinx.android.synthetic.main.fragment_report_statuses.*
import javax.inject.Inject
class ReportStatusesFragment : Fragment(), Injectable, AdapterHandler {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var accountManager: AccountManager
private lateinit var viewModel: ReportViewModel
private lateinit var adapter: StatusesAdapter
private lateinit var layoutManager: LinearLayoutManager
private var snackbarErrorRetry: Snackbar? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProviders.of(requireActivity(), viewModelFactory)[ReportViewModel::class.java]
}
override fun showMedia(v: View?, status: Status?, idx: Int) {
status?.actionableStatus?.let { actionable ->
when (actionable.attachments[idx].type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE -> {
val attachments = AttachmentViewData.list(actionable)
val intent = ViewMediaActivity.newIntent(context, attachments,
idx)
if (v != null) {
val url = actionable.attachments[idx].url
ViewCompat.setTransitionName(v, url)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(),
v, url)
startActivity(intent, options.toBundle())
} else {
startActivity(intent)
}
}
Attachment.Type.UNKNOWN -> {
}
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_report_statuses, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
handleClicks()
initStatusesView()
setupSwipeRefreshLayout()
}
private fun setupSwipeRefreshLayout() {
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground))
swipeRefreshLayout.setOnRefreshListener {
snackbarErrorRetry?.dismiss()
viewModel.refreshStatuses()
}
}
private fun initStatusesView() {
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
val account = accountManager.activeAccount
val mediaPreviewEnabled = account?.mediaPreviewEnabled ?: true
adapter = StatusesAdapter(useAbsoluteTime, mediaPreviewEnabled, viewModel.statusViewState, this)
recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
layoutManager = LinearLayoutManager(requireContext())
recyclerView.layoutManager = layoutManager
recyclerView.adapter = adapter
(recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
viewModel.statuses.observe(viewLifecycleOwner, Observer<PagedList<Status>> {
adapter.submitList(it)
})
viewModel.networkStateAfter.observe(viewLifecycleOwner, Observer {
if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING)
progressBarBottom.show()
else
progressBarBottom.hide()
if (it?.status == com.keylesspalace.tusky.util.Status.FAILED)
showError(it.msg)
})
viewModel.networkStateBefore.observe(viewLifecycleOwner, Observer {
if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING)
progressBarTop.show()
else
progressBarTop.hide()
if (it?.status == com.keylesspalace.tusky.util.Status.FAILED)
showError(it.msg)
})
viewModel.networkStateRefresh.observe(viewLifecycleOwner, Observer {
if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING && !swipeRefreshLayout.isRefreshing)
progressBarLoading.show()
else
progressBarLoading.hide()
if (it?.status != com.keylesspalace.tusky.util.Status.RUNNING)
swipeRefreshLayout.isRefreshing = false
if (it?.status == com.keylesspalace.tusky.util.Status.FAILED)
showError(it.msg)
})
}
private fun showError(@Suppress("UNUSED_PARAMETER") msg: String?) {
if (snackbarErrorRetry?.isShown != true) {
snackbarErrorRetry = Snackbar.make(swipeRefreshLayout, R.string.failed_fetch_statuses, Snackbar.LENGTH_INDEFINITE)
snackbarErrorRetry?.setAction(R.string.action_retry) {
viewModel.retryStatusLoad()
}
snackbarErrorRetry?.show()
}
}
private fun handleClicks() {
buttonCancel.setOnClickListener {
viewModel.navigateTo(Screen.Back)
}
buttonContinue.setOnClickListener {
if (viewModel.isStatusesSelected()) {
viewModel.navigateTo(Screen.Note)
} else {
Snackbar.make(swipeRefreshLayout, R.string.error_report_too_few_statuses, Snackbar.LENGTH_LONG).show()
}
}
}
override fun setStatusChecked(status: Status, isChecked: Boolean) {
viewModel.setStatusChecked(status, isChecked)
}
override fun isStatusChecked(id: String): Boolean {
return viewModel.isStatusChecked(id)
}
override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id))
override fun onViewTag(tag: String) = startActivity(ViewTagActivity.getIntent(requireContext(), tag))
override fun onViewUrl(url: String?) = viewModel.checkClickedUrl(url)
companion object {
fun newInstance() = ReportStatusesFragment()
}
}

View file

@ -0,0 +1,36 @@
/* Copyright 2019 Joel Pyska
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.model
class StatusViewState {
private val mediaShownState = HashMap<String, Boolean>()
private val contentShownState = HashMap<String, Boolean>()
private val longContentCollapsedState = HashMap<String, Boolean>()
fun isMediaShow(id: String, isSensitive: Boolean): Boolean = isStateEnabled(mediaShownState, id, !isSensitive)
fun setMediaShow(id: String, isShow: Boolean) = setStateEnabled(mediaShownState, id, isShow)
fun isContentShow(id: String, isSensitive: Boolean): Boolean = isStateEnabled(contentShownState, id, !isSensitive)
fun setContentShow(id: String, isShow: Boolean) = setStateEnabled(contentShownState, id, isShow)
fun isCollapsed(id: String, isCollapsed: Boolean): Boolean = isStateEnabled(longContentCollapsedState, id, isCollapsed)
fun setCollapsed(id: String, isCollapsed: Boolean) = setStateEnabled(longContentCollapsedState, id, isCollapsed)
private fun isStateEnabled(map: Map<String, Boolean>, id: String, def: Boolean): Boolean = map[id]
?: def
private fun setStateEnabled(map: MutableMap<String, Boolean>, id: String, state: Boolean) = map.put(id, state)
}