rewrite threads with Kotlin & coroutines (#2617)
* 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 ViewThreadViewModel
This commit is contained in:
parent
607f448eb3
commit
741461acde
24 changed files with 1446 additions and 999 deletions
|
@ -98,7 +98,7 @@
|
|||
android:theme="@style/TuskyDialogActivityTheme"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize" />
|
||||
<activity
|
||||
android:name=".ViewThreadActivity"
|
||||
android:name=".components.viewthread.ViewThreadActivity"
|
||||
android:configChanges="orientation|screenSize" />
|
||||
<activity
|
||||
android:name=".ViewMediaActivity"
|
||||
|
|
|
@ -27,6 +27,7 @@ import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
|
|||
import autodispose2.autoDispose
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.openLink
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
|
|
|
@ -47,6 +47,7 @@ import autodispose2.autoDispose
|
|||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.FutureTarget
|
||||
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.fragment.ViewImageFragment
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -21,12 +21,12 @@ import com.keylesspalace.tusky.viewdata.StatusViewData;
|
|||
import java.text.DateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
||||
private TextView reblogs;
|
||||
private TextView favourites;
|
||||
private View infoDivider;
|
||||
public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
||||
private final TextView reblogs;
|
||||
private final TextView favourites;
|
||||
private final View infoDivider;
|
||||
|
||||
StatusDetailedViewHolder(View view) {
|
||||
public StatusDetailedViewHolder(View view) {
|
||||
super(view);
|
||||
reblogs = view.findViewById(R.id.status_reblogs);
|
||||
favourites = view.findViewById(R.id.status_favourites);
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@
|
|||
* 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.view
|
||||
package com.keylesspalace.tusky.components.viewthread
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
|
@ -22,7 +22,6 @@ import android.view.View
|
|||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.ThreadAdapter
|
||||
|
||||
class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() {
|
||||
|
||||
|
@ -39,22 +38,19 @@ class ConversationLineItemDecoration(private val context: Context) : RecyclerVie
|
|||
val child = parent.getChildAt(i)
|
||||
|
||||
val position = parent.getChildAdapterPosition(child)
|
||||
val adapter = parent.adapter as ThreadAdapter
|
||||
val items = (parent.adapter as ThreadAdapter).currentList
|
||||
|
||||
val current = items.getOrNull(position)
|
||||
|
||||
val current = adapter.getItem(position)
|
||||
val dividerTop: Int
|
||||
val dividerBottom: Int
|
||||
if (current != null) {
|
||||
val above = adapter.getItem(position - 1)
|
||||
dividerTop = if (above != null && above.id == current.status.inReplyToId) {
|
||||
val above = items.getOrNull(position - 1)
|
||||
val dividerTop = if (above != null && above.id == current.status.inReplyToId) {
|
||||
child.top
|
||||
} else {
|
||||
child.top + avatarMargin
|
||||
}
|
||||
val below = adapter.getItem(position + 1)
|
||||
dividerBottom = if (below != null && current.id == below.status.inReplyToId &&
|
||||
adapter.detailedStatusPosition != position
|
||||
) {
|
||||
val below = items.getOrNull(position + 1)
|
||||
val dividerBottom = if (below != null && current.id == below.status.inReplyToId && below.isDetailed) {
|
||||
child.bottom
|
||||
} else {
|
||||
child.top + avatarMargin
|
|
@ -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
|
||||
}
|
|
@ -27,7 +27,6 @@ import com.keylesspalace.tusky.SplashActivity
|
|||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.TabPreferenceActivity
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
import com.keylesspalace.tusky.ViewThreadActivity
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
|
@ -39,6 +38,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesActivity
|
|||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
||||
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
|
|
|
@ -29,9 +29,9 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen
|
|||
import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment
|
||||
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
|
||||
import com.keylesspalace.tusky.fragment.AccountListFragment
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||
import com.keylesspalace.tusky.fragment.ViewThreadFragment
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
|
|||
import com.keylesspalace.tusky.components.search.SearchViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
||||
|
@ -108,5 +109,9 @@ abstract class ViewModelModule {
|
|||
@ViewModelKey(NetworkTimelineViewModel::class)
|
||||
internal abstract fun networkTimelineViewModel(viewModel: NetworkTimelineViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ViewThreadViewModel::class)
|
||||
internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel
|
||||
// Add more ViewModels here
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -167,10 +167,15 @@ interface MastodonApi {
|
|||
@Path("id") statusId: String
|
||||
): Single<Status>
|
||||
|
||||
@GET("api/v1/statuses/{id}/context")
|
||||
fun statusContext(
|
||||
@GET("api/v1/statuses/{id}")
|
||||
suspend fun statusAsync(
|
||||
@Path("id") statusId: String
|
||||
): Single<StatusContext>
|
||||
): NetworkResult<Status>
|
||||
|
||||
@GET("api/v1/statuses/{id}/context")
|
||||
suspend fun statusContext(
|
||||
@Path("id") statusId: String
|
||||
): NetworkResult<StatusContext>
|
||||
|
||||
@GET("api/v1/statuses/{id}/reblogged_by")
|
||||
fun statusRebloggedBy(
|
||||
|
|
|
@ -25,13 +25,15 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
|
|||
fun Status.toViewData(
|
||||
isShowingContent: Boolean,
|
||||
isExpanded: Boolean,
|
||||
isCollapsed: Boolean
|
||||
isCollapsed: Boolean,
|
||||
isDetailed: Boolean = false
|
||||
): StatusViewData.Concrete {
|
||||
return StatusViewData.Concrete(
|
||||
status = this,
|
||||
isShowingContent = isShowingContent,
|
||||
isCollapsed = isCollapsed,
|
||||
isExpanded = isExpanded,
|
||||
isDetailed = isDetailed
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -42,6 +42,7 @@ sealed class StatusViewData {
|
|||
*/
|
||||
/** Whether the status meets the requirement to be collapse */
|
||||
val isCollapsed: Boolean,
|
||||
val isDetailed: Boolean = false
|
||||
) : StatusViewData() {
|
||||
override val id: String
|
||||
get() = status.id
|
||||
|
|
12
app/src/main/res/drawable/ic_back.xml
Normal file
12
app/src/main/res/drawable/ic_back.xml
Normal file
|
@ -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,15 +1,30 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/windowBackgroundColor"
|
||||
tools:viewBindingIgnore="true">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:elevation="@dimen/actionbar_elevation" >
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:title="@string/title_view_thread"
|
||||
app:navigationIcon="@drawable/ic_back"
|
||||
app:menu="@menu/view_thread_toolbar"/>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_width="640dp"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
|
||||
android:layout_gravity="center_horizontal">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
|
@ -18,6 +33,20 @@
|
|||
android:layout_height="match_parent"
|
||||
android:background="?android:attr/colorBackground"
|
||||
android:scrollbars="vertical" />
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
</FrameLayout>
|
||||
<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.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
|
@ -2,12 +2,9 @@
|
|||
<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/activity_view_thread"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context="com.keylesspalace.tusky.ViewThreadActivity">
|
||||
|
||||
<include layout="@layout/toolbar_basic" />
|
||||
tools:context="com.keylesspalace.tusky.components.viewthread.ViewThreadActivity">
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/fragment_container"
|
||||
|
|
|
@ -1,17 +1,52 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="top"
|
||||
tools:viewBindingIgnore="true">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:elevation="@dimen/actionbar_elevation" >
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:title="@string/title_view_thread"
|
||||
app:navigationIcon="@drawable/ic_back"
|
||||
app:menu="@menu/view_thread_toolbar"/>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?android:attr/colorBackground"
|
||||
android:scrollbars="vertical" />
|
||||
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
|
||||
android:layout_gravity="top">
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
<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.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
|
@ -10,7 +10,15 @@ import java.util.Date
|
|||
|
||||
private val fixedDate = Date(1638889052000)
|
||||
|
||||
fun mockStatus(id: String = "100") = Status(
|
||||
fun mockStatus(
|
||||
id: String = "100",
|
||||
inReplyToId: String? = null,
|
||||
inReplyToAccountId: String? = null,
|
||||
spoilerText: String = "",
|
||||
reblogged: Boolean = false,
|
||||
favourited: Boolean = true,
|
||||
bookmarked: Boolean = true
|
||||
) = Status(
|
||||
id = id,
|
||||
url = "https://mastodon.example/@ConnyDuck/$id",
|
||||
account = TimelineAccount(
|
||||
|
@ -21,8 +29,8 @@ fun mockStatus(id: String = "100") = Status(
|
|||
url = "https://mastodon.example/@ConnyDuck",
|
||||
avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg"
|
||||
),
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
inReplyToId = inReplyToId,
|
||||
inReplyToAccountId = inReplyToAccountId,
|
||||
reblog = null,
|
||||
content = "Test",
|
||||
createdAt = fixedDate,
|
||||
|
@ -30,11 +38,11 @@ fun mockStatus(id: String = "100") = Status(
|
|||
reblogsCount = 1,
|
||||
favouritesCount = 2,
|
||||
repliesCount = 3,
|
||||
reblogged = false,
|
||||
favourited = true,
|
||||
bookmarked = true,
|
||||
reblogged = reblogged,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive = true,
|
||||
spoilerText = "",
|
||||
spoilerText = spoilerText,
|
||||
visibility = Status.Visibility.PUBLIC,
|
||||
attachments = ArrayList(),
|
||||
mentions = emptyList(),
|
||||
|
@ -46,11 +54,32 @@ fun mockStatus(id: String = "100") = Status(
|
|||
card = null
|
||||
)
|
||||
|
||||
fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete(
|
||||
status = mockStatus(id),
|
||||
isExpanded = false,
|
||||
isShowingContent = false,
|
||||
isCollapsed = true,
|
||||
fun mockStatusViewData(
|
||||
id: String = "100",
|
||||
inReplyToId: String? = null,
|
||||
inReplyToAccountId: String? = null,
|
||||
isDetailed: Boolean = false,
|
||||
spoilerText: String = "",
|
||||
isExpanded: Boolean = false,
|
||||
isShowingContent: Boolean = false,
|
||||
isCollapsed: Boolean = !isDetailed,
|
||||
reblogged: Boolean = false,
|
||||
favourited: Boolean = true,
|
||||
bookmarked: Boolean = true
|
||||
) = StatusViewData.Concrete(
|
||||
status = mockStatus(
|
||||
id = id,
|
||||
inReplyToId = inReplyToId,
|
||||
inReplyToAccountId = inReplyToAccountId,
|
||||
spoilerText = spoilerText,
|
||||
reblogged = reblogged,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked
|
||||
),
|
||||
isExpanded = isExpanded,
|
||||
isShowingContent = isShowingContent,
|
||||
isCollapsed = isCollapsed,
|
||||
isDetailed = isDetailed
|
||||
)
|
||||
|
||||
fun mockStatusEntityWithAccount(
|
||||
|
|
|
@ -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 a new issue