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"?> |
<?xml version="1.0" encoding="utf-8"?> |
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" |
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||||
xmlns:tools="http://schemas.android.com/tools" |
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||||
android:id="@+id/swipeRefreshLayout" |
|
||||||
android:layout_width="match_parent" |
android:layout_width="match_parent" |
||||||
android:layout_height="match_parent" |
android:layout_height="match_parent"> |
||||||
android:layout_gravity="top" |
|
||||||
tools:viewBindingIgnore="true"> |
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView |
<com.google.android.material.appbar.AppBarLayout |
||||||
android:id="@+id/recyclerView" |
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_width="match_parent" |
||||||
android:layout_height="match_parent" |
android:layout_height="match_parent" |
||||||
android:background="?android:attr/colorBackground" |
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" |
||||||
android:scrollbars="vertical" /> |
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