mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
* initial class setup * handle events and filters * handle status state changes * code formatting * fix status filtering * cleanup code a bit * implement removeAllByAccountId * move toolbar into fragment, implement menu * error and load state handling * fix pull to refresh * implement reveal button * use requireContext() instead of context!! * jump to detailed status * add ViewThreadViewModelTest * fix ktlint * small code improvements (thx charlag) * add testcase for toggleRevealButton * add more state change testcases to ViewThreadViewModelpull/2666/head
24 changed files with 1445 additions and 998 deletions
@ -1,130 +0,0 @@
|
||||
/* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky; |
||||
|
||||
import android.content.Context; |
||||
import android.content.Intent; |
||||
import android.os.Bundle; |
||||
import androidx.annotation.Nullable; |
||||
import androidx.fragment.app.FragmentTransaction; |
||||
import androidx.appcompat.app.ActionBar; |
||||
import androidx.appcompat.widget.Toolbar; |
||||
import android.view.Menu; |
||||
import android.view.MenuItem; |
||||
|
||||
import com.keylesspalace.tusky.fragment.ViewThreadFragment; |
||||
import com.keylesspalace.tusky.util.LinkHelper; |
||||
|
||||
import javax.inject.Inject; |
||||
|
||||
import dagger.android.AndroidInjector; |
||||
import dagger.android.DispatchingAndroidInjector; |
||||
import dagger.android.HasAndroidInjector; |
||||
|
||||
public class ViewThreadActivity extends BottomSheetActivity implements HasAndroidInjector { |
||||
|
||||
public static final int REVEAL_BUTTON_HIDDEN = 1; |
||||
public static final int REVEAL_BUTTON_REVEAL = 2; |
||||
public static final int REVEAL_BUTTON_HIDE = 3; |
||||
|
||||
public static Intent startIntent(Context context, String id, String url) { |
||||
Intent intent = new Intent(context, ViewThreadActivity.class); |
||||
intent.putExtra(ID_EXTRA, id); |
||||
intent.putExtra(URL_EXTRA, url); |
||||
return intent; |
||||
} |
||||
|
||||
private static final String ID_EXTRA = "id"; |
||||
private static final String URL_EXTRA = "url"; |
||||
private static final String FRAGMENT_TAG = "ViewThreadFragment_"; |
||||
|
||||
private int revealButtonState = REVEAL_BUTTON_HIDDEN; |
||||
|
||||
@Inject |
||||
public DispatchingAndroidInjector<Object> dispatchingAndroidInjector; |
||||
|
||||
private ViewThreadFragment fragment; |
||||
|
||||
@Override |
||||
protected void onCreate(@Nullable Bundle savedInstanceState) { |
||||
super.onCreate(savedInstanceState); |
||||
setContentView(R.layout.activity_view_thread); |
||||
|
||||
Toolbar toolbar = findViewById(R.id.toolbar); |
||||
setSupportActionBar(toolbar); |
||||
ActionBar actionBar = getSupportActionBar(); |
||||
if (actionBar != null) { |
||||
actionBar.setTitle(R.string.title_view_thread); |
||||
actionBar.setDisplayHomeAsUpEnabled(true); |
||||
actionBar.setDisplayShowHomeEnabled(true); |
||||
} |
||||
|
||||
String id = getIntent().getStringExtra(ID_EXTRA); |
||||
|
||||
fragment = (ViewThreadFragment)getSupportFragmentManager().findFragmentByTag(FRAGMENT_TAG + id); |
||||
if(fragment == null) { |
||||
fragment = ViewThreadFragment.newInstance(id); |
||||
} |
||||
|
||||
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); |
||||
fragmentTransaction.replace(R.id.fragment_container, fragment, FRAGMENT_TAG + id); |
||||
fragmentTransaction.commit(); |
||||
} |
||||
|
||||
@Override |
||||
public boolean onCreateOptionsMenu(Menu menu) { |
||||
getMenuInflater().inflate(R.menu.view_thread_toolbar, menu); |
||||
MenuItem menuItem = menu.findItem(R.id.action_reveal); |
||||
menuItem.setVisible(revealButtonState != REVEAL_BUTTON_HIDDEN); |
||||
menuItem.setIcon(revealButtonState == REVEAL_BUTTON_REVEAL ? |
||||
R.drawable.ic_eye_24dp : R.drawable.ic_hide_media_24dp); |
||||
return super.onCreateOptionsMenu(menu); |
||||
} |
||||
|
||||
public void setRevealButtonState(int state) { |
||||
switch (state) { |
||||
case REVEAL_BUTTON_HIDDEN: |
||||
case REVEAL_BUTTON_REVEAL: |
||||
case REVEAL_BUTTON_HIDE: |
||||
this.revealButtonState = state; |
||||
invalidateOptionsMenu(); |
||||
break; |
||||
default: |
||||
throw new IllegalArgumentException("Invalid reveal button state: " + state); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public boolean onOptionsItemSelected(MenuItem item) { |
||||
switch (item.getItemId()) { |
||||
case R.id.action_open_in_web: { |
||||
openLink(getIntent().getStringExtra(URL_EXTRA)); |
||||
return true; |
||||
} |
||||
case R.id.action_reveal: { |
||||
fragment.onRevealPressed(); |
||||
return true; |
||||
} |
||||
} |
||||
return super.onOptionsItemSelected(item); |
||||
} |
||||
|
||||
@Override |
||||
public AndroidInjector<Object> androidInjector() { |
||||
return dispatchingAndroidInjector; |
||||
} |
||||
|
||||
} |
||||
@ -1,129 +0,0 @@
|
||||
/* Copyright 2021 Tusky Contributors |
||||
* |
||||
* 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.adapter |
||||
|
||||
import android.view.LayoutInflater |
||||
import android.view.ViewGroup |
||||
import androidx.recyclerview.widget.RecyclerView |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener |
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions |
||||
import com.keylesspalace.tusky.viewdata.StatusViewData |
||||
|
||||
class ThreadAdapter( |
||||
private val statusDisplayOptions: StatusDisplayOptions, |
||||
private val statusActionListener: StatusActionListener |
||||
) : RecyclerView.Adapter<StatusBaseViewHolder>() { |
||||
private val statuses = mutableListOf<StatusViewData.Concrete>() |
||||
var detailedStatusPosition: Int = RecyclerView.NO_POSITION |
||||
private set |
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { |
||||
return when (viewType) { |
||||
VIEW_TYPE_STATUS -> { |
||||
val view = LayoutInflater.from(parent.context) |
||||
.inflate(R.layout.item_status, parent, false) |
||||
StatusViewHolder(view) |
||||
} |
||||
VIEW_TYPE_STATUS_DETAILED -> { |
||||
val view = LayoutInflater.from(parent.context) |
||||
.inflate(R.layout.item_status_detailed, parent, false) |
||||
StatusDetailedViewHolder(view) |
||||
} |
||||
else -> error("Unknown item type: $viewType") |
||||
} |
||||
} |
||||
|
||||
override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) { |
||||
val status = statuses[position] |
||||
viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions) |
||||
} |
||||
|
||||
override fun getItemViewType(position: Int): Int { |
||||
return if (position == detailedStatusPosition) { |
||||
VIEW_TYPE_STATUS_DETAILED |
||||
} else { |
||||
VIEW_TYPE_STATUS |
||||
} |
||||
} |
||||
|
||||
override fun getItemCount(): Int = statuses.size |
||||
|
||||
fun setStatuses(statuses: List<StatusViewData.Concrete>?) { |
||||
this.statuses.clear() |
||||
this.statuses.addAll(statuses!!) |
||||
notifyDataSetChanged() |
||||
} |
||||
|
||||
fun addItem(position: Int, statusViewData: StatusViewData.Concrete) { |
||||
statuses.add(position, statusViewData) |
||||
notifyItemInserted(position) |
||||
} |
||||
|
||||
fun clearItems() { |
||||
val oldSize = statuses.size |
||||
statuses.clear() |
||||
detailedStatusPosition = RecyclerView.NO_POSITION |
||||
notifyItemRangeRemoved(0, oldSize) |
||||
} |
||||
|
||||
fun addAll(position: Int, statuses: List<StatusViewData.Concrete>) { |
||||
this.statuses.addAll(position, statuses) |
||||
notifyItemRangeInserted(position, statuses.size) |
||||
} |
||||
|
||||
fun addAll(statuses: List<StatusViewData.Concrete>) { |
||||
val end = statuses.size |
||||
this.statuses.addAll(statuses) |
||||
notifyItemRangeInserted(end, statuses.size) |
||||
} |
||||
|
||||
fun removeItem(position: Int) { |
||||
statuses.removeAt(position) |
||||
notifyItemRemoved(position) |
||||
} |
||||
|
||||
fun clear() { |
||||
statuses.clear() |
||||
detailedStatusPosition = RecyclerView.NO_POSITION |
||||
notifyDataSetChanged() |
||||
} |
||||
|
||||
fun setItem(position: Int, status: StatusViewData.Concrete, notifyAdapter: Boolean) { |
||||
statuses[position] = status |
||||
if (notifyAdapter) { |
||||
notifyItemChanged(position) |
||||
} |
||||
} |
||||
|
||||
fun getItem(position: Int): StatusViewData.Concrete? = statuses.getOrNull(position) |
||||
|
||||
fun setDetailedStatusPosition(position: Int) { |
||||
if (position != detailedStatusPosition && |
||||
detailedStatusPosition != RecyclerView.NO_POSITION |
||||
) { |
||||
val prior = detailedStatusPosition |
||||
detailedStatusPosition = position |
||||
notifyItemChanged(prior) |
||||
} else { |
||||
detailedStatusPosition = position |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
private const val VIEW_TYPE_STATUS = 0 |
||||
private const val VIEW_TYPE_STATUS_DETAILED = 1 |
||||
} |
||||
} |
||||
@ -0,0 +1,95 @@
|
||||
/* Copyright 2021 Tusky Contributors |
||||
* |
||||
* 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.viewthread |
||||
|
||||
import android.view.LayoutInflater |
||||
import android.view.ViewGroup |
||||
import androidx.recyclerview.widget.DiffUtil |
||||
import androidx.recyclerview.widget.ListAdapter |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder |
||||
import com.keylesspalace.tusky.adapter.StatusDetailedViewHolder |
||||
import com.keylesspalace.tusky.adapter.StatusViewHolder |
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener |
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions |
||||
import com.keylesspalace.tusky.viewdata.StatusViewData |
||||
|
||||
class ThreadAdapter( |
||||
private val statusDisplayOptions: StatusDisplayOptions, |
||||
private val statusActionListener: StatusActionListener |
||||
) : ListAdapter<StatusViewData.Concrete, StatusBaseViewHolder>(ThreadDifferCallback) { |
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { |
||||
return when (viewType) { |
||||
VIEW_TYPE_STATUS -> { |
||||
val view = LayoutInflater.from(parent.context) |
||||
.inflate(R.layout.item_status, parent, false) |
||||
StatusViewHolder(view) |
||||
} |
||||
VIEW_TYPE_STATUS_DETAILED -> { |
||||
val view = LayoutInflater.from(parent.context) |
||||
.inflate(R.layout.item_status_detailed, parent, false) |
||||
StatusDetailedViewHolder(view) |
||||
} |
||||
else -> error("Unknown item type: $viewType") |
||||
} |
||||
} |
||||
|
||||
override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) { |
||||
val status = getItem(position) |
||||
viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions) |
||||
} |
||||
|
||||
override fun getItemViewType(position: Int): Int { |
||||
return if (getItem(position).isDetailed) { |
||||
VIEW_TYPE_STATUS_DETAILED |
||||
} else { |
||||
VIEW_TYPE_STATUS |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
private const val VIEW_TYPE_STATUS = 0 |
||||
private const val VIEW_TYPE_STATUS_DETAILED = 1 |
||||
|
||||
val ThreadDifferCallback = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() { |
||||
override fun areItemsTheSame( |
||||
oldItem: StatusViewData.Concrete, |
||||
newItem: StatusViewData.Concrete |
||||
): Boolean { |
||||
return oldItem.id == newItem.id |
||||
} |
||||
|
||||
override fun areContentsTheSame( |
||||
oldItem: StatusViewData.Concrete, |
||||
newItem: StatusViewData.Concrete |
||||
): Boolean { |
||||
return false // Items are different always. It allows to refresh timestamp on every view holder update |
||||
} |
||||
|
||||
override fun getChangePayload( |
||||
oldItem: StatusViewData.Concrete, |
||||
newItem: StatusViewData.Concrete |
||||
): Any? { |
||||
return if (oldItem == newItem) { |
||||
// If items are equal - update timestamp only |
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED) |
||||
} else // If items are different - update the whole view holder |
||||
null |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,62 @@
|
||||
/* Copyright 2022 Tusky Contributors |
||||
* |
||||
* 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.viewthread |
||||
|
||||
import android.content.Context |
||||
import android.content.Intent |
||||
import android.os.Bundle |
||||
import androidx.fragment.app.commit |
||||
import com.keylesspalace.tusky.BottomSheetActivity |
||||
import com.keylesspalace.tusky.R |
||||
import dagger.android.DispatchingAndroidInjector |
||||
import dagger.android.HasAndroidInjector |
||||
import javax.inject.Inject |
||||
|
||||
class ViewThreadActivity : BottomSheetActivity(), HasAndroidInjector { |
||||
|
||||
@Inject |
||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any> |
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
setContentView(R.layout.activity_view_thread) |
||||
val id = intent.getStringExtra(ID_EXTRA)!! |
||||
val url = intent.getStringExtra(URL_EXTRA)!! |
||||
val fragment = |
||||
supportFragmentManager.findFragmentByTag(FRAGMENT_TAG + id) as ViewThreadFragment? |
||||
?: ViewThreadFragment.newInstance(id, url) |
||||
|
||||
supportFragmentManager.commit { |
||||
replace(R.id.fragment_container, fragment, FRAGMENT_TAG + id) |
||||
} |
||||
} |
||||
|
||||
override fun androidInjector() = dispatchingAndroidInjector |
||||
|
||||
companion object { |
||||
|
||||
fun startIntent(context: Context, id: String, url: String): Intent { |
||||
val intent = Intent(context, ViewThreadActivity::class.java) |
||||
intent.putExtra(ID_EXTRA, id) |
||||
intent.putExtra(URL_EXTRA, url) |
||||
return intent |
||||
} |
||||
|
||||
private const val ID_EXTRA = "id" |
||||
private const val URL_EXTRA = "url" |
||||
private const val FRAGMENT_TAG = "ViewThreadFragment_" |
||||
} |
||||
} |
||||
@ -0,0 +1,337 @@
|
||||
/* Copyright 2022 Tusky Contributors |
||||
* |
||||
* 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.viewthread |
||||
|
||||
import android.os.Bundle |
||||
import android.util.Log |
||||
import android.view.LayoutInflater |
||||
import android.view.View |
||||
import android.view.ViewGroup |
||||
import android.widget.LinearLayout |
||||
import androidx.fragment.app.viewModels |
||||
import androidx.lifecycle.lifecycleScope |
||||
import androidx.preference.PreferenceManager |
||||
import androidx.recyclerview.widget.DividerItemDecoration |
||||
import androidx.recyclerview.widget.LinearLayoutManager |
||||
import androidx.recyclerview.widget.SimpleItemAnimator |
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener |
||||
import com.google.android.material.snackbar.Snackbar |
||||
import com.keylesspalace.tusky.AccountListActivity |
||||
import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent |
||||
import com.keylesspalace.tusky.BaseActivity |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding |
||||
import com.keylesspalace.tusky.di.Injectable |
||||
import com.keylesspalace.tusky.di.ViewModelFactory |
||||
import com.keylesspalace.tusky.fragment.SFragment |
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener |
||||
import com.keylesspalace.tusky.settings.PrefKeys |
||||
import com.keylesspalace.tusky.util.CardViewMode |
||||
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate |
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions |
||||
import com.keylesspalace.tusky.util.hide |
||||
import com.keylesspalace.tusky.util.openLink |
||||
import com.keylesspalace.tusky.util.show |
||||
import com.keylesspalace.tusky.util.viewBinding |
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list |
||||
import com.keylesspalace.tusky.viewdata.StatusViewData |
||||
import kotlinx.coroutines.launch |
||||
import java.io.IOException |
||||
import javax.inject.Inject |
||||
|
||||
class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable { |
||||
|
||||
@Inject |
||||
lateinit var viewModelFactory: ViewModelFactory |
||||
|
||||
private val viewModel: ViewThreadViewModel by viewModels { viewModelFactory } |
||||
|
||||
private val binding by viewBinding(FragmentViewThreadBinding::bind) |
||||
|
||||
private lateinit var adapter: ThreadAdapter |
||||
private lateinit var thisThreadsStatusId: String |
||||
|
||||
private var alwaysShowSensitiveMedia = false |
||||
private var alwaysOpenSpoiler = false |
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
thisThreadsStatusId = requireArguments().getString(ID_EXTRA)!! |
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) |
||||
|
||||
val statusDisplayOptions = StatusDisplayOptions( |
||||
animateAvatars = preferences.getBoolean("animateGifAvatars", false), |
||||
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, |
||||
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), |
||||
showBotOverlay = preferences.getBoolean("showBotOverlay", true), |
||||
useBlurhash = preferences.getBoolean("useBlurhash", true), |
||||
cardViewMode = if (preferences.getBoolean("showCardsInTimelines", false)) { |
||||
CardViewMode.INDENTED |
||||
} else { |
||||
CardViewMode.NONE |
||||
}, |
||||
confirmReblogs = preferences.getBoolean("confirmReblogs", true), |
||||
confirmFavourites = preferences.getBoolean("confirmFavourites", false), |
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), |
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) |
||||
) |
||||
adapter = ThreadAdapter(statusDisplayOptions, this) |
||||
} |
||||
|
||||
override fun onCreateView( |
||||
inflater: LayoutInflater, |
||||
container: ViewGroup?, |
||||
savedInstanceState: Bundle? |
||||
): View? { |
||||
return inflater.inflate(R.layout.fragment_view_thread, container, false) |
||||
} |
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
||||
|
||||
binding.toolbar.setNavigationOnClickListener { |
||||
activity?.onBackPressed() |
||||
} |
||||
binding.toolbar.setOnMenuItemClickListener { menuItem -> |
||||
when (menuItem.itemId) { |
||||
R.id.action_reveal -> { |
||||
viewModel.toggleRevealButton() |
||||
true |
||||
} |
||||
R.id.action_open_in_web -> { |
||||
context?.openLink(requireArguments().getString(URL_EXTRA)!!) |
||||
true |
||||
} |
||||
else -> false |
||||
} |
||||
} |
||||
|
||||
binding.swipeRefreshLayout.setOnRefreshListener(this) |
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) |
||||
|
||||
binding.recyclerView.setHasFixedSize(true) |
||||
binding.recyclerView.layoutManager = LinearLayoutManager(context) |
||||
binding.recyclerView.setAccessibilityDelegateCompat( |
||||
ListStatusAccessibilityDelegate( |
||||
binding.recyclerView, |
||||
this |
||||
) { index -> adapter.currentList.getOrNull(index) } |
||||
) |
||||
val divider = DividerItemDecoration(context, LinearLayout.VERTICAL) |
||||
binding.recyclerView.addItemDecoration(divider) |
||||
binding.recyclerView.addItemDecoration(ConversationLineItemDecoration(requireContext())) |
||||
alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia |
||||
alwaysOpenSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler |
||||
|
||||
binding.recyclerView.adapter = adapter |
||||
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false |
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch { |
||||
viewModel.uiState.collect { uiState -> |
||||
when (uiState) { |
||||
is ThreadUiState.Loading -> { |
||||
updateRevealButton(RevealButtonState.NO_BUTTON) |
||||
binding.recyclerView.hide() |
||||
binding.statusView.hide() |
||||
binding.progressBar.show() |
||||
} |
||||
is ThreadUiState.Error -> { |
||||
Log.w(TAG, "failed to load status", uiState.throwable) |
||||
|
||||
updateRevealButton(RevealButtonState.NO_BUTTON) |
||||
binding.swipeRefreshLayout.isRefreshing = false |
||||
|
||||
binding.recyclerView.hide() |
||||
binding.statusView.show() |
||||
binding.progressBar.hide() |
||||
|
||||
if (uiState.throwable is IOException) { |
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { |
||||
viewModel.retry(thisThreadsStatusId) |
||||
} |
||||
} else { |
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { |
||||
viewModel.retry(thisThreadsStatusId) |
||||
} |
||||
} |
||||
} |
||||
is ThreadUiState.Success -> { |
||||
adapter.submitList(uiState.statuses) { |
||||
if (viewModel.isInitialLoad) { |
||||
viewModel.isInitialLoad = false |
||||
val detailedPosition = adapter.currentList.indexOfFirst { viewData -> |
||||
viewData.isDetailed |
||||
} |
||||
binding.recyclerView.scrollToPosition(detailedPosition) |
||||
} |
||||
} |
||||
|
||||
updateRevealButton(uiState.revealButton) |
||||
binding.swipeRefreshLayout.isRefreshing = uiState.refreshing |
||||
|
||||
binding.recyclerView.show() |
||||
binding.statusView.hide() |
||||
binding.progressBar.hide() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
lifecycleScope.launch { |
||||
viewModel.errors.collect { throwable -> |
||||
Log.w(TAG, "failed to load status context", throwable) |
||||
Snackbar.make(binding.root, R.string.error_generic, Snackbar.LENGTH_SHORT) |
||||
.setAction(R.string.action_retry) { |
||||
viewModel.retry(thisThreadsStatusId) |
||||
} |
||||
.show() |
||||
} |
||||
} |
||||
|
||||
viewModel.loadThread(thisThreadsStatusId) |
||||
} |
||||
|
||||
private fun updateRevealButton(state: RevealButtonState) { |
||||
val menuItem = binding.toolbar.menu.findItem(R.id.action_reveal) |
||||
|
||||
menuItem.isVisible = state != RevealButtonState.NO_BUTTON |
||||
menuItem.setIcon(if (state == RevealButtonState.REVEAL) R.drawable.ic_eye_24dp else R.drawable.ic_hide_media_24dp) |
||||
} |
||||
|
||||
override fun onRefresh() { |
||||
viewModel.refresh(thisThreadsStatusId) |
||||
} |
||||
|
||||
override fun onReply(position: Int) { |
||||
super.reply(adapter.currentList[position].status) |
||||
} |
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) { |
||||
val status = adapter.currentList[position] |
||||
viewModel.reblog(reblog, status) |
||||
} |
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) { |
||||
val status = adapter.currentList[position] |
||||
viewModel.favorite(favourite, status) |
||||
} |
||||
|
||||
override fun onBookmark(bookmark: Boolean, position: Int) { |
||||
val status = adapter.currentList[position] |
||||
viewModel.bookmark(bookmark, status) |
||||
} |
||||
|
||||
override fun onMore(view: View, position: Int) { |
||||
super.more(adapter.currentList[position].status, view, position) |
||||
} |
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { |
||||
val status = adapter.currentList[position].status |
||||
super.viewMedia(attachmentIndex, list(status), view) |
||||
} |
||||
|
||||
override fun onViewThread(position: Int) { |
||||
val status = adapter.currentList[position] |
||||
if (thisThreadsStatusId == status.id) { |
||||
// If already viewing this thread, don't reopen it. |
||||
return |
||||
} |
||||
super.viewThread(status.actionableId, status.actionable.url) |
||||
} |
||||
|
||||
override fun onViewUrl(url: String) { |
||||
val status: StatusViewData.Concrete? = viewModel.detailedStatus() |
||||
if (status != null && status.status.url == url) { |
||||
// already viewing the status with this url |
||||
// probably just a preview federated and the user is clicking again to view more -> open the browser |
||||
// this can happen with some friendica statuses |
||||
requireContext().openLink(url) |
||||
return |
||||
} |
||||
super.onViewUrl(url) |
||||
} |
||||
|
||||
override fun onOpenReblog(position: Int) { |
||||
// there are no reblogs in threads |
||||
} |
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) { |
||||
viewModel.changeExpanded(expanded, adapter.currentList[position]) |
||||
} |
||||
|
||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { |
||||
viewModel.changeContentShowing(isShowing, adapter.currentList[position]) |
||||
} |
||||
|
||||
override fun onLoadMore(position: Int) { |
||||
// only used in timelines |
||||
} |
||||
|
||||
override fun onShowReblogs(position: Int) { |
||||
val statusId = adapter.currentList[position].id |
||||
val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) |
||||
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) |
||||
} |
||||
|
||||
override fun onShowFavs(position: Int) { |
||||
val statusId = adapter.currentList[position].id |
||||
val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) |
||||
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) |
||||
} |
||||
|
||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { |
||||
viewModel.changeContentCollapsed(isCollapsed, adapter.currentList[position]) |
||||
} |
||||
|
||||
override fun onViewTag(tag: String) { |
||||
super.viewTag(tag) |
||||
} |
||||
|
||||
override fun onViewAccount(id: String) { |
||||
super.viewAccount(id) |
||||
} |
||||
|
||||
public override fun removeItem(position: Int) { |
||||
val status = adapter.currentList[position] |
||||
if (status.isDetailed) { |
||||
// the main status we are viewing is being removed, finish the activity |
||||
activity?.finish() |
||||
return |
||||
} |
||||
viewModel.removeStatus(status) |
||||
} |
||||
|
||||
override fun onVoteInPoll(position: Int, choices: List<Int>) { |
||||
val status = adapter.currentList[position] |
||||
viewModel.voteInPoll(choices, status) |
||||
} |
||||
|
||||
companion object { |
||||
private const val TAG = "ViewThreadFragment" |
||||
|
||||
private const val ID_EXTRA = "id" |
||||
private const val URL_EXTRA = "url" |
||||
|
||||
fun newInstance(id: String, url: String): ViewThreadFragment { |
||||
val arguments = Bundle(2) |
||||
val fragment = ViewThreadFragment() |
||||
arguments.putString(ID_EXTRA, id) |
||||
arguments.putString(URL_EXTRA, url) |
||||
fragment.arguments = arguments |
||||
return fragment |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,426 @@
|
||||
/* Copyright 2022 Tusky Contributors |
||||
* |
||||
* 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.viewthread |
||||
|
||||
import android.util.Log |
||||
import androidx.lifecycle.ViewModel |
||||
import androidx.lifecycle.viewModelScope |
||||
import at.connyduck.calladapter.networkresult.fold |
||||
import at.connyduck.calladapter.networkresult.getOrElse |
||||
import com.keylesspalace.tusky.appstore.BlockEvent |
||||
import com.keylesspalace.tusky.appstore.BookmarkEvent |
||||
import com.keylesspalace.tusky.appstore.EventHub |
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent |
||||
import com.keylesspalace.tusky.appstore.PinEvent |
||||
import com.keylesspalace.tusky.appstore.ReblogEvent |
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent |
||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent |
||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected |
||||
import com.keylesspalace.tusky.db.AccountManager |
||||
import com.keylesspalace.tusky.entity.Filter |
||||
import com.keylesspalace.tusky.entity.Status |
||||
import com.keylesspalace.tusky.network.FilterModel |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.usecase.TimelineCases |
||||
import com.keylesspalace.tusky.util.toViewData |
||||
import com.keylesspalace.tusky.viewdata.StatusViewData |
||||
import kotlinx.coroutines.Job |
||||
import kotlinx.coroutines.async |
||||
import kotlinx.coroutines.channels.BufferOverflow |
||||
import kotlinx.coroutines.flow.Flow |
||||
import kotlinx.coroutines.flow.MutableSharedFlow |
||||
import kotlinx.coroutines.flow.MutableStateFlow |
||||
import kotlinx.coroutines.flow.update |
||||
import kotlinx.coroutines.launch |
||||
import kotlinx.coroutines.rx3.asFlow |
||||
import kotlinx.coroutines.rx3.await |
||||
import javax.inject.Inject |
||||
|
||||
class ViewThreadViewModel @Inject constructor( |
||||
private val api: MastodonApi, |
||||
private val filterModel: FilterModel, |
||||
private val timelineCases: TimelineCases, |
||||
eventHub: EventHub, |
||||
accountManager: AccountManager |
||||
) : ViewModel() { |
||||
|
||||
private val _uiState: MutableStateFlow<ThreadUiState> = MutableStateFlow(ThreadUiState.Loading) |
||||
val uiState: Flow<ThreadUiState> |
||||
get() = _uiState |
||||
|
||||
private val _errors = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) |
||||
val errors: Flow<Throwable> |
||||
get() = _errors |
||||
|
||||
var isInitialLoad: Boolean = true |
||||
|
||||
private val alwaysShowSensitiveMedia: Boolean |
||||
private val alwaysOpenSpoiler: Boolean |
||||
|
||||
init { |
||||
val activeAccount = accountManager.activeAccount |
||||
alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false |
||||
alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false |
||||
|
||||
viewModelScope.launch { |
||||
eventHub.events |
||||
.asFlow() |
||||
.collect { event -> |
||||
when (event) { |
||||
is FavoriteEvent -> handleFavEvent(event) |
||||
is ReblogEvent -> handleReblogEvent(event) |
||||
is BookmarkEvent -> handleBookmarkEvent(event) |
||||
is PinEvent -> handlePinEvent(event) |
||||
is BlockEvent -> removeAllByAccountId(event.accountId) |
||||
is StatusComposedEvent -> handleStatusComposedEvent(event) |
||||
is StatusDeletedEvent -> handleStatusDeletedEvent(event) |
||||
} |
||||
} |
||||
} |
||||
|
||||
loadFilters() |
||||
} |
||||
|
||||
fun loadThread(id: String) { |
||||
viewModelScope.launch { |
||||
val contextCall = async { api.statusContext(id) } |
||||
val statusCall = async { api.statusAsync(id) } |
||||
|
||||
val contextResult = contextCall.await() |
||||
val statusResult = statusCall.await() |
||||
|
||||
val status = statusResult.getOrElse { exception -> |
||||
_uiState.value = ThreadUiState.Error(exception) |
||||
return@launch |
||||
} |
||||
|
||||
contextResult.fold({ statusContext -> |
||||
|
||||
val ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter() |
||||
val detailedStatus = status.toViewData(true) |
||||
val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter() |
||||
val statuses = ancestors + detailedStatus + descendants |
||||
|
||||
_uiState.value = ThreadUiState.Success( |
||||
statuses = statuses, |
||||
revealButton = statuses.getRevealButtonState(), |
||||
refreshing = false |
||||
) |
||||
}, { throwable -> |
||||
_errors.emit(throwable) |
||||
_uiState.value = ThreadUiState.Success( |
||||
statuses = listOf(status.toViewData(true)), |
||||
revealButton = RevealButtonState.NO_BUTTON, |
||||
refreshing = false |
||||
) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
fun retry(id: String) { |
||||
_uiState.value = ThreadUiState.Loading |
||||
loadThread(id) |
||||
} |
||||
|
||||
fun refresh(id: String) { |
||||
updateSuccess { uiState -> |
||||
uiState.copy(refreshing = true) |
||||
} |
||||
loadThread(id) |
||||
} |
||||
|
||||
fun detailedStatus(): StatusViewData.Concrete? { |
||||
return (_uiState.value as ThreadUiState.Success?)?.statuses?.find { status -> |
||||
status.isDetailed |
||||
} |
||||
} |
||||
|
||||
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { |
||||
try { |
||||
timelineCases.reblog(status.actionableId, reblog).await() |
||||
} catch (t: Exception) { |
||||
ifExpected(t) { |
||||
Log.d(TAG, "Failed to reblog status " + status.actionableId, t) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { |
||||
try { |
||||
timelineCases.favourite(status.actionableId, favorite).await() |
||||
} catch (t: Exception) { |
||||
ifExpected(t) { |
||||
Log.d(TAG, "Failed to favourite status " + status.actionableId, t) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { |
||||
try { |
||||
timelineCases.bookmark(status.actionableId, bookmark).await() |
||||
} catch (t: Exception) { |
||||
ifExpected(t) { |
||||
Log.d(TAG, "Failed to favourite status " + status.actionableId, t) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun voteInPoll(choices: List<Int>, status: StatusViewData.Concrete): Job = viewModelScope.launch { |
||||
val poll = status.status.actionableStatus.poll ?: run { |
||||
Log.w(TAG, "No poll on status ${status.id}") |
||||
return@launch |
||||
} |
||||
|
||||
val votedPoll = poll.votedCopy(choices) |
||||
updateStatus(status.id) { status -> |
||||
status.copy(poll = votedPoll) |
||||
} |
||||
|
||||
try { |
||||
timelineCases.voteInPoll(status.actionableId, poll.id, choices).await() |
||||
} catch (t: Exception) { |
||||
ifExpected(t) { |
||||
Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun removeStatus(statusToRemove: StatusViewData.Concrete) { |
||||
updateSuccess { uiState -> |
||||
uiState.copy( |
||||
statuses = uiState.statuses.filterNot { status -> status == statusToRemove } |
||||
) |
||||
} |
||||
} |
||||
|
||||
fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { |
||||
updateSuccess { uiState -> |
||||
val statuses = uiState.statuses.map { viewData -> |
||||
if (viewData.id == status.id) { |
||||
viewData.copy(isExpanded = expanded) |
||||
} else { |
||||
viewData |
||||
} |
||||
} |
||||
uiState.copy( |
||||
statuses = statuses, |
||||
revealButton = statuses.getRevealButtonState() |
||||
) |
||||
} |
||||
} |
||||
|
||||
fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { |
||||
updateStatusViewData(status.id) { viewData -> |
||||
viewData.copy(isShowingContent = isShowing) |
||||
} |
||||
} |
||||
|
||||
fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { |
||||
updateStatusViewData(status.id) { viewData -> |
||||
viewData.copy(isCollapsed = isCollapsed) |
||||
} |
||||
} |
||||
|
||||
private fun handleFavEvent(event: FavoriteEvent) { |
||||
updateStatus(event.statusId) { status -> |
||||
status.copy(favourited = event.favourite) |
||||
} |
||||
} |
||||
|
||||
private fun handleReblogEvent(event: ReblogEvent) { |
||||
updateStatus(event.statusId) { status -> |
||||
status.copy(reblogged = event.reblog) |
||||
} |
||||
} |
||||
|
||||
private fun handleBookmarkEvent(event: BookmarkEvent) { |
||||
updateStatus(event.statusId) { status -> |
||||
status.copy(bookmarked = event.bookmark) |
||||
} |
||||
} |
||||
|
||||
private fun handlePinEvent(event: PinEvent) { |
||||
updateStatus(event.statusId) { status -> |
||||
status.copy(pinned = event.pinned) |
||||
} |
||||
} |
||||
|
||||
private fun removeAllByAccountId(accountId: String) { |
||||
updateSuccess { uiState -> |
||||
uiState.copy( |
||||
statuses = uiState.statuses.filter { viewData -> |
||||
viewData.status.account.id == accountId |
||||
} |
||||
) |
||||
} |
||||
} |
||||
|
||||
private fun handleStatusComposedEvent(event: StatusComposedEvent) { |
||||
val eventStatus = event.status |
||||
updateSuccess { uiState -> |
||||
val statuses = uiState.statuses |
||||
val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed } |
||||
val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id } |
||||
if (detailedIndex != -1 && repliedIndex >= detailedIndex) { |
||||
// there is a new reply to the detailed status or below -> display it |
||||
val newStatuses = statuses.subList(0, repliedIndex + 1) + |
||||
eventStatus.toViewData() + |
||||
statuses.subList(repliedIndex + 1, statuses.size) |
||||
uiState.copy(statuses = newStatuses) |
||||
} else { |
||||
uiState |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun handleStatusDeletedEvent(event: StatusDeletedEvent) { |
||||
updateSuccess { uiState -> |
||||
uiState.copy( |
||||
statuses = uiState.statuses.filter { status -> |
||||
status.id != event.statusId |
||||
} |
||||
) |
||||
} |
||||
} |
||||
|
||||
fun toggleRevealButton() { |
||||
updateSuccess { uiState -> |
||||
when (uiState.revealButton) { |
||||
RevealButtonState.HIDE -> uiState.copy( |
||||
statuses = uiState.statuses.map { viewData -> |
||||
viewData.copy(isExpanded = false) |
||||
}, |
||||
revealButton = RevealButtonState.REVEAL |
||||
) |
||||
RevealButtonState.REVEAL -> uiState.copy( |
||||
statuses = uiState.statuses.map { viewData -> |
||||
viewData.copy(isExpanded = true) |
||||
}, |
||||
revealButton = RevealButtonState.HIDE |
||||
) |
||||
else -> uiState |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun List<StatusViewData.Concrete>.getRevealButtonState(): RevealButtonState { |
||||
val hasWarnings = any { viewData -> |
||||
viewData.status.spoilerText.isNotEmpty() |
||||
} |
||||
|
||||
return if (hasWarnings) { |
||||
val allExpanded = none { viewData -> |
||||
!viewData.isExpanded |
||||
} |
||||
if (allExpanded) { |
||||
RevealButtonState.HIDE |
||||
} else { |
||||
RevealButtonState.REVEAL |
||||
} |
||||
} else { |
||||
RevealButtonState.NO_BUTTON |
||||
} |
||||
} |
||||
|
||||
private fun loadFilters() { |
||||
viewModelScope.launch { |
||||
val filters = try { |
||||
api.getFilters().await() |
||||
} catch (t: Exception) { |
||||
Log.w(TAG, "Failed to fetch filters", t) |
||||
return@launch |
||||
} |
||||
filterModel.initWithFilters( |
||||
filters.filter { filter -> |
||||
filter.context.contains(Filter.THREAD) |
||||
} |
||||
) |
||||
|
||||
updateSuccess { uiState -> |
||||
val statuses = uiState.statuses.filter() |
||||
uiState.copy( |
||||
statuses = statuses, |
||||
revealButton = statuses.getRevealButtonState() |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun List<StatusViewData.Concrete>.filter(): List<StatusViewData.Concrete> { |
||||
return filter { status -> |
||||
status.isDetailed || !filterModel.shouldFilterStatus(status.status) |
||||
} |
||||
} |
||||
|
||||
private fun Status.toViewData(detailed: Boolean = false): StatusViewData.Concrete { |
||||
return toViewData( |
||||
isShowingContent = alwaysShowSensitiveMedia || !actionableStatus.sensitive, |
||||
isExpanded = alwaysOpenSpoiler, |
||||
isCollapsed = !detailed, |
||||
isDetailed = detailed |
||||
) |
||||
} |
||||
|
||||
private inline fun updateSuccess(updater: (ThreadUiState.Success) -> ThreadUiState.Success) { |
||||
_uiState.update { uiState -> |
||||
if (uiState is ThreadUiState.Success) { |
||||
updater(uiState) |
||||
} else { |
||||
uiState |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) { |
||||
updateSuccess { uiState -> |
||||
uiState.copy( |
||||
statuses = uiState.statuses.map { viewData -> |
||||
if (viewData.id == statusId) { |
||||
updater(viewData) |
||||
} else { |
||||
viewData |
||||
} |
||||
} |
||||
) |
||||
} |
||||
} |
||||
|
||||
private fun updateStatus(statusId: String, updater: (Status) -> Status) { |
||||
updateStatusViewData(statusId) { viewData -> |
||||
viewData.copy( |
||||
status = updater(viewData.status) |
||||
) |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
private const val TAG = "ViewThreadViewModel" |
||||
} |
||||
} |
||||
|
||||
sealed interface ThreadUiState { |
||||
object Loading : ThreadUiState |
||||
class Error(val throwable: Throwable) : ThreadUiState |
||||
data class Success( |
||||
val statuses: List<StatusViewData.Concrete>, |
||||
val revealButton: RevealButtonState, |
||||
val refreshing: Boolean |
||||
) : ThreadUiState |
||||
} |
||||
|
||||
enum class RevealButtonState { |
||||
NO_BUTTON, REVEAL, HIDE |
||||
} |
||||
@ -1,683 +0,0 @@
|
||||
/* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.fragment; |
||||
|
||||
import android.content.Context; |
||||
import android.content.Intent; |
||||
import android.content.SharedPreferences; |
||||
import android.os.Bundle; |
||||
import android.text.TextUtils; |
||||
import android.util.Log; |
||||
import android.view.LayoutInflater; |
||||
import android.view.View; |
||||
import android.view.ViewGroup; |
||||
|
||||
import androidx.annotation.NonNull; |
||||
import androidx.annotation.Nullable; |
||||
import androidx.arch.core.util.Function; |
||||
import androidx.lifecycle.Lifecycle; |
||||
import androidx.preference.PreferenceManager; |
||||
import androidx.recyclerview.widget.DividerItemDecoration; |
||||
import androidx.recyclerview.widget.LinearLayoutManager; |
||||
import androidx.recyclerview.widget.RecyclerView; |
||||
import androidx.recyclerview.widget.SimpleItemAnimator; |
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; |
||||
|
||||
import com.google.android.material.snackbar.Snackbar; |
||||
import com.keylesspalace.tusky.AccountListActivity; |
||||
import com.keylesspalace.tusky.BaseActivity; |
||||
import com.keylesspalace.tusky.BuildConfig; |
||||
import com.keylesspalace.tusky.R; |
||||
import com.keylesspalace.tusky.ViewThreadActivity; |
||||
import com.keylesspalace.tusky.adapter.ThreadAdapter; |
||||
import com.keylesspalace.tusky.appstore.BlockEvent; |
||||
import com.keylesspalace.tusky.appstore.BookmarkEvent; |
||||
import com.keylesspalace.tusky.appstore.EventHub; |
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent; |
||||
import com.keylesspalace.tusky.appstore.PinEvent; |
||||
import com.keylesspalace.tusky.appstore.ReblogEvent; |
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent; |
||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent; |
||||
import com.keylesspalace.tusky.di.Injectable; |
||||
import com.keylesspalace.tusky.entity.Filter; |
||||
import com.keylesspalace.tusky.entity.Poll; |
||||
import com.keylesspalace.tusky.entity.Status; |
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener; |
||||
import com.keylesspalace.tusky.network.FilterModel; |
||||
import com.keylesspalace.tusky.network.MastodonApi; |
||||
import com.keylesspalace.tusky.settings.PrefKeys; |
||||
import com.keylesspalace.tusky.util.CardViewMode; |
||||
import com.keylesspalace.tusky.util.LinkHelper; |
||||
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; |
||||
import com.keylesspalace.tusky.util.PairedList; |
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions; |
||||
import com.keylesspalace.tusky.util.ViewDataUtils; |
||||
import com.keylesspalace.tusky.view.ConversationLineItemDecoration; |
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData; |
||||
import com.keylesspalace.tusky.viewdata.StatusViewData; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.Iterator; |
||||
import java.util.List; |
||||
import java.util.Locale; |
||||
|
||||
import javax.inject.Inject; |
||||
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider; |
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; |
||||
import kotlin.collections.CollectionsKt; |
||||
|
||||
import static autodispose2.AutoDispose.autoDisposable; |
||||
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; |
||||
|
||||
public final class ViewThreadFragment extends SFragment implements |
||||
SwipeRefreshLayout.OnRefreshListener, StatusActionListener, Injectable { |
||||
private static final String TAG = "ViewThreadFragment"; |
||||
|
||||
@Inject |
||||
public MastodonApi mastodonApi; |
||||
@Inject |
||||
public EventHub eventHub; |
||||
@Inject |
||||
public FilterModel filterModel; |
||||
|
||||
private SwipeRefreshLayout swipeRefreshLayout; |
||||
private RecyclerView recyclerView; |
||||
private ThreadAdapter adapter; |
||||
private String thisThreadsStatusId; |
||||
private boolean alwaysShowSensitiveMedia; |
||||
private boolean alwaysOpenSpoiler; |
||||
|
||||
private int statusIndex = 0; |
||||
|
||||
private final PairedList<Status, StatusViewData.Concrete> statuses = |
||||
new PairedList<>(new Function<Status, StatusViewData.Concrete>() { |
||||
@Override |
||||
public StatusViewData.Concrete apply(Status status) { |
||||
return ViewDataUtils.statusToViewData( |
||||
status, |
||||
alwaysShowSensitiveMedia || !status.getActionableStatus().getSensitive(), |
||||
alwaysOpenSpoiler, |
||||
true |
||||
); |
||||
} |
||||
}); |
||||
|
||||
public static ViewThreadFragment newInstance(String id) { |
||||
Bundle arguments = new Bundle(1); |
||||
ViewThreadFragment fragment = new ViewThreadFragment(); |
||||
arguments.putString("id", id); |
||||
fragment.setArguments(arguments); |
||||
return fragment; |
||||
} |
||||
|
||||
@Override |
||||
public void onCreate(@Nullable Bundle savedInstanceState) { |
||||
super.onCreate(savedInstanceState); |
||||
|
||||
thisThreadsStatusId = getArguments().getString("id"); |
||||
SharedPreferences preferences = |
||||
PreferenceManager.getDefaultSharedPreferences(getActivity()); |
||||
|
||||
StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( |
||||
preferences.getBoolean("animateGifAvatars", false), |
||||
accountManager.getActiveAccount().getMediaPreviewEnabled(), |
||||
preferences.getBoolean("absoluteTimeView", false), |
||||
preferences.getBoolean("showBotOverlay", true), |
||||
preferences.getBoolean("useBlurhash", true), |
||||
preferences.getBoolean("showCardsInTimelines", false) ? |
||||
CardViewMode.INDENTED : |
||||
CardViewMode.NONE, |
||||
preferences.getBoolean("confirmReblogs", true), |
||||
preferences.getBoolean("confirmFavourites", false), |
||||
preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), |
||||
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) |
||||
); |
||||
adapter = new ThreadAdapter(statusDisplayOptions, this); |
||||
} |
||||
|
||||
@Override |
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, |
||||
@Nullable Bundle savedInstanceState) { |
||||
View rootView = inflater.inflate(R.layout.fragment_view_thread, container, false); |
||||
|
||||
Context context = getContext(); |
||||
swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout); |
||||
swipeRefreshLayout.setOnRefreshListener(this); |
||||
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); |
||||
|
||||
recyclerView = rootView.findViewById(R.id.recyclerView); |
||||
recyclerView.setHasFixedSize(true); |
||||
LinearLayoutManager layoutManager = new LinearLayoutManager(context); |
||||
recyclerView.setLayoutManager(layoutManager); |
||||
recyclerView.setAccessibilityDelegateCompat( |
||||
new ListStatusAccessibilityDelegate(recyclerView, this, statuses::getPairedItemOrNull)); |
||||
DividerItemDecoration divider = new DividerItemDecoration( |
||||
context, layoutManager.getOrientation()); |
||||
recyclerView.addItemDecoration(divider); |
||||
|
||||
recyclerView.addItemDecoration(new ConversationLineItemDecoration(context)); |
||||
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); |
||||
alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); |
||||
reloadFilters(); |
||||
|
||||
recyclerView.setAdapter(adapter); |
||||
|
||||
statuses.clear(); |
||||
|
||||
((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); |
||||
|
||||
return rootView; |
||||
} |
||||
|
||||
|
||||
@Override |
||||
public void onActivityCreated(@Nullable Bundle savedInstanceState) { |
||||
super.onActivityCreated(savedInstanceState); |
||||
onRefresh(); |
||||
|
||||
eventHub.getEvents() |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) |
||||
.subscribe(event -> { |
||||
if (event instanceof FavoriteEvent) { |
||||
handleFavEvent((FavoriteEvent) event); |
||||
} else if (event instanceof ReblogEvent) { |
||||
handleReblogEvent((ReblogEvent) event); |
||||
} else if (event instanceof BookmarkEvent) { |
||||
handleBookmarkEvent((BookmarkEvent) event); |
||||
} else if (event instanceof PinEvent) { |
||||
handlePinEvent(((PinEvent) event)); |
||||
} else if (event instanceof BlockEvent) { |
||||
removeAllByAccountId(((BlockEvent) event).getAccountId()); |
||||
} else if (event instanceof StatusComposedEvent) { |
||||
handleStatusComposedEvent((StatusComposedEvent) event); |
||||
} else if (event instanceof StatusDeletedEvent) { |
||||
handleStatusDeletedEvent((StatusDeletedEvent) event); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
public void onRevealPressed() { |
||||
boolean allExpanded = allExpanded(); |
||||
for (int i = 0; i < statuses.size(); i++) { |
||||
updateViewData(i, statuses.getPairedItem(i).copyWithExpanded(!allExpanded)); |
||||
} |
||||
updateRevealIcon(); |
||||
} |
||||
|
||||
private boolean allExpanded() { |
||||
boolean allExpanded = true; |
||||
for (int i = 0; i < statuses.size(); i++) { |
||||
if (!statuses.getPairedItem(i).isExpanded()) { |
||||
allExpanded = false; |
||||
break; |
||||
} |
||||
} |
||||
return allExpanded; |
||||
} |
||||
|
||||
@Override |
||||
public void onRefresh() { |
||||
sendStatusRequest(thisThreadsStatusId); |
||||
sendThreadRequest(thisThreadsStatusId); |
||||
} |
||||
|
||||
@Override |
||||
public void onReply(int position) { |
||||
super.reply(statuses.get(position)); |
||||
} |
||||
|
||||
@Override |
||||
public void onReblog(final boolean reblog, final int position) { |
||||
final Status status = statuses.get(position); |
||||
|
||||
timelineCases.reblog(statuses.get(position).getId(), reblog) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.to(autoDisposable(from(this))) |
||||
.subscribe( |
||||
this::replaceStatus, |
||||
(t) -> Log.d(TAG, |
||||
"Failed to reblog status: " + status.getId(), t) |
||||
); |
||||
} |
||||
|
||||
@Override |
||||
public void onFavourite(final boolean favourite, final int position) { |
||||
final Status status = statuses.get(position); |
||||
|
||||
timelineCases.favourite(statuses.get(position).getId(), favourite) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.to(autoDisposable(from(this))) |
||||
.subscribe( |
||||
this::replaceStatus, |
||||
(t) -> Log.d(TAG, |
||||
"Failed to favourite status: " + status.getId(), t) |
||||
); |
||||
} |
||||
|
||||
@Override |
||||
public void onBookmark(final boolean bookmark, final int position) { |
||||
final Status status = statuses.get(position); |
||||
|
||||
timelineCases.bookmark(statuses.get(position).getId(), bookmark) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.to(autoDisposable(from(this))) |
||||
.subscribe( |
||||
this::replaceStatus, |
||||
(t) -> Log.d(TAG, |
||||
"Failed to bookmark status: " + status.getId(), t) |
||||
); |
||||
} |
||||
|
||||
private void replaceStatus(Status status) { |
||||
updateStatus(status.getId(), (__) -> status); |
||||
} |
||||
|
||||
private void updateStatus(String statusId, Function<Status, Status> mapper) { |
||||
int position = indexOfStatus(statusId); |
||||
|
||||
if (position >= 0 && position < statuses.size()) { |
||||
Status oldStatus = statuses.get(position); |
||||
Status newStatus = mapper.apply(oldStatus); |
||||
StatusViewData.Concrete oldViewData = statuses.getPairedItem(position); |
||||
statuses.set(position, newStatus); |
||||
updateViewData(position, oldViewData.copyWithStatus(newStatus)); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onMore(@NonNull View view, int position) { |
||||
super.more(statuses.get(position), view, position); |
||||
} |
||||
|
||||
@Override |
||||
public void onViewMedia(int position, int attachmentIndex, @NonNull View view) { |
||||
Status status = statuses.get(position); |
||||
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view); |
||||
} |
||||
|
||||
@Override |
||||
public void onViewThread(int position) { |
||||
Status status = statuses.get(position); |
||||
if (thisThreadsStatusId.equals(status.getId())) { |
||||
// If already viewing this thread, don't reopen it.
|
||||
return; |
||||
} |
||||
super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); |
||||
} |
||||
|
||||
@Override |
||||
public void onViewUrl(String url) { |
||||
Status status = null; |
||||
if (!statuses.isEmpty()) { |
||||
status = statuses.get(statusIndex); |
||||
} |
||||
if (status != null && status.getUrl().equals(url)) { |
||||
// already viewing the status with this url
|
||||
// probably just a preview federated and the user is clicking again to view more -> open the browser
|
||||
// this can happen with some friendica statuses
|
||||
LinkHelper.openLink(requireContext(), url); |
||||
return; |
||||
} |
||||
super.onViewUrl(url); |
||||
} |
||||
|
||||
@Override |
||||
public void onOpenReblog(int position) { |
||||
// there should be no reblogs in the thread but let's implement it to be sure
|
||||
super.openReblog(statuses.get(position)); |
||||
} |
||||
|
||||
@Override |
||||
public void onExpandedChange(boolean expanded, int position) { |
||||
updateViewData( |
||||
position, |
||||
statuses.getPairedItem(position).copyWithExpanded(expanded) |
||||
); |
||||
updateRevealIcon(); |
||||
} |
||||
|
||||
@Override |
||||
public void onContentHiddenChange(boolean isShowing, int position) { |
||||
updateViewData( |
||||
position, |
||||
statuses.getPairedItem(position).copyWithShowingContent(isShowing) |
||||
); |
||||
} |
||||
|
||||
private void updateViewData(int position, StatusViewData.Concrete newViewData) { |
||||
statuses.setPairedItem(position, newViewData); |
||||
adapter.setItem(position, newViewData, true); |
||||
} |
||||
|
||||
@Override |
||||
public void onLoadMore(int position) { |
||||
|
||||
} |
||||
|
||||
@Override |
||||
public void onShowReblogs(int position) { |
||||
String statusId = statuses.get(position).getId(); |
||||
Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.REBLOGGED, statusId); |
||||
((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); |
||||
} |
||||
|
||||
@Override |
||||
public void onShowFavs(int position) { |
||||
String statusId = statuses.get(position).getId(); |
||||
Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.FAVOURITED, statusId); |
||||
((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); |
||||
} |
||||
|
||||
@Override |
||||
public void onContentCollapsedChange(boolean isCollapsed, int position) { |
||||
adapter.setItem( |
||||
position, |
||||
statuses.getPairedItem(position).copyWithCollapsed(isCollapsed), |
||||
true |
||||
); |
||||
} |
||||
|
||||
@Override |
||||
public void onViewTag(String tag) { |
||||
super.viewTag(tag); |
||||
} |
||||
|
||||
@Override |
||||
public void onViewAccount(String id) { |
||||
super.viewAccount(id); |
||||
} |
||||
|
||||
@Override |
||||
public void removeItem(int position) { |
||||
if (position == statusIndex) { |
||||
//the status got removed, close the activity
|
||||
getActivity().finish(); |
||||
} |
||||
statuses.remove(position); |
||||
adapter.setStatuses(statuses.getPairedCopy()); |
||||
} |
||||
|
||||
public void onVoteInPoll(int position, @NonNull List<Integer> choices) { |
||||
final Status status = statuses.get(position).getActionableStatus(); |
||||
|
||||
setVoteForPoll(status.getId(), status.getPoll().votedCopy(choices)); |
||||
|
||||
timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.to(autoDisposable(from(this))) |
||||
.subscribe( |
||||
(newPoll) -> setVoteForPoll(status.getId(), newPoll), |
||||
(t) -> Log.d(TAG, |
||||
"Failed to vote in poll: " + status.getId(), t) |
||||
); |
||||
|
||||
} |
||||
|
||||
private void setVoteForPoll(String statusId, Poll newPoll) { |
||||
updateStatus(statusId, s -> s.copyWithPoll(newPoll)); |
||||
} |
||||
|
||||
private void removeAllByAccountId(String accountId) { |
||||
Status status = null; |
||||
if (!statuses.isEmpty()) { |
||||
status = statuses.get(statusIndex); |
||||
} |
||||
// using iterator to safely remove items while iterating
|
||||
Iterator<Status> iterator = statuses.iterator(); |
||||
while (iterator.hasNext()) { |
||||
Status s = iterator.next(); |
||||
if (s.getAccount().getId().equals(accountId) || s.getActionableStatus().getAccount().getId().equals(accountId)) { |
||||
iterator.remove(); |
||||
} |
||||
} |
||||
statusIndex = statuses.indexOf(status); |
||||
if (statusIndex == -1) { |
||||
//the status got removed, close the activity
|
||||
getActivity().finish(); |
||||
return; |
||||
} |
||||
adapter.setDetailedStatusPosition(statusIndex); |
||||
adapter.setStatuses(statuses.getPairedCopy()); |
||||
} |
||||
|
||||
private void sendStatusRequest(final String id) { |
||||
mastodonApi.status(id) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) |
||||
.subscribe( |
||||
status -> { |
||||
int position = setStatus(status); |
||||
recyclerView.scrollToPosition(position); |
||||
}, |
||||
throwable -> onThreadRequestFailure(id, throwable) |
||||
); |
||||
} |
||||
|
||||
private void sendThreadRequest(final String id) { |
||||
mastodonApi.statusContext(id) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) |
||||
.subscribe( |
||||
context -> { |
||||
swipeRefreshLayout.setRefreshing(false); |
||||
setContext(context.getAncestors(), context.getDescendants()); |
||||
}, |
||||
throwable -> onThreadRequestFailure(id, throwable) |
||||
); |
||||
} |
||||
|
||||
private void onThreadRequestFailure(final String id, final Throwable throwable) { |
||||
View view = getView(); |
||||
swipeRefreshLayout.setRefreshing(false); |
||||
if (view != null) { |
||||
Snackbar.make(view, R.string.error_generic, Snackbar.LENGTH_LONG) |
||||
.setAction(R.string.action_retry, v -> { |
||||
sendThreadRequest(id); |
||||
sendStatusRequest(id); |
||||
}) |
||||
.show(); |
||||
} else { |
||||
Log.e(TAG, "Network request failed", throwable); |
||||
} |
||||
} |
||||
|
||||
private int setStatus(Status status) { |
||||
if (statuses.size() > 0 |
||||
&& statusIndex < statuses.size() |
||||
&& statuses.get(statusIndex).getId().equals(status.getId())) { |
||||
// Do not add this status on refresh, it's already in there.
|
||||
statuses.set(statusIndex, status); |
||||
return statusIndex; |
||||
} |
||||
int i = statusIndex; |
||||
statuses.add(i, status); |
||||
adapter.setDetailedStatusPosition(i); |
||||
adapter.addItem(i, statuses.getPairedItem(i)); |
||||
updateRevealIcon(); |
||||
return i; |
||||
} |
||||
|
||||
private void setContext(List<Status> unfilteredAncestors, List<Status> unfilteredDescendants) { |
||||
Status mainStatus = null; |
||||
|
||||
// In case of refresh, remove old ancestors and descendants first. We'll remove all blindly,
|
||||
// as we have no guarantee on their order to be the same as before
|
||||
int oldSize = statuses.size(); |
||||
if (oldSize > 1) { |
||||
mainStatus = statuses.get(statusIndex); |
||||
statuses.clear(); |
||||
adapter.clearItems(); |
||||
} |
||||
|
||||
ArrayList<Status> ancestors = new ArrayList<>(); |
||||
for (Status status : unfilteredAncestors) |
||||
if (!filterModel.shouldFilterStatus(status)) |
||||
ancestors.add(status); |
||||
|
||||
// Insert newly fetched ancestors
|
||||
statusIndex = ancestors.size(); |
||||
adapter.setDetailedStatusPosition(statusIndex); |
||||
statuses.addAll(0, ancestors); |
||||
List<StatusViewData.Concrete> ancestorsViewDatas = statuses.getPairedCopy().subList(0, statusIndex); |
||||
if (BuildConfig.DEBUG && ancestors.size() != ancestorsViewDatas.size()) { |
||||
String error = String.format(Locale.getDefault(), |
||||
"Incorrectly got statusViewData sublist." + |
||||
" ancestors.size == %d ancestorsViewDatas.size == %d," + |
||||
" statuses.size == %d", |
||||
ancestors.size(), ancestorsViewDatas.size(), statuses.size()); |
||||
throw new AssertionError(error); |
||||
} |
||||
adapter.addAll(0, ancestorsViewDatas); |
||||
|
||||
if (mainStatus != null) { |
||||
// In case we needed to delete everything (which is way easier than deleting
|
||||
// everything except one), re-insert the remaining status here.
|
||||
// Not filtering the main status, since the user explicitly chose to be here
|
||||
statuses.add(statusIndex, mainStatus); |
||||
StatusViewData.Concrete viewData = statuses.getPairedItem(statusIndex); |
||||
|
||||
adapter.addItem(statusIndex, viewData); |
||||
} |
||||
|
||||
ArrayList<Status> descendants = new ArrayList<>(); |
||||
for (Status status : unfilteredDescendants) |
||||
if (!filterModel.shouldFilterStatus(status)) |
||||
descendants.add(status); |
||||
|
||||
// Insert newly fetched descendants
|
||||
statuses.addAll(descendants); |
||||
List<StatusViewData.Concrete> descendantsViewData; |
||||
descendantsViewData = statuses.getPairedCopy() |
||||
.subList(statuses.size() - descendants.size(), statuses.size()); |
||||
if (BuildConfig.DEBUG && descendants.size() != descendantsViewData.size()) { |
||||
String error = String.format(Locale.getDefault(), |
||||
"Incorrectly got statusViewData sublist." + |
||||
" descendants.size == %d descendantsViewData.size == %d," + |
||||
" statuses.size == %d", |
||||
descendants.size(), descendantsViewData.size(), statuses.size()); |
||||
throw new AssertionError(error); |
||||
} |
||||
adapter.addAll(descendantsViewData); |
||||
updateRevealIcon(); |
||||
} |
||||
|
||||
private void handleFavEvent(FavoriteEvent event) { |
||||
updateStatus(event.getStatusId(), (s) -> { |
||||
s.setFavourited(event.getFavourite()); |
||||
return s; |
||||
}); |
||||
} |
||||
|
||||
private void handleReblogEvent(ReblogEvent event) { |
||||
updateStatus(event.getStatusId(), (s) -> { |
||||
s.setReblogged(event.getReblog()); |
||||
return s; |
||||
}); |
||||
} |
||||
|
||||
private void handleBookmarkEvent(BookmarkEvent event) { |
||||
updateStatus(event.getStatusId(), (s) -> { |
||||
s.setBookmarked(event.getBookmark()); |
||||
return s; |
||||
}); |
||||
} |
||||
|
||||
private void handlePinEvent(PinEvent event) { |
||||
updateStatus(event.getStatusId(), (s) -> s.copyWithPinned(event.getPinned())); |
||||
} |
||||
|
||||
|
||||
private void handleStatusComposedEvent(StatusComposedEvent event) { |
||||
Status eventStatus = event.getStatus(); |
||||
if (eventStatus.getInReplyToId() == null) return; |
||||
|
||||
if (eventStatus.getInReplyToId().equals(thisThreadsStatusId)) { |
||||
insertStatus(eventStatus, statuses.size()); |
||||
} else { |
||||
// If new status is a reply to some status in the thread, insert new status after it
|
||||
// We only check statuses below main status, ones on top don't belong to this thread
|
||||
for (int i = statusIndex; i < statuses.size(); i++) { |
||||
Status status = statuses.get(i); |
||||
if (eventStatus.getInReplyToId().equals(status.getId())) { |
||||
insertStatus(eventStatus, i + 1); |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private void insertStatus(Status status, int at) { |
||||
statuses.add(at, status); |
||||
adapter.addItem(at, statuses.getPairedItem(at)); |
||||
} |
||||
|
||||
private void handleStatusDeletedEvent(StatusDeletedEvent event) { |
||||
int index = this.indexOfStatus(event.getStatusId()); |
||||
if (index != -1) { |
||||
statuses.remove(index); |
||||
adapter.removeItem(index); |
||||
} |
||||
} |
||||
|
||||
|
||||
private int indexOfStatus(String statusId) { |
||||
return CollectionsKt.indexOfFirst(this.statuses, (s) -> s.getId().equals(statusId)); |
||||
} |
||||
|
||||
private void updateRevealIcon() { |
||||
ViewThreadActivity activity = ((ViewThreadActivity) getActivity()); |
||||
if (activity == null) return; |
||||
|
||||
boolean hasAnyWarnings = false; |
||||
// Statuses are updated from the main thread so nothing should change while iterating
|
||||
for (int i = 0; i < statuses.size(); i++) { |
||||
if (!TextUtils.isEmpty(statuses.get(i).getSpoilerText())) { |
||||
hasAnyWarnings = true; |
||||
break; |
||||
} |
||||
} |
||||
if (!hasAnyWarnings) { |
||||
activity.setRevealButtonState(ViewThreadActivity.REVEAL_BUTTON_HIDDEN); |
||||
return; |
||||
} |
||||
activity.setRevealButtonState(allExpanded() ? ViewThreadActivity.REVEAL_BUTTON_HIDE : |
||||
ViewThreadActivity.REVEAL_BUTTON_REVEAL); |
||||
} |
||||
|
||||
private void reloadFilters() { |
||||
mastodonApi.getFilters() |
||||
.to(autoDisposable(AndroidLifecycleScopeProvider.from(this))) |
||||
.subscribe( |
||||
(filters) -> { |
||||
List<Filter> relevantFilters = CollectionsKt.filter( |
||||
filters, |
||||
(f) -> f.getContext().contains(Filter.THREAD) |
||||
); |
||||
filterModel.initWithFilters(relevantFilters); |
||||
|
||||
recyclerView.post(this::applyFilters); |
||||
}, |
||||
(t) -> Log.e(TAG, "Failed to load filters", t) |
||||
); |
||||
} |
||||
|
||||
private void applyFilters() { |
||||
CollectionsKt.removeAll(this.statuses, filterModel::shouldFilterStatus); |
||||
adapter.setStatuses(this.statuses.getPairedCopy()); |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:width="24dp" |
||||
android:height="24dp" |
||||
android:viewportWidth="24.0" |
||||
android:viewportHeight="24.0" |
||||
android:autoMirrored="true" |
||||
android:tint="?attr/colorControlNormal"> |
||||
<path |
||||
android:pathData="M20,11L7.8,11l5.6,-5.6L12,4l-8,8l8,8l1.4,-1.4L7.8,13L20,13L20,11z" |
||||
android:fillColor="@android:color/white"/> |
||||
</vector> |
||||
@ -1,17 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:tools="http://schemas.android.com/tools" |
||||
android:id="@+id/swipeRefreshLayout" |
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:layout_gravity="top" |
||||
tools:viewBindingIgnore="true"> |
||||
android:layout_height="match_parent"> |
||||
|
||||
<androidx.recyclerview.widget.RecyclerView |
||||
android:id="@+id/recyclerView" |
||||
<com.google.android.material.appbar.AppBarLayout |
||||
android:id="@+id/appbar" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:elevation="@dimen/actionbar_elevation" > |
||||
|
||||
<androidx.appcompat.widget.Toolbar |
||||
android:id="@+id/toolbar" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="?attr/actionBarSize" |
||||
app:title="@string/title_view_thread" |
||||
app:navigationIcon="@drawable/ic_back" |
||||
app:menu="@menu/view_thread_toolbar"/> |
||||
|
||||
</com.google.android.material.appbar.AppBarLayout> |
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout |
||||
android:id="@+id/swipeRefreshLayout" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:background="?android:attr/colorBackground" |
||||
android:scrollbars="vertical" /> |
||||
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" |
||||
android:layout_gravity="top"> |
||||
|
||||
<androidx.recyclerview.widget.RecyclerView |
||||
android:id="@+id/recyclerView" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:background="?android:attr/colorBackground" |
||||
android:scrollbars="vertical" /> |
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> |
||||
|
||||
<ProgressBar |
||||
android:id="@+id/progressBar" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_gravity="center"/> |
||||
|
||||
<com.keylesspalace.tusky.view.BackgroundMessageView |
||||
android:id="@+id/statusView" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:visibility="gone" |
||||
android:layout_gravity="center" /> |
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> |
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout> |
||||
|
||||
@ -0,0 +1,356 @@
|
||||
package com.keylesspalace.tusky.components.viewthread |
||||
|
||||
import android.os.Looper.getMainLooper |
||||
import androidx.test.ext.junit.runners.AndroidJUnit4 |
||||
import at.connyduck.calladapter.networkresult.NetworkResult |
||||
import com.keylesspalace.tusky.appstore.BookmarkEvent |
||||
import com.keylesspalace.tusky.appstore.EventHub |
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent |
||||
import com.keylesspalace.tusky.appstore.ReblogEvent |
||||
import com.keylesspalace.tusky.components.timeline.mockStatus |
||||
import com.keylesspalace.tusky.components.timeline.mockStatusViewData |
||||
import com.keylesspalace.tusky.db.AccountEntity |
||||
import com.keylesspalace.tusky.db.AccountManager |
||||
import com.keylesspalace.tusky.entity.StatusContext |
||||
import com.keylesspalace.tusky.network.FilterModel |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.usecase.TimelineCases |
||||
import kotlinx.coroutines.flow.first |
||||
import kotlinx.coroutines.runBlocking |
||||
import org.junit.Assert.assertEquals |
||||
import org.junit.Before |
||||
import org.junit.Test |
||||
import org.junit.runner.RunWith |
||||
import org.mockito.kotlin.doReturn |
||||
import org.mockito.kotlin.mock |
||||
import org.mockito.kotlin.stub |
||||
import org.robolectric.Shadows.shadowOf |
||||
import org.robolectric.annotation.Config |
||||
import java.io.IOException |
||||
|
||||
@Config(sdk = [28]) |
||||
@RunWith(AndroidJUnit4::class) |
||||
class ViewThreadViewModelTest { |
||||
|
||||
private lateinit var api: MastodonApi |
||||
private lateinit var eventHub: EventHub |
||||
private lateinit var viewModel: ViewThreadViewModel |
||||
|
||||
private val threadId = "1234" |
||||
|
||||
@Before |
||||
fun setup() { |
||||
shadowOf(getMainLooper()).idle() |
||||
|
||||
api = mock() |
||||
eventHub = EventHub() |
||||
val filterModel = FilterModel() |
||||
val timelineCases = TimelineCases(api, eventHub) |
||||
val accountManager: AccountManager = mock { |
||||
on { activeAccount } doReturn AccountEntity( |
||||
id = 1, |
||||
domain = "mastodon.test", |
||||
accessToken = "fakeToken", |
||||
clientId = "fakeId", |
||||
clientSecret = "fakeSecret", |
||||
isActive = true |
||||
) |
||||
} |
||||
viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager) |
||||
} |
||||
|
||||
@Test |
||||
fun `should emit status and context when both load`() { |
||||
mockSuccessResponses() |
||||
|
||||
viewModel.loadThread(threadId) |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Success( |
||||
statuses = listOf( |
||||
mockStatusViewData(id = "1", spoilerText = "Test"), |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), |
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") |
||||
), |
||||
revealButton = RevealButtonState.REVEAL, |
||||
refreshing = false |
||||
), |
||||
viewModel.uiState.first() |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `should emit status even if context fails to load`() { |
||||
api.stub { |
||||
onBlocking { statusAsync(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1")) |
||||
onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException()) |
||||
} |
||||
|
||||
viewModel.loadThread(threadId) |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Success( |
||||
statuses = listOf( |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true) |
||||
), |
||||
revealButton = RevealButtonState.NO_BUTTON, |
||||
refreshing = false |
||||
), |
||||
viewModel.uiState.first() |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `should emit error when status and context fail to load`() { |
||||
api.stub { |
||||
onBlocking { statusAsync(threadId) } doReturn NetworkResult.failure(IOException()) |
||||
onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException()) |
||||
} |
||||
|
||||
viewModel.loadThread(threadId) |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Error::class.java, |
||||
viewModel.uiState.first().javaClass |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `should emit error when status fails to load`() { |
||||
api.stub { |
||||
onBlocking { statusAsync(threadId) } doReturn NetworkResult.failure(IOException()) |
||||
onBlocking { statusContext(threadId) } doReturn NetworkResult.success( |
||||
StatusContext( |
||||
ancestors = listOf(mockStatus(id = "1")), |
||||
descendants = listOf(mockStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1")) |
||||
) |
||||
) |
||||
} |
||||
|
||||
viewModel.loadThread(threadId) |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Error::class.java, |
||||
viewModel.uiState.first().javaClass |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `should update state when reveal button is toggled`() { |
||||
mockSuccessResponses() |
||||
|
||||
viewModel.loadThread(threadId) |
||||
viewModel.toggleRevealButton() |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Success( |
||||
statuses = listOf( |
||||
mockStatusViewData(id = "1", spoilerText = "Test", isExpanded = true), |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true), |
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", isExpanded = true) |
||||
), |
||||
revealButton = RevealButtonState.HIDE, |
||||
refreshing = false |
||||
), |
||||
viewModel.uiState.first() |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `should handle favorite event`() { |
||||
mockSuccessResponses() |
||||
|
||||
viewModel.loadThread(threadId) |
||||
|
||||
eventHub.dispatch(FavoriteEvent(statusId = "1", false)) |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Success( |
||||
statuses = listOf( |
||||
mockStatusViewData(id = "1", spoilerText = "Test", favourited = false), |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), |
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") |
||||
), |
||||
revealButton = RevealButtonState.REVEAL, |
||||
refreshing = false |
||||
), |
||||
viewModel.uiState.first() |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `should handle reblog event`() { |
||||
mockSuccessResponses() |
||||
|
||||
viewModel.loadThread(threadId) |
||||
|
||||
eventHub.dispatch(ReblogEvent(statusId = "2", true)) |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Success( |
||||
statuses = listOf( |
||||
mockStatusViewData(id = "1", spoilerText = "Test"), |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", reblogged = true), |
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") |
||||
), |
||||
revealButton = RevealButtonState.REVEAL, |
||||
refreshing = false |
||||
), |
||||
viewModel.uiState.first() |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `should handle bookmark event`() { |
||||
mockSuccessResponses() |
||||
|
||||
viewModel.loadThread(threadId) |
||||
|
||||
eventHub.dispatch(BookmarkEvent(statusId = "3", false)) |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Success( |
||||
statuses = listOf( |
||||
mockStatusViewData(id = "1", spoilerText = "Test"), |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), |
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", bookmarked = false) |
||||
), |
||||
revealButton = RevealButtonState.REVEAL, |
||||
refreshing = false |
||||
), |
||||
viewModel.uiState.first() |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `should remove status`() { |
||||
mockSuccessResponses() |
||||
|
||||
viewModel.loadThread(threadId) |
||||
|
||||
viewModel.removeStatus(mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")) |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Success( |
||||
statuses = listOf( |
||||
mockStatusViewData(id = "1", spoilerText = "Test"), |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test") |
||||
), |
||||
revealButton = RevealButtonState.REVEAL, |
||||
refreshing = false |
||||
), |
||||
viewModel.uiState.first() |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `should change status expanded state`() { |
||||
mockSuccessResponses() |
||||
|
||||
viewModel.loadThread(threadId) |
||||
|
||||
viewModel.changeExpanded( |
||||
true, |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test") |
||||
) |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Success( |
||||
statuses = listOf( |
||||
mockStatusViewData(id = "1", spoilerText = "Test"), |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true), |
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") |
||||
), |
||||
revealButton = RevealButtonState.REVEAL, |
||||
refreshing = false |
||||
), |
||||
viewModel.uiState.first() |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `should change content collapsed state`() { |
||||
mockSuccessResponses() |
||||
|
||||
viewModel.loadThread(threadId) |
||||
|
||||
viewModel.changeContentCollapsed( |
||||
true, |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test") |
||||
) |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Success( |
||||
statuses = listOf( |
||||
mockStatusViewData(id = "1", spoilerText = "Test"), |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isCollapsed = true), |
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") |
||||
), |
||||
revealButton = RevealButtonState.REVEAL, |
||||
refreshing = false |
||||
), |
||||
viewModel.uiState.first() |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `should change content showing state`() { |
||||
mockSuccessResponses() |
||||
|
||||
viewModel.loadThread(threadId) |
||||
|
||||
viewModel.changeContentShowing( |
||||
true, |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test") |
||||
) |
||||
|
||||
runBlocking { |
||||
assertEquals( |
||||
ThreadUiState.Success( |
||||
statuses = listOf( |
||||
mockStatusViewData(id = "1", spoilerText = "Test"), |
||||
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isShowingContent = true), |
||||
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") |
||||
), |
||||
revealButton = RevealButtonState.REVEAL, |
||||
refreshing = false |
||||
), |
||||
viewModel.uiState.first() |
||||
) |
||||
} |
||||
} |
||||
|
||||
private fun mockSuccessResponses() { |
||||
api.stub { |
||||
onBlocking { statusAsync(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1", spoilerText = "Test")) |
||||
onBlocking { statusContext(threadId) } doReturn NetworkResult.success( |
||||
StatusContext( |
||||
ancestors = listOf(mockStatus(id = "1", spoilerText = "Test")), |
||||
descendants = listOf(mockStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")) |
||||
) |
||||
) |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue