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
@ -98,7 +98,7 @@
|
|||||||
android:theme="@style/TuskyDialogActivityTheme"
|
android:theme="@style/TuskyDialogActivityTheme"
|
||||||
android:windowSoftInputMode="stateVisible|adjustResize" />
|
android:windowSoftInputMode="stateVisible|adjustResize" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ViewThreadActivity"
|
android:name=".components.viewthread.ViewThreadActivity"
|
||||||
android:configChanges="orientation|screenSize" />
|
android:configChanges="orientation|screenSize" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ViewMediaActivity"
|
android:name=".ViewMediaActivity"
|
||||||
|
@ -27,6 +27,7 @@ import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
|
|||||||
import autodispose2.autoDispose
|
import autodispose2.autoDispose
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||||
|
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.util.openLink
|
import com.keylesspalace.tusky.util.openLink
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
@ -47,6 +47,7 @@ import autodispose2.autoDispose
|
|||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.request.FutureTarget
|
import com.bumptech.glide.request.FutureTarget
|
||||||
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
|
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
|
||||||
|
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||||
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
|
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.fragment.ViewImageFragment
|
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.text.DateFormat;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
||||||
private TextView reblogs;
|
private final TextView reblogs;
|
||||||
private TextView favourites;
|
private final TextView favourites;
|
||||||
private View infoDivider;
|
private final View infoDivider;
|
||||||
|
|
||||||
StatusDetailedViewHolder(View view) {
|
public StatusDetailedViewHolder(View view) {
|
||||||
super(view);
|
super(view);
|
||||||
reblogs = view.findViewById(R.id.status_reblogs);
|
reblogs = view.findViewById(R.id.status_reblogs);
|
||||||
favourites = view.findViewById(R.id.status_favourites);
|
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,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.view
|
package com.keylesspalace.tusky.components.viewthread
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
@ -22,7 +22,6 @@ import android.view.View
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.adapter.ThreadAdapter
|
|
||||||
|
|
||||||
class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() {
|
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 child = parent.getChildAt(i)
|
||||||
|
|
||||||
val position = parent.getChildAdapterPosition(child)
|
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) {
|
if (current != null) {
|
||||||
val above = adapter.getItem(position - 1)
|
val above = items.getOrNull(position - 1)
|
||||||
dividerTop = if (above != null && above.id == current.status.inReplyToId) {
|
val dividerTop = if (above != null && above.id == current.status.inReplyToId) {
|
||||||
child.top
|
child.top
|
||||||
} else {
|
} else {
|
||||||
child.top + avatarMargin
|
child.top + avatarMargin
|
||||||
}
|
}
|
||||||
val below = adapter.getItem(position + 1)
|
val below = items.getOrNull(position + 1)
|
||||||
dividerBottom = if (below != null && current.id == below.status.inReplyToId &&
|
val dividerBottom = if (below != null && current.id == below.status.inReplyToId && below.isDetailed) {
|
||||||
adapter.detailedStatusPosition != position
|
|
||||||
) {
|
|
||||||
child.bottom
|
child.bottom
|
||||||
} else {
|
} else {
|
||||||
child.top + avatarMargin
|
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.StatusListActivity
|
||||||
import com.keylesspalace.tusky.TabPreferenceActivity
|
import com.keylesspalace.tusky.TabPreferenceActivity
|
||||||
import com.keylesspalace.tusky.ViewMediaActivity
|
import com.keylesspalace.tusky.ViewMediaActivity
|
||||||
import com.keylesspalace.tusky.ViewThreadActivity
|
|
||||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||||
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
|
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
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.report.ReportActivity
|
||||||
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
||||||
import com.keylesspalace.tusky.components.search.SearchActivity
|
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||||
|
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.android.ContributesAndroidInjector
|
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.SearchHashtagsFragment
|
||||||
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
|
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
|
||||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
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.AccountListFragment
|
||||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||||
import com.keylesspalace.tusky.fragment.ViewThreadFragment
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.android.ContributesAndroidInjector
|
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.search.SearchViewModel
|
||||||
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
|
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
|
||||||
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
|
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.AccountsInListViewModel
|
||||||
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
||||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
||||||
@ -108,5 +109,9 @@ abstract class ViewModelModule {
|
|||||||
@ViewModelKey(NetworkTimelineViewModel::class)
|
@ViewModelKey(NetworkTimelineViewModel::class)
|
||||||
internal abstract fun networkTimelineViewModel(viewModel: NetworkTimelineViewModel): ViewModel
|
internal abstract fun networkTimelineViewModel(viewModel: NetworkTimelineViewModel): ViewModel
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoMap
|
||||||
|
@ViewModelKey(ViewThreadViewModel::class)
|
||||||
|
internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel
|
||||||
// Add more ViewModels here
|
// 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
|
@Path("id") statusId: String
|
||||||
): Single<Status>
|
): Single<Status>
|
||||||
|
|
||||||
@GET("api/v1/statuses/{id}/context")
|
@GET("api/v1/statuses/{id}")
|
||||||
fun statusContext(
|
suspend fun statusAsync(
|
||||||
@Path("id") statusId: String
|
@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")
|
@GET("api/v1/statuses/{id}/reblogged_by")
|
||||||
fun statusRebloggedBy(
|
fun statusRebloggedBy(
|
||||||
|
@ -25,13 +25,15 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
|
|||||||
fun Status.toViewData(
|
fun Status.toViewData(
|
||||||
isShowingContent: Boolean,
|
isShowingContent: Boolean,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
isCollapsed: Boolean
|
isCollapsed: Boolean,
|
||||||
|
isDetailed: Boolean = false
|
||||||
): StatusViewData.Concrete {
|
): StatusViewData.Concrete {
|
||||||
return StatusViewData.Concrete(
|
return StatusViewData.Concrete(
|
||||||
status = this,
|
status = this,
|
||||||
isShowingContent = isShowingContent,
|
isShowingContent = isShowingContent,
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
|
isDetailed = isDetailed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,6 +42,7 @@ sealed class StatusViewData {
|
|||||||
*/
|
*/
|
||||||
/** Whether the status meets the requirement to be collapse */
|
/** Whether the status meets the requirement to be collapse */
|
||||||
val isCollapsed: Boolean,
|
val isCollapsed: Boolean,
|
||||||
|
val isDetailed: Boolean = false
|
||||||
) : StatusViewData() {
|
) : StatusViewData() {
|
||||||
override val id: String
|
override val id: String
|
||||||
get() = status.id
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent">
|
||||||
android:background="?attr/windowBackgroundColor"
|
|
||||||
tools:viewBindingIgnore="true">
|
<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
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/swipeRefreshLayout"
|
android:id="@+id/swipeRefreshLayout"
|
||||||
android:layout_width="640dp"
|
android:layout_width="640dp"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
|
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
|
||||||
android:layout_gravity="center_horizontal">
|
android:layout_gravity="center_horizontal">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
@ -18,6 +33,20 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="?android:attr/colorBackground"
|
android:background="?android:attr/colorBackground"
|
||||||
android:scrollbars="vertical" />
|
android:scrollbars="vertical" />
|
||||||
|
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
</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"
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:id="@+id/activity_view_thread"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:context="com.keylesspalace.tusky.ViewThreadActivity">
|
tools:context="com.keylesspalace.tusky.components.viewthread.ViewThreadActivity">
|
||||||
|
|
||||||
<include layout="@layout/toolbar_basic" />
|
|
||||||
|
|
||||||
<androidx.fragment.app.FragmentContainerView
|
<androidx.fragment.app.FragmentContainerView
|
||||||
android:id="@+id/fragment_container"
|
android:id="@+id/fragment_container"
|
||||||
|
@ -1,11 +1,31 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
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:id="@+id/swipeRefreshLayout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="top"
|
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
|
||||||
tools:viewBindingIgnore="true">
|
android:layout_gravity="top">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recyclerView"
|
android:id="@+id/recyclerView"
|
||||||
@ -15,3 +35,18 @@
|
|||||||
android:scrollbars="vertical" />
|
android:scrollbars="vertical" />
|
||||||
|
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
</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)
|
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,
|
id = id,
|
||||||
url = "https://mastodon.example/@ConnyDuck/$id",
|
url = "https://mastodon.example/@ConnyDuck/$id",
|
||||||
account = TimelineAccount(
|
account = TimelineAccount(
|
||||||
@ -21,8 +29,8 @@ fun mockStatus(id: String = "100") = Status(
|
|||||||
url = "https://mastodon.example/@ConnyDuck",
|
url = "https://mastodon.example/@ConnyDuck",
|
||||||
avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg"
|
avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg"
|
||||||
),
|
),
|
||||||
inReplyToId = null,
|
inReplyToId = inReplyToId,
|
||||||
inReplyToAccountId = null,
|
inReplyToAccountId = inReplyToAccountId,
|
||||||
reblog = null,
|
reblog = null,
|
||||||
content = "Test",
|
content = "Test",
|
||||||
createdAt = fixedDate,
|
createdAt = fixedDate,
|
||||||
@ -30,11 +38,11 @@ fun mockStatus(id: String = "100") = Status(
|
|||||||
reblogsCount = 1,
|
reblogsCount = 1,
|
||||||
favouritesCount = 2,
|
favouritesCount = 2,
|
||||||
repliesCount = 3,
|
repliesCount = 3,
|
||||||
reblogged = false,
|
reblogged = reblogged,
|
||||||
favourited = true,
|
favourited = favourited,
|
||||||
bookmarked = true,
|
bookmarked = bookmarked,
|
||||||
sensitive = true,
|
sensitive = true,
|
||||||
spoilerText = "",
|
spoilerText = spoilerText,
|
||||||
visibility = Status.Visibility.PUBLIC,
|
visibility = Status.Visibility.PUBLIC,
|
||||||
attachments = ArrayList(),
|
attachments = ArrayList(),
|
||||||
mentions = emptyList(),
|
mentions = emptyList(),
|
||||||
@ -46,11 +54,32 @@ fun mockStatus(id: String = "100") = Status(
|
|||||||
card = null
|
card = null
|
||||||
)
|
)
|
||||||
|
|
||||||
fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete(
|
fun mockStatusViewData(
|
||||||
status = mockStatus(id),
|
id: String = "100",
|
||||||
isExpanded = false,
|
inReplyToId: String? = null,
|
||||||
isShowingContent = false,
|
inReplyToAccountId: String? = null,
|
||||||
isCollapsed = true,
|
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(
|
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…
x
Reference in New Issue
Block a user