From 741461acde9ed397fa4bbb291a261b206ceeddf3 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Mon, 15 Aug 2022 11:00:18 +0200 Subject: [PATCH] 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 --- app/src/main/AndroidManifest.xml | 2 +- .../tusky/BottomSheetActivity.kt | 1 + .../keylesspalace/tusky/ViewMediaActivity.kt | 1 + .../tusky/ViewThreadActivity.java | 130 ---- .../adapter/StatusDetailedViewHolder.java | 10 +- .../tusky/adapter/ThreadAdapter.kt | 129 ---- .../ConversationLineItemDecoration.kt | 20 +- .../components/viewthread/ThreadAdapter.kt | 95 +++ .../viewthread/ViewThreadActivity.kt | 62 ++ .../viewthread/ViewThreadFragment.kt | 337 +++++++++ .../viewthread/ViewThreadViewModel.kt | 426 +++++++++++ .../tusky/di/ActivitiesModule.kt | 2 +- .../tusky/di/FragmentBuildersModule.kt | 2 +- .../tusky/di/ViewModelFactory.kt | 5 + .../tusky/fragment/ViewThreadFragment.java | 683 ------------------ .../tusky/network/MastodonApi.kt | 11 +- .../keylesspalace/tusky/util/ViewDataUtils.kt | 4 +- .../tusky/viewdata/StatusViewData.kt | 1 + app/src/main/res/drawable/ic_back.xml | 12 + .../layout-sw640dp/fragment_view_thread.xml | 41 +- .../main/res/layout/activity_view_thread.xml | 5 +- .../main/res/layout/fragment_view_thread.xml | 57 +- .../tusky/components/timeline/StatusMocker.kt | 53 +- .../viewthread/ViewThreadViewModelTest.kt | 356 +++++++++ 24 files changed, 1446 insertions(+), 999 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java delete mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.kt rename app/src/main/java/com/keylesspalace/tusky/{view => components/viewthread}/ConversationLineItemDecoration.kt (78%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java create mode 100644 app/src/main/res/drawable/ic_back.xml create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2273316d..34968690 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -98,7 +98,7 @@ android:theme="@style/TuskyDialogActivityTheme" android:windowSoftInputMode="stateVisible|adjustResize" /> . */ - -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 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 androidInjector() { - return dispatchingAndroidInjector; - } - -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index ae0b0678..74f09f64 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -21,12 +21,12 @@ import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.DateFormat; import java.util.Date; -class StatusDetailedViewHolder extends StatusBaseViewHolder { - private TextView reblogs; - private TextView favourites; - private View infoDivider; +public class StatusDetailedViewHolder extends StatusBaseViewHolder { + private final TextView reblogs; + private final TextView favourites; + private final View infoDivider; - StatusDetailedViewHolder(View view) { + public StatusDetailedViewHolder(View view) { super(view); reblogs = view.findViewById(R.id.status_reblogs); favourites = view.findViewById(R.id.status_favourites); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.kt deleted file mode 100644 index 8abbbd5f..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.kt +++ /dev/null @@ -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 . */ -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() { - private val statuses = mutableListOf() - 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?) { - 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) { - this.statuses.addAll(position, statuses) - notifyItemRangeInserted(position, statuses.size) - } - - fun addAll(statuses: List) { - 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 - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt similarity index 78% rename from app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt rename to app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt index c291bd01..7e98ebef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.view +package com.keylesspalace.tusky.components.viewthread import android.content.Context import android.graphics.Canvas @@ -22,7 +22,6 @@ import android.view.View import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.ThreadAdapter class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() { @@ -39,22 +38,19 @@ class ConversationLineItemDecoration(private val context: Context) : RecyclerVie val child = parent.getChildAt(i) val position = parent.getChildAdapterPosition(child) - val adapter = parent.adapter as ThreadAdapter + val items = (parent.adapter as ThreadAdapter).currentList + + val current = items.getOrNull(position) - val current = adapter.getItem(position) - val dividerTop: Int - val dividerBottom: Int if (current != null) { - val above = adapter.getItem(position - 1) - dividerTop = if (above != null && above.id == current.status.inReplyToId) { + val above = items.getOrNull(position - 1) + val dividerTop = if (above != null && above.id == current.status.inReplyToId) { child.top } else { child.top + avatarMargin } - val below = adapter.getItem(position + 1) - dividerBottom = if (below != null && current.id == below.status.inReplyToId && - adapter.detailedStatusPosition != position - ) { + val below = items.getOrNull(position + 1) + val dividerBottom = if (below != null && current.id == below.status.inReplyToId && below.isDetailed) { child.bottom } else { child.top + avatarMargin diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt new file mode 100644 index 00000000..9e0903b0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt @@ -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 . */ + +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(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() { + 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 + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt new file mode 100644 index 00000000..ed0393fa --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt @@ -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 . */ + +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 + + 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_" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt new file mode 100644 index 00000000..cee6f046 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -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 . */ + +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) { + 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 + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt new file mode 100644 index 00000000..c109494c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -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 . */ + +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 = MutableStateFlow(ThreadUiState.Loading) + val uiState: Flow + get() = _uiState + + private val _errors = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val errors: Flow + 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, 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.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.filter(): List { + 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, + val revealButton: RevealButtonState, + val refreshing: Boolean + ) : ThreadUiState +} + +enum class RevealButtonState { + NO_BUTTON, REVEAL, HIDE +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index d85f6c45..15d6d9cd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -27,7 +27,6 @@ import com.keylesspalace.tusky.SplashActivity import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.ViewMediaActivity -import com.keylesspalace.tusky.ViewThreadActivity import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity @@ -39,6 +38,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity +import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import dagger.Module import dagger.android.ContributesAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index b3c5eaf8..989fb526 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -29,9 +29,9 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.fragment.AccountListFragment import com.keylesspalace.tusky.fragment.NotificationsFragment -import com.keylesspalace.tusky.fragment.ViewThreadFragment import dagger.Module import dagger.android.ContributesAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index c8f746e0..05444b5a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -14,6 +14,7 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.keylesspalace.tusky.viewmodel.ListsViewModel @@ -108,5 +109,9 @@ abstract class ViewModelModule { @ViewModelKey(NetworkTimelineViewModel::class) internal abstract fun networkTimelineViewModel(viewModel: NetworkTimelineViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(ViewThreadViewModel::class) + internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel // Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java deleted file mode 100644 index 1d6525d1..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ /dev/null @@ -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 . */ - -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 statuses = - new PairedList<>(new Function() { - @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 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 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 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 unfilteredAncestors, List 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 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 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 descendants = new ArrayList<>(); - for (Status status : unfilteredDescendants) - if (!filterModel.shouldFilterStatus(status)) - descendants.add(status); - - // Insert newly fetched descendants - statuses.addAll(descendants); - List 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 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()); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 12d142ba..e72800c4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -167,10 +167,15 @@ interface MastodonApi { @Path("id") statusId: String ): Single - @GET("api/v1/statuses/{id}/context") - fun statusContext( + @GET("api/v1/statuses/{id}") + suspend fun statusAsync( @Path("id") statusId: String - ): Single + ): NetworkResult + + @GET("api/v1/statuses/{id}/context") + suspend fun statusContext( + @Path("id") statusId: String + ): NetworkResult @GET("api/v1/statuses/{id}/reblogged_by") fun statusRebloggedBy( diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index fef9c0bb..3facc3a9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -25,13 +25,15 @@ import com.keylesspalace.tusky.viewdata.StatusViewData fun Status.toViewData( isShowingContent: Boolean, isExpanded: Boolean, - isCollapsed: Boolean + isCollapsed: Boolean, + isDetailed: Boolean = false ): StatusViewData.Concrete { return StatusViewData.Concrete( status = this, isShowingContent = isShowingContent, isCollapsed = isCollapsed, isExpanded = isExpanded, + isDetailed = isDetailed ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index bef7d0e1..ac9df9c2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -42,6 +42,7 @@ sealed class StatusViewData { */ /** Whether the status meets the requirement to be collapse */ val isCollapsed: Boolean, + val isDetailed: Boolean = false ) : StatusViewData() { override val id: String get() = status.id diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 00000000..83808c54 --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml index 150f0860..a74cc83b 100644 --- a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml +++ b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml @@ -1,15 +1,30 @@ - + android:layout_height="match_parent"> + + + + + + + - \ No newline at end of file + + + + + diff --git a/app/src/main/res/layout/activity_view_thread.xml b/app/src/main/res/layout/activity_view_thread.xml index 66b156dc..c6a79182 100644 --- a/app/src/main/res/layout/activity_view_thread.xml +++ b/app/src/main/res/layout/activity_view_thread.xml @@ -2,12 +2,9 @@ - - + tools:context="com.keylesspalace.tusky.components.viewthread.ViewThreadActivity"> - + android:layout_height="match_parent"> - + + + + + + + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" + android:layout_gravity="top"> - + + + + + + + + + diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt index 8781f6d9..fc8e15b3 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -10,7 +10,15 @@ import java.util.Date private val fixedDate = Date(1638889052000) -fun mockStatus(id: String = "100") = Status( +fun mockStatus( + id: String = "100", + inReplyToId: String? = null, + inReplyToAccountId: String? = null, + spoilerText: String = "", + reblogged: Boolean = false, + favourited: Boolean = true, + bookmarked: Boolean = true +) = Status( id = id, url = "https://mastodon.example/@ConnyDuck/$id", account = TimelineAccount( @@ -21,8 +29,8 @@ fun mockStatus(id: String = "100") = Status( url = "https://mastodon.example/@ConnyDuck", avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg" ), - inReplyToId = null, - inReplyToAccountId = null, + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, reblog = null, content = "Test", createdAt = fixedDate, @@ -30,11 +38,11 @@ fun mockStatus(id: String = "100") = Status( reblogsCount = 1, favouritesCount = 2, repliesCount = 3, - reblogged = false, - favourited = true, - bookmarked = true, + reblogged = reblogged, + favourited = favourited, + bookmarked = bookmarked, sensitive = true, - spoilerText = "", + spoilerText = spoilerText, visibility = Status.Visibility.PUBLIC, attachments = ArrayList(), mentions = emptyList(), @@ -46,11 +54,32 @@ fun mockStatus(id: String = "100") = Status( card = null ) -fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete( - status = mockStatus(id), - isExpanded = false, - isShowingContent = false, - isCollapsed = true, +fun mockStatusViewData( + id: String = "100", + inReplyToId: String? = null, + inReplyToAccountId: String? = null, + isDetailed: Boolean = false, + spoilerText: String = "", + isExpanded: Boolean = false, + isShowingContent: Boolean = false, + isCollapsed: Boolean = !isDetailed, + reblogged: Boolean = false, + favourited: Boolean = true, + bookmarked: Boolean = true +) = StatusViewData.Concrete( + status = mockStatus( + id = id, + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + spoilerText = spoilerText, + reblogged = reblogged, + favourited = favourited, + bookmarked = bookmarked + ), + isExpanded = isExpanded, + isShowingContent = isShowingContent, + isCollapsed = isCollapsed, + isDetailed = isDetailed ) fun mockStatusEntityWithAccount( diff --git a/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt new file mode 100644 index 00000000..e1d690a1 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt @@ -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")) + ) + ) + } + } +}