Timeline refactor (#2175)
* Move Timeline files into their own package * Introduce TimelineViewModel, add coroutines * Simplify StatusViewData * Handle timeilne fetch errors * Rework filters, fix ViewThreadFragment * Fix NotificationsFragment * Simplify Notifications and Thread, handle pin * Redo loading in TimelineViewModel * Improve error handling in TimelineViewModel * Rewrite actions in TimelineViewModel * Apply feedback after timeline factoring review * Handle initial failure in timeline correctly
This commit is contained in:
parent
0a992480c2
commit
44a5b42cac
58 changed files with 3956 additions and 3618 deletions
|
@ -119,6 +119,8 @@ dependencies {
|
||||||
implementation "androidx.work:work-runtime:2.5.0"
|
implementation "androidx.work:work-runtime:2.5.0"
|
||||||
implementation "androidx.room:room-runtime:$roomVersion"
|
implementation "androidx.room:room-runtime:$roomVersion"
|
||||||
implementation "androidx.room:room-rxjava3:$roomVersion"
|
implementation "androidx.room:room-rxjava3:$roomVersion"
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.5.0'
|
||||||
kapt "androidx.room:room-compiler:$roomVersion"
|
kapt "androidx.room:room-compiler:$roomVersion"
|
||||||
|
|
||||||
implementation "com.google.android.material:material:1.3.0"
|
implementation "com.google.android.material:material:1.3.0"
|
||||||
|
|
|
@ -5,7 +5,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.keylesspalace.tusky.db.*
|
import com.keylesspalace.tusky.db.*
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.repository.TimelineRepository
|
import com.keylesspalace.tusky.components.timeline.TimelineRepository
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
|
|
@ -5,6 +5,7 @@ import android.widget.AdapterView
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||||
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
|
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
|
||||||
|
@ -14,11 +15,14 @@ import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.show
|
import com.keylesspalace.tusky.util.show
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.rx3.await
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.Call
|
import retrofit2.Call
|
||||||
import retrofit2.Callback
|
import retrofit2.Callback
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.lang.Exception
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class FiltersActivity: BaseActivity() {
|
class FiltersActivity: BaseActivity() {
|
||||||
|
@ -162,37 +166,29 @@ class FiltersActivity: BaseActivity() {
|
||||||
binding.addFilterButton.hide()
|
binding.addFilterButton.hide()
|
||||||
binding.filterProgressBar.show()
|
binding.filterProgressBar.show()
|
||||||
|
|
||||||
api.getFilters().enqueue(object : Callback<List<Filter>> {
|
lifecycleScope.launch {
|
||||||
override fun onResponse(call: Call<List<Filter>>, response: Response<List<Filter>>) {
|
val newFilters = try {
|
||||||
val filterResponse = response.body()
|
api.getFilters().await()
|
||||||
if(response.isSuccessful && filterResponse != null) {
|
} catch (t: Exception) {
|
||||||
|
|
||||||
filters = filterResponse.filter { filter -> filter.context.contains(context) }.toMutableList()
|
|
||||||
refreshFilterDisplay()
|
|
||||||
|
|
||||||
binding.filtersView.show()
|
|
||||||
binding.addFilterButton.show()
|
|
||||||
binding.filterProgressBar.hide()
|
|
||||||
} else {
|
|
||||||
binding.filterProgressBar.hide()
|
|
||||||
binding.filterMessageView.show()
|
|
||||||
binding.filterMessageView.setup(R.drawable.elephant_error,
|
|
||||||
R.string.error_generic) { loadFilters() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(call: Call<List<Filter>>, t: Throwable) {
|
|
||||||
binding.filterProgressBar.hide()
|
binding.filterProgressBar.hide()
|
||||||
binding.filterMessageView.show()
|
binding.filterMessageView.show()
|
||||||
if (t is IOException) {
|
if (t is IOException) {
|
||||||
binding.filterMessageView.setup(R.drawable.elephant_offline,
|
binding.filterMessageView.setup(R.drawable.elephant_offline,
|
||||||
R.string.error_network) { loadFilters() }
|
R.string.error_network) { loadFilters() }
|
||||||
} else {
|
} else {
|
||||||
binding.filterMessageView.setup(R.drawable.elephant_error,
|
binding.filterMessageView.setup(R.drawable.elephant_error,
|
||||||
R.string.error_generic) { loadFilters() }
|
R.string.error_generic) { loadFilters() }
|
||||||
}
|
}
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
filters = newFilters.filter { it.context.contains(context) }.toMutableList()
|
||||||
|
refreshFilterDisplay()
|
||||||
|
|
||||||
|
binding.filtersView.show()
|
||||||
|
binding.addFilterButton.show()
|
||||||
|
binding.filterProgressBar.hide()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
@ -37,7 +37,7 @@ import com.keylesspalace.tusky.databinding.ActivityListsBinding
|
||||||
import com.keylesspalace.tusky.di.Injectable
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
import com.keylesspalace.tusky.entity.MastoList
|
import com.keylesspalace.tusky.entity.MastoList
|
||||||
import com.keylesspalace.tusky.fragment.TimelineFragment
|
import com.keylesspalace.tusky.components.timeline.TimelineViewModel
|
||||||
import com.keylesspalace.tusky.util.*
|
import com.keylesspalace.tusky.util.*
|
||||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
||||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.*
|
import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.*
|
||||||
|
@ -182,7 +182,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
||||||
|
|
||||||
private fun onListSelected(listId: String) {
|
private fun onListSelected(listId: String) {
|
||||||
startActivityWithSlideInAnimation(
|
startActivityWithSlideInAnimation(
|
||||||
ModalTimelineActivity.newIntent(this, TimelineFragment.Kind.LIST, listId))
|
ModalTimelineActivity.newIntent(this, TimelineViewModel.Kind.LIST, listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openListSettings(list: MastoList) {
|
private fun openListSettings(list: MastoList) {
|
||||||
|
|
|
@ -595,7 +595,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
||||||
|
|
||||||
private fun changeAccount(newSelectedId: Long, forward: Intent?) {
|
private fun changeAccount(newSelectedId: Long, forward: Intent?) {
|
||||||
cacheUpdater.stop()
|
cacheUpdater.stop()
|
||||||
SFragment.flushFilters()
|
|
||||||
accountManager.setActiveAccount(newSelectedId)
|
accountManager.setActiveAccount(newSelectedId)
|
||||||
val intent = Intent(this, MainActivity::class.java)
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
|
|
@ -5,7 +5,8 @@ import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
import com.keylesspalace.tusky.databinding.ActivityModalTimelineBinding
|
import com.keylesspalace.tusky.databinding.ActivityModalTimelineBinding
|
||||||
import com.keylesspalace.tusky.fragment.TimelineFragment
|
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||||
|
import com.keylesspalace.tusky.components.timeline.TimelineViewModel
|
||||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||||
import dagger.android.DispatchingAndroidInjector
|
import dagger.android.DispatchingAndroidInjector
|
||||||
import dagger.android.HasAndroidInjector
|
import dagger.android.HasAndroidInjector
|
||||||
|
@ -29,8 +30,8 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) {
|
if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) {
|
||||||
val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineFragment.Kind
|
val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineViewModel.Kind
|
||||||
?: TimelineFragment.Kind.HOME
|
?: TimelineViewModel.Kind.HOME
|
||||||
val argument = intent?.getStringExtra(ARG_ARG)
|
val argument = intent?.getStringExtra(ARG_ARG)
|
||||||
supportFragmentManager.beginTransaction()
|
supportFragmentManager.beginTransaction()
|
||||||
.replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument))
|
.replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument))
|
||||||
|
@ -47,7 +48,7 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn
|
||||||
private const val ARG_ARG = "arg"
|
private const val ARG_ARG = "arg"
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun newIntent(context: Context, kind: TimelineFragment.Kind,
|
fun newIntent(context: Context, kind: TimelineViewModel.Kind,
|
||||||
argument: String?): Intent {
|
argument: String?): Intent {
|
||||||
val intent = Intent(context, ModalTimelineActivity::class.java)
|
val intent = Intent(context, ModalTimelineActivity::class.java)
|
||||||
intent.putExtra(ARG_KIND, kind)
|
intent.putExtra(ARG_KIND, kind)
|
||||||
|
|
|
@ -21,8 +21,8 @@ import android.os.Bundle
|
||||||
import androidx.fragment.app.commit
|
import androidx.fragment.app.commit
|
||||||
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
|
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
|
||||||
|
|
||||||
import com.keylesspalace.tusky.fragment.TimelineFragment
|
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||||
import com.keylesspalace.tusky.fragment.TimelineFragment.Kind
|
import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Kind
|
||||||
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,8 @@ import androidx.annotation.StringRes
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||||
import com.keylesspalace.tusky.fragment.TimelineFragment
|
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||||
|
import com.keylesspalace.tusky.components.timeline.TimelineViewModel
|
||||||
|
|
||||||
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
||||||
|
|
||||||
|
@ -47,7 +48,7 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
|
||||||
HOME,
|
HOME,
|
||||||
R.string.title_home,
|
R.string.title_home,
|
||||||
R.drawable.ic_home_24dp,
|
R.drawable.ic_home_24dp,
|
||||||
{ TimelineFragment.newInstance(TimelineFragment.Kind.HOME) }
|
{ TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) }
|
||||||
)
|
)
|
||||||
NOTIFICATIONS -> TabData(
|
NOTIFICATIONS -> TabData(
|
||||||
NOTIFICATIONS,
|
NOTIFICATIONS,
|
||||||
|
@ -59,13 +60,13 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
|
||||||
LOCAL,
|
LOCAL,
|
||||||
R.string.title_public_local,
|
R.string.title_public_local,
|
||||||
R.drawable.ic_local_24dp,
|
R.drawable.ic_local_24dp,
|
||||||
{ TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL) }
|
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) }
|
||||||
)
|
)
|
||||||
FEDERATED -> TabData(
|
FEDERATED -> TabData(
|
||||||
FEDERATED,
|
FEDERATED,
|
||||||
R.string.title_public_federated,
|
R.string.title_public_federated,
|
||||||
R.drawable.ic_public_24dp,
|
R.drawable.ic_public_24dp,
|
||||||
{ TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) }
|
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) }
|
||||||
)
|
)
|
||||||
DIRECT -> TabData(
|
DIRECT -> TabData(
|
||||||
DIRECT,
|
DIRECT,
|
||||||
|
@ -85,7 +86,7 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
|
||||||
LIST,
|
LIST,
|
||||||
R.string.list,
|
R.string.list,
|
||||||
R.drawable.ic_list,
|
R.drawable.ic_list,
|
||||||
{ args -> TimelineFragment.newInstance(TimelineFragment.Kind.LIST, args.getOrNull(0).orEmpty()) },
|
{ args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) },
|
||||||
arguments,
|
arguments,
|
||||||
{ arguments.getOrNull(1).orEmpty() }
|
{ arguments.getOrNull(1).orEmpty() }
|
||||||
)
|
)
|
||||||
|
|
|
@ -25,7 +25,7 @@ import androidx.appcompat.widget.Toolbar;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
import androidx.fragment.app.FragmentTransaction;
|
import androidx.fragment.app.FragmentTransaction;
|
||||||
|
|
||||||
import com.keylesspalace.tusky.fragment.TimelineFragment;
|
import com.keylesspalace.tusky.components.timeline.TimelineFragment;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,7 @@ import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
|
||||||
import com.keylesspalace.tusky.entity.Account;
|
import com.keylesspalace.tusky.entity.Account;
|
||||||
import com.keylesspalace.tusky.entity.Emoji;
|
import com.keylesspalace.tusky.entity.Emoji;
|
||||||
import com.keylesspalace.tusky.entity.Notification;
|
import com.keylesspalace.tusky.entity.Notification;
|
||||||
|
import com.keylesspalace.tusky.entity.Status;
|
||||||
import com.keylesspalace.tusky.interfaces.AccountActionListener;
|
import com.keylesspalace.tusky.interfaces.AccountActionListener;
|
||||||
import com.keylesspalace.tusky.interfaces.LinkListener;
|
import com.keylesspalace.tusky.interfaces.LinkListener;
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||||
|
@ -195,14 +196,15 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
||||||
} else {
|
} else {
|
||||||
holder.showNotificationContent(true);
|
holder.showNotificationContent(true);
|
||||||
|
|
||||||
holder.setDisplayName(statusViewData.getUserFullName(), statusViewData.getAccountEmojis());
|
Status status = statusViewData.getActionable();
|
||||||
holder.setUsername(statusViewData.getNickname());
|
holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis());
|
||||||
holder.setCreatedAt(statusViewData.getCreatedAt());
|
holder.setUsername(status.getAccount().getUsername());
|
||||||
|
holder.setCreatedAt(status.getCreatedAt());
|
||||||
|
|
||||||
if(concreteNotificaton.getType() == Notification.Type.STATUS) {
|
if (concreteNotificaton.getType() == Notification.Type.STATUS) {
|
||||||
holder.setAvatar(statusViewData.getAvatar(), statusViewData.isBot());
|
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
|
||||||
} else {
|
} else {
|
||||||
holder.setAvatars(statusViewData.getAvatar(),
|
holder.setAvatars(status.getAccount().getAvatar(),
|
||||||
concreteNotificaton.getAccount().getAvatar());
|
concreteNotificaton.getAccount().getAvatar());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -215,7 +217,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
||||||
if (payloadForHolder instanceof List)
|
if (payloadForHolder instanceof List)
|
||||||
for (Object item : (List) payloadForHolder) {
|
for (Object item : (List) payloadForHolder) {
|
||||||
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
|
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
|
||||||
holder.setCreatedAt(statusViewData.getCreatedAt());
|
holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -386,7 +388,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
||||||
private StatusViewData.Concrete statusViewData;
|
private StatusViewData.Concrete statusViewData;
|
||||||
private SimpleDateFormat shortSdf;
|
private SimpleDateFormat shortSdf;
|
||||||
private SimpleDateFormat longSdf;
|
private SimpleDateFormat longSdf;
|
||||||
|
|
||||||
private int avatarRadius48dp;
|
private int avatarRadius48dp;
|
||||||
private int avatarRadius36dp;
|
private int avatarRadius36dp;
|
||||||
private int avatarRadius24dp;
|
private int avatarRadius24dp;
|
||||||
|
@ -415,7 +417,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
||||||
statusContent.setOnClickListener(this);
|
statusContent.setOnClickListener(this);
|
||||||
shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
|
shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
|
||||||
longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault());
|
longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault());
|
||||||
|
|
||||||
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
|
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
|
||||||
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
||||||
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
||||||
|
@ -531,7 +533,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
||||||
message.setText(emojifiedText);
|
message.setText(emojifiedText);
|
||||||
|
|
||||||
if (statusViewData != null) {
|
if (statusViewData != null) {
|
||||||
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText());
|
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText());
|
||||||
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
||||||
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
||||||
if (statusViewData.isExpanded()) {
|
if (statusViewData.isExpanded()) {
|
||||||
|
@ -586,7 +588,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
||||||
|
|
||||||
notificationAvatar.setVisibility(View.VISIBLE);
|
notificationAvatar.setVisibility(View.VISIBLE);
|
||||||
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
|
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
|
||||||
avatarRadius24dp, statusDisplayOptions.animateAvatars());
|
avatarRadius24dp, statusDisplayOptions.animateAvatars());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -607,7 +609,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
||||||
private void setupContentAndSpoiler(final LinkListener listener) {
|
private void setupContentAndSpoiler(final LinkListener listener) {
|
||||||
|
|
||||||
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
|
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
|
||||||
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText());
|
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
|
||||||
if (!shouldShowContentIfSpoiler && hasSpoiler) {
|
if (!shouldShowContentIfSpoiler && hasSpoiler) {
|
||||||
statusContent.setVisibility(View.GONE);
|
statusContent.setVisibility(View.GONE);
|
||||||
} else {
|
} else {
|
||||||
|
@ -615,7 +617,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
Spanned content = statusViewData.getContent();
|
Spanned content = statusViewData.getContent();
|
||||||
List<Emoji> emojis = statusViewData.getStatusEmojis();
|
List<Emoji> emojis = statusViewData.getActionable().getEmojis();
|
||||||
|
|
||||||
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
|
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
|
||||||
contentCollapseButton.setOnClickListener(view -> {
|
contentCollapseButton.setOnClickListener(view -> {
|
||||||
|
@ -641,13 +643,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
||||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
||||||
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
|
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
|
||||||
);
|
);
|
||||||
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener);
|
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), listener);
|
||||||
|
|
||||||
CharSequence emojifiedContentWarning;
|
CharSequence emojifiedContentWarning;
|
||||||
if (statusViewData.getSpoilerText() != null) {
|
if (statusViewData.getSpoilerText() != null) {
|
||||||
emojifiedContentWarning = CustomEmojiHelper.emojify(
|
emojifiedContentWarning = CustomEmojiHelper.emojify(
|
||||||
statusViewData.getSpoilerText(),
|
statusViewData.getSpoilerText(),
|
||||||
statusViewData.getStatusEmojis(),
|
statusViewData.getActionable().getEmojis(),
|
||||||
contentWarningDescriptionTextView,
|
contentWarningDescriptionTextView,
|
||||||
statusDisplayOptions.animateEmojis()
|
statusDisplayOptions.animateEmojis()
|
||||||
);
|
);
|
||||||
|
|
|
@ -28,7 +28,7 @@ public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
|
||||||
private Button loadMoreButton;
|
private Button loadMoreButton;
|
||||||
private ProgressBar progressBar;
|
private ProgressBar progressBar;
|
||||||
|
|
||||||
PlaceholderViewHolder(View itemView) {
|
public PlaceholderViewHolder(View itemView) {
|
||||||
super(itemView);
|
super(itemView);
|
||||||
loadMoreButton = itemView.findViewById(R.id.button_load_more);
|
loadMoreButton = itemView.findViewById(R.id.button_load_more);
|
||||||
progressBar = itemView.findViewById(R.id.progressBar);
|
progressBar = itemView.findViewById(R.id.progressBar);
|
||||||
|
|
|
@ -201,7 +201,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
protected void setSpoilerAndContent(boolean expanded,
|
protected void setSpoilerAndContent(boolean expanded,
|
||||||
@NonNull Spanned content,
|
@NonNull Spanned content,
|
||||||
@Nullable String spoilerText,
|
@Nullable String spoilerText,
|
||||||
@Nullable Status.Mention[] mentions,
|
@Nullable List<Status.Mention> mentions,
|
||||||
@NonNull List<Emoji> emojis,
|
@NonNull List<Emoji> emojis,
|
||||||
@Nullable PollViewData poll,
|
@Nullable PollViewData poll,
|
||||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||||
|
@ -243,7 +243,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
private void setTextVisible(boolean sensitive,
|
private void setTextVisible(boolean sensitive,
|
||||||
boolean expanded,
|
boolean expanded,
|
||||||
Spanned content,
|
Spanned content,
|
||||||
Status.Mention[] mentions,
|
List<Status.Mention> mentions,
|
||||||
List<Emoji> emojis,
|
List<Emoji> emojis,
|
||||||
@Nullable PollViewData poll,
|
@Nullable PollViewData poll,
|
||||||
StatusDisplayOptions statusDisplayOptions,
|
StatusDisplayOptions statusDisplayOptions,
|
||||||
|
@ -708,21 +708,23 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
this.setupWithStatus(status, listener, statusDisplayOptions, null);
|
this.setupWithStatus(status, listener, statusDisplayOptions, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void setupWithStatus(StatusViewData.Concrete status,
|
public void setupWithStatus(StatusViewData.Concrete status,
|
||||||
final StatusActionListener listener,
|
final StatusActionListener listener,
|
||||||
StatusDisplayOptions statusDisplayOptions,
|
StatusDisplayOptions statusDisplayOptions,
|
||||||
@Nullable Object payloads) {
|
@Nullable Object payloads) {
|
||||||
if (payloads == null) {
|
if (payloads == null) {
|
||||||
setDisplayName(status.getUserFullName(), status.getAccountEmojis(), statusDisplayOptions);
|
Status actionable = status.getActionable();
|
||||||
setUsername(status.getNickname());
|
setDisplayName(actionable.getAccount().getDisplayName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
|
||||||
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
setUsername(status.getUsername());
|
||||||
setIsReply(status.getInReplyToId() != null);
|
setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions);
|
||||||
setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), statusDisplayOptions);
|
setIsReply(actionable.getInReplyToId() != null);
|
||||||
setReblogged(status.isReblogged());
|
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
|
||||||
setFavourited(status.isFavourited());
|
actionable.getAccount().getBot(), statusDisplayOptions);
|
||||||
setBookmarked(status.isBookmarked());
|
setReblogged(actionable.getReblogged());
|
||||||
List<Attachment> attachments = status.getAttachments();
|
setFavourited(actionable.getFavourited());
|
||||||
boolean sensitive = status.isSensitive();
|
setBookmarked(actionable.getBookmarked());
|
||||||
|
List<Attachment> attachments = actionable.getAttachments();
|
||||||
|
boolean sensitive = actionable.getSensitive();
|
||||||
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
||||||
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
|
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
|
||||||
|
|
||||||
|
@ -747,11 +749,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions);
|
setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupButtons(listener, status.getSenderId(), status.getContent().toString(),
|
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
|
||||||
statusDisplayOptions);
|
statusDisplayOptions);
|
||||||
setRebloggingEnabled(status.getRebloggingEnabled(), status.getVisibility());
|
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
|
||||||
|
|
||||||
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), status.getPoll(), statusDisplayOptions, listener);
|
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(),
|
||||||
|
actionable.getMentions(), actionable.getEmojis(),
|
||||||
|
PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions,
|
||||||
|
listener);
|
||||||
|
|
||||||
setDescriptionForStatus(status, statusDisplayOptions);
|
setDescriptionForStatus(status, statusDisplayOptions);
|
||||||
|
|
||||||
|
@ -765,7 +770,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
if (payloads instanceof List)
|
if (payloads instanceof List)
|
||||||
for (Object item : (List<?>) payloads) {
|
for (Object item : (List<?>) payloads) {
|
||||||
if (Key.KEY_CREATED.equals(item)) {
|
if (Key.KEY_CREATED.equals(item)) {
|
||||||
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
setCreatedAt(status.getActionable().getCreatedAt(), statusDisplayOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -784,21 +789,22 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status,
|
private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status,
|
||||||
StatusDisplayOptions statusDisplayOptions) {
|
StatusDisplayOptions statusDisplayOptions) {
|
||||||
Context context = itemView.getContext();
|
Context context = itemView.getContext();
|
||||||
|
Status actionable = status.getActionable();
|
||||||
|
|
||||||
String description = context.getString(R.string.description_status,
|
String description = context.getString(R.string.description_status,
|
||||||
status.getUserFullName(),
|
actionable.getAccount().getDisplayName(),
|
||||||
getContentWarningDescription(context, status),
|
getContentWarningDescription(context, status),
|
||||||
(TextUtils.isEmpty(status.getSpoilerText()) || !status.isSensitive() || status.isExpanded() ? status.getContent() : ""),
|
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
|
||||||
getCreatedAtDescription(status.getCreatedAt(), statusDisplayOptions),
|
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
|
||||||
getReblogDescription(context, status),
|
getReblogDescription(context, status),
|
||||||
status.getNickname(),
|
status.getUsername(),
|
||||||
status.isReblogged() ? context.getString(R.string.description_status_reblogged) : "",
|
actionable.getReblogged() ? context.getString(R.string.description_status_reblogged) : "",
|
||||||
status.isFavourited() ? context.getString(R.string.description_status_favourited) : "",
|
actionable.getFavourited() ? context.getString(R.string.description_status_favourited) : "",
|
||||||
status.isBookmarked() ? context.getString(R.string.description_status_bookmarked) : "",
|
actionable.getBookmarked() ? context.getString(R.string.description_status_bookmarked) : "",
|
||||||
getMediaDescription(context, status),
|
getMediaDescription(context, status),
|
||||||
getVisibilityDescription(context, status.getVisibility()),
|
getVisibilityDescription(context, actionable.getVisibility()),
|
||||||
getFavsText(context, status.getFavouritesCount()),
|
getFavsText(context, actionable.getFavouritesCount()),
|
||||||
getReblogsText(context, status.getReblogsCount()),
|
getReblogsText(context, actionable.getReblogsCount()),
|
||||||
getPollDescription(status, context, statusDisplayOptions)
|
getPollDescription(status, context, statusDisplayOptions)
|
||||||
);
|
);
|
||||||
itemView.setContentDescription(description);
|
itemView.setContentDescription(description);
|
||||||
|
@ -806,10 +812,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
|
||||||
private static CharSequence getReblogDescription(Context context,
|
private static CharSequence getReblogDescription(Context context,
|
||||||
@NonNull StatusViewData.Concrete status) {
|
@NonNull StatusViewData.Concrete status) {
|
||||||
String rebloggedUsername = status.getRebloggedByUsername();
|
Status reblog = status.getRebloggingStatus();
|
||||||
if (rebloggedUsername != null) {
|
if (reblog != null) {
|
||||||
return context
|
return context
|
||||||
.getString(R.string.status_boosted_format, rebloggedUsername);
|
.getString(R.string.status_boosted_format, reblog.getAccount().getUsername());
|
||||||
} else {
|
} else {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
@ -817,11 +823,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
|
||||||
private static CharSequence getMediaDescription(Context context,
|
private static CharSequence getMediaDescription(Context context,
|
||||||
@NonNull StatusViewData.Concrete status) {
|
@NonNull StatusViewData.Concrete status) {
|
||||||
if (status.getAttachments().isEmpty()) {
|
if (status.getActionable().getAttachments().isEmpty()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
StringBuilder mediaDescriptions = CollectionsKt.fold(
|
StringBuilder mediaDescriptions = CollectionsKt.fold(
|
||||||
status.getAttachments(),
|
status.getActionable().getAttachments(),
|
||||||
new StringBuilder(),
|
new StringBuilder(),
|
||||||
(builder, a) -> {
|
(builder, a) -> {
|
||||||
if (a.getDescription() == null) {
|
if (a.getDescription() == null) {
|
||||||
|
@ -874,7 +880,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status,
|
private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status,
|
||||||
Context context,
|
Context context,
|
||||||
StatusDisplayOptions statusDisplayOptions) {
|
StatusDisplayOptions statusDisplayOptions) {
|
||||||
PollViewData poll = status.getPoll();
|
PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll());
|
||||||
if (poll == null) {
|
if (poll == null) {
|
||||||
return "";
|
return "";
|
||||||
} else {
|
} else {
|
||||||
|
@ -980,7 +986,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
StatusDisplayOptions statusDisplayOptions,
|
StatusDisplayOptions statusDisplayOptions,
|
||||||
Context context) {
|
Context context) {
|
||||||
String votesText;
|
String votesText;
|
||||||
if(poll.getVotersCount() == null) {
|
if (poll.getVotersCount() == null) {
|
||||||
String voters = numberFormat.format(poll.getVotesCount());
|
String voters = numberFormat.format(poll.getVotesCount());
|
||||||
votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters);
|
votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters);
|
||||||
} else {
|
} else {
|
||||||
|
@ -1004,12 +1010,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode, StatusDisplayOptions statusDisplayOptions) {
|
protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode, StatusDisplayOptions statusDisplayOptions) {
|
||||||
|
final Card card = status.getActionable().getCard();
|
||||||
if (cardViewMode != CardViewMode.NONE &&
|
if (cardViewMode != CardViewMode.NONE &&
|
||||||
status.getAttachments().size() == 0 &&
|
status.getActionable().getAttachments().size() == 0 &&
|
||||||
status.getCard() != null &&
|
card != null &&
|
||||||
!TextUtils.isEmpty(status.getCard().getUrl()) &&
|
!TextUtils.isEmpty(card.getUrl()) &&
|
||||||
(!status.isCollapsible() || !status.isCollapsed())) {
|
(!status.isCollapsible() || !status.isCollapsed())) {
|
||||||
final Card card = status.getCard();
|
|
||||||
cardView.setVisibility(View.VISIBLE);
|
cardView.setVisibility(View.VISIBLE);
|
||||||
cardTitle.setText(card.getTitle());
|
cardTitle.setText(card.getTitle());
|
||||||
if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) {
|
if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) {
|
||||||
|
@ -1028,7 +1034,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
// Statuses from other activitypub sources can be marked sensitive even if there's no media,
|
// Statuses from other activitypub sources can be marked sensitive even if there's no media,
|
||||||
// so let's blur the preview in that case
|
// so let's blur the preview in that case
|
||||||
// If media previews are disabled, show placeholder for cards as well
|
// If media previews are disabled, show placeholder for cards as well
|
||||||
if (statusDisplayOptions.mediaPreviewEnabled() && !status.isSensitive() && !TextUtils.isEmpty(card.getImage())) {
|
if (statusDisplayOptions.mediaPreviewEnabled() && !status.getActionable().getSensitive() && !TextUtils.isEmpty(card.getImage())) {
|
||||||
|
|
||||||
int topLeftRadius = 0;
|
int topLeftRadius = 0;
|
||||||
int topRightRadius = 0;
|
int topRightRadius = 0;
|
||||||
|
|
|
@ -101,7 +101,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void setupWithStatus(final StatusViewData.Concrete status,
|
public void setupWithStatus(final StatusViewData.Concrete status,
|
||||||
final StatusActionListener listener,
|
final StatusActionListener listener,
|
||||||
StatusDisplayOptions statusDisplayOptions,
|
StatusDisplayOptions statusDisplayOptions,
|
||||||
@Nullable Object payloads) {
|
@Nullable Object payloads) {
|
||||||
|
@ -110,12 +110,13 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
||||||
if (payloads == null) {
|
if (payloads == null) {
|
||||||
|
|
||||||
if (!statusDisplayOptions.hideStats()) {
|
if (!statusDisplayOptions.hideStats()) {
|
||||||
setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener);
|
setReblogAndFavCount(status.getActionable().getReblogsCount(),
|
||||||
|
status.getActionable().getFavouritesCount(), listener);
|
||||||
} else {
|
} else {
|
||||||
hideQuantitativeStats();
|
hideQuantitativeStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
setApplication(status.getApplication());
|
setApplication(status.getActionable().getApplication());
|
||||||
|
|
||||||
View.OnLongClickListener longClickListener = view -> {
|
View.OnLongClickListener longClickListener = view -> {
|
||||||
TextView textView = (TextView) view;
|
TextView textView = (TextView) view;
|
||||||
|
@ -130,7 +131,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
||||||
|
|
||||||
content.setOnLongClickListener(longClickListener);
|
content.setOnLongClickListener(longClickListener);
|
||||||
contentWarningDescription.setOnLongClickListener(longClickListener);
|
contentWarningDescription.setOnLongClickListener(longClickListener);
|
||||||
setStatusVisibility(status.getVisibility());
|
setStatusVisibility(status.getActionable().getVisibility());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,8 @@ import androidx.annotation.Nullable;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
import com.keylesspalace.tusky.R;
|
import com.keylesspalace.tusky.R;
|
||||||
|
import com.keylesspalace.tusky.entity.Emoji;
|
||||||
|
import com.keylesspalace.tusky.entity.Status;
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||||
|
@ -33,6 +35,8 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||||
import com.keylesspalace.tusky.util.StringUtils;
|
import com.keylesspalace.tusky.util.StringUtils;
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import at.connyduck.sparkbutton.helpers.Utils;
|
import at.connyduck.sparkbutton.helpers.Utils;
|
||||||
|
|
||||||
public class StatusViewHolder extends StatusBaseViewHolder {
|
public class StatusViewHolder extends StatusBaseViewHolder {
|
||||||
|
@ -54,19 +58,21 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void setupWithStatus(StatusViewData.Concrete status,
|
public void setupWithStatus(StatusViewData.Concrete status,
|
||||||
final StatusActionListener listener,
|
final StatusActionListener listener,
|
||||||
StatusDisplayOptions statusDisplayOptions,
|
StatusDisplayOptions statusDisplayOptions,
|
||||||
@Nullable Object payloads) {
|
@Nullable Object payloads) {
|
||||||
if (payloads == null) {
|
if (payloads == null) {
|
||||||
|
|
||||||
setupCollapsedState(status, listener);
|
setupCollapsedState(status, listener);
|
||||||
|
|
||||||
String rebloggedByDisplayName = status.getRebloggedByUsername();
|
Status reblogging = status.getRebloggingStatus();
|
||||||
if (rebloggedByDisplayName == null) {
|
if (reblogging == null) {
|
||||||
hideStatusInfo();
|
hideStatusInfo();
|
||||||
} else {
|
} else {
|
||||||
setRebloggedByDisplayName(rebloggedByDisplayName, status, statusDisplayOptions);
|
String rebloggedByDisplayName = reblogging.getAccount().getDisplayName();
|
||||||
|
setRebloggedByDisplayName(rebloggedByDisplayName,
|
||||||
|
reblogging.getAccount().getEmojis(), statusDisplayOptions);
|
||||||
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
|
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,13 +82,13 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setRebloggedByDisplayName(final CharSequence name,
|
private void setRebloggedByDisplayName(final CharSequence name,
|
||||||
final StatusViewData.Concrete status,
|
final List<Emoji> accountEmoji,
|
||||||
final StatusDisplayOptions statusDisplayOptions) {
|
final StatusDisplayOptions statusDisplayOptions) {
|
||||||
Context context = statusInfo.getContext();
|
Context context = statusInfo.getContext();
|
||||||
CharSequence wrappedName = StringUtils.unicodeWrap(name);
|
CharSequence wrappedName = StringUtils.unicodeWrap(name);
|
||||||
CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName);
|
CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName);
|
||||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
||||||
boostedText, status.getRebloggedByAccountEmojis(), statusInfo, statusDisplayOptions.animateEmojis()
|
boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
|
||||||
);
|
);
|
||||||
statusInfo.setText(emojifiedText);
|
statusInfo.setText(emojifiedText);
|
||||||
statusInfo.setVisibility(View.VISIBLE);
|
statusInfo.setVisibility(View.VISIBLE);
|
||||||
|
|
|
@ -21,3 +21,4 @@ data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
|
||||||
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
|
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
|
||||||
data class DomainMuteEvent(val instance: String): Dispatchable
|
data class DomainMuteEvent(val instance: String): Dispatchable
|
||||||
data class AnnouncementReadEvent(val announcementId: String): Dispatchable
|
data class AnnouncementReadEvent(val announcementId: String): Dispatchable
|
||||||
|
data class PinEvent(val statusId: String, val pinned: Boolean): Dispatchable
|
||||||
|
|
|
@ -73,7 +73,7 @@ data class ConversationStatusEntity(
|
||||||
val sensitive: Boolean,
|
val sensitive: Boolean,
|
||||||
val spoilerText: String,
|
val spoilerText: String,
|
||||||
val attachments: ArrayList<Attachment>,
|
val attachments: ArrayList<Attachment>,
|
||||||
val mentions: Array<Status.Mention>,
|
val mentions: List<Status.Mention>,
|
||||||
val showingHiddenContent: Boolean,
|
val showingHiddenContent: Boolean,
|
||||||
val expanded: Boolean,
|
val expanded: Boolean,
|
||||||
val collapsible: Boolean,
|
val collapsible: Boolean,
|
||||||
|
@ -101,7 +101,7 @@ data class ConversationStatusEntity(
|
||||||
if (sensitive != other.sensitive) return false
|
if (sensitive != other.sensitive) return false
|
||||||
if (spoilerText != other.spoilerText) return false
|
if (spoilerText != other.spoilerText) return false
|
||||||
if (attachments != other.attachments) return false
|
if (attachments != other.attachments) return false
|
||||||
if (!mentions.contentEquals(other.mentions)) return false
|
if (mentions != other.mentions) return false
|
||||||
if (showingHiddenContent != other.showingHiddenContent) return false
|
if (showingHiddenContent != other.showingHiddenContent) return false
|
||||||
if (expanded != other.expanded) return false
|
if (expanded != other.expanded) return false
|
||||||
if (collapsible != other.collapsible) return false
|
if (collapsible != other.collapsible) return false
|
||||||
|
@ -125,7 +125,7 @@ data class ConversationStatusEntity(
|
||||||
result = 31 * result + sensitive.hashCode()
|
result = 31 * result + sensitive.hashCode()
|
||||||
result = 31 * result + spoilerText.hashCode()
|
result = 31 * result + spoilerText.hashCode()
|
||||||
result = 31 * result + attachments.hashCode()
|
result = 31 * result + attachments.hashCode()
|
||||||
result = 31 * result + mentions.contentHashCode()
|
result = 31 * result + mentions.hashCode()
|
||||||
result = 31 * result + showingHiddenContent.hashCode()
|
result = 31 * result + showingHiddenContent.hashCode()
|
||||||
result = 31 * result + expanded.hashCode()
|
result = 31 * result + expanded.hashCode()
|
||||||
result = 31 * result + collapsible.hashCode()
|
result = 31 * result + collapsible.hashCode()
|
||||||
|
|
|
@ -40,6 +40,7 @@ import com.keylesspalace.tusky.util.NetworkState
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
import com.keylesspalace.tusky.util.hide
|
import com.keylesspalace.tusky.util.hide
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
|
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
|
||||||
|
@ -132,13 +133,14 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
||||||
|
|
||||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
||||||
viewMedia(attachmentIndex, it.toStatus(), view)
|
viewMedia(attachmentIndex, AttachmentViewData.list(it.toStatus()), view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewThread(position: Int) {
|
override fun onViewThread(position: Int) {
|
||||||
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
|
||||||
viewThread(it.toStatus())
|
val status = it.toStatus()
|
||||||
|
viewThread(status.actionableId, status.actionableStatus.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,17 +15,20 @@ import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ConversationsViewModel @Inject constructor(
|
class ConversationsViewModel @Inject constructor(
|
||||||
private val repository: ConversationsRepository,
|
private val repository: ConversationsRepository,
|
||||||
private val timelineCases: TimelineCases,
|
private val timelineCases: TimelineCases,
|
||||||
private val database: AppDatabase,
|
private val database: AppDatabase,
|
||||||
private val accountManager: AccountManager
|
private val accountManager: AccountManager
|
||||||
) : RxAwareViewModel() {
|
) : RxAwareViewModel() {
|
||||||
|
|
||||||
private val repoResult = MutableLiveData<Listing<ConversationEntity>>()
|
private val repoResult = MutableLiveData<Listing<ConversationEntity>>()
|
||||||
|
|
||||||
val conversations: LiveData<PagedList<ConversationEntity>> = Transformations.switchMap(repoResult) { it.pagedList }
|
val conversations: LiveData<PagedList<ConversationEntity>> =
|
||||||
val networkState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.networkState }
|
Transformations.switchMap(repoResult) { it.pagedList }
|
||||||
val refreshState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.refreshState }
|
val networkState: LiveData<NetworkState> =
|
||||||
|
Transformations.switchMap(repoResult) { it.networkState }
|
||||||
|
val refreshState: LiveData<NetworkState> =
|
||||||
|
Transformations.switchMap(repoResult) { it.refreshState }
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
val accountId = accountManager.activeAccount?.id ?: return
|
val accountId = accountManager.activeAccount?.id ?: return
|
||||||
|
@ -45,57 +48,76 @@ class ConversationsViewModel @Inject constructor(
|
||||||
|
|
||||||
fun favourite(favourite: Boolean, position: Int) {
|
fun favourite(favourite: Boolean, position: Int) {
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||||
timelineCases.favourite(conversation.lastStatus.toStatus(), favourite)
|
timelineCases.favourite(conversation.lastStatus.id, favourite)
|
||||||
.flatMap {
|
.flatMap {
|
||||||
val newConversation = conversation.copy(
|
val newConversation = conversation.copy(
|
||||||
lastStatus = conversation.lastStatus.copy(favourited = favourite)
|
lastStatus = conversation.lastStatus.copy(favourited = favourite)
|
||||||
)
|
)
|
||||||
|
|
||||||
database.conversationDao().insert(newConversation)
|
database.conversationDao().insert(newConversation)
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
|
.doOnError { t ->
|
||||||
.onErrorReturnItem(0)
|
Log.w(
|
||||||
.subscribe()
|
"ConversationViewModel",
|
||||||
.autoDispose()
|
"Failed to favourite conversation",
|
||||||
|
t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onErrorReturnItem(0)
|
||||||
|
.subscribe()
|
||||||
|
.autoDispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bookmark(bookmark: Boolean, position: Int) {
|
fun bookmark(bookmark: Boolean, position: Int) {
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||||
timelineCases.bookmark(conversation.lastStatus.toStatus(), bookmark)
|
timelineCases.bookmark(conversation.lastStatus.id, bookmark)
|
||||||
.flatMap {
|
.flatMap {
|
||||||
val newConversation = conversation.copy(
|
val newConversation = conversation.copy(
|
||||||
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
|
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
|
||||||
)
|
)
|
||||||
|
|
||||||
database.conversationDao().insert(newConversation)
|
database.conversationDao().insert(newConversation)
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.doOnError { t -> Log.w("ConversationViewModel", "Failed to bookmark conversation", t) }
|
.doOnError { t ->
|
||||||
.onErrorReturnItem(0)
|
Log.w(
|
||||||
.subscribe()
|
"ConversationViewModel",
|
||||||
.autoDispose()
|
"Failed to bookmark conversation",
|
||||||
|
t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onErrorReturnItem(0)
|
||||||
|
.subscribe()
|
||||||
|
.autoDispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun voteInPoll(position: Int, choices: MutableList<Int>) {
|
fun voteInPoll(position: Int, choices: MutableList<Int>) {
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||||
timelineCases.voteInPoll(conversation.lastStatus.toStatus(), choices)
|
val poll = conversation.lastStatus.poll ?: return
|
||||||
.flatMap { poll ->
|
timelineCases.voteInPoll(conversation.lastStatus.id, poll.id, choices)
|
||||||
val newConversation = conversation.copy(
|
.flatMap { newPoll ->
|
||||||
lastStatus = conversation.lastStatus.copy(poll = poll)
|
val newConversation = conversation.copy(
|
||||||
)
|
lastStatus = conversation.lastStatus.copy(poll = newPoll)
|
||||||
|
)
|
||||||
|
|
||||||
database.conversationDao().insert(newConversation)
|
database.conversationDao().insert(newConversation)
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
|
.doOnError { t ->
|
||||||
.onErrorReturnItem(0)
|
Log.w(
|
||||||
.subscribe()
|
"ConversationViewModel",
|
||||||
.autoDispose()
|
"Failed to favourite conversation",
|
||||||
|
t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onErrorReturnItem(0)
|
||||||
|
.subscribe()
|
||||||
|
.autoDispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -103,7 +125,7 @@ class ConversationsViewModel @Inject constructor(
|
||||||
fun expandHiddenStatus(expanded: Boolean, position: Int) {
|
fun expandHiddenStatus(expanded: Boolean, position: Int) {
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||||
val newConversation = conversation.copy(
|
val newConversation = conversation.copy(
|
||||||
lastStatus = conversation.lastStatus.copy(expanded = expanded)
|
lastStatus = conversation.lastStatus.copy(expanded = expanded)
|
||||||
)
|
)
|
||||||
saveConversationToDb(newConversation)
|
saveConversationToDb(newConversation)
|
||||||
}
|
}
|
||||||
|
@ -112,7 +134,7 @@ class ConversationsViewModel @Inject constructor(
|
||||||
fun collapseLongStatus(collapsed: Boolean, position: Int) {
|
fun collapseLongStatus(collapsed: Boolean, position: Int) {
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||||
val newConversation = conversation.copy(
|
val newConversation = conversation.copy(
|
||||||
lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
|
lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
|
||||||
)
|
)
|
||||||
saveConversationToDb(newConversation)
|
saveConversationToDb(newConversation)
|
||||||
}
|
}
|
||||||
|
@ -121,7 +143,7 @@ class ConversationsViewModel @Inject constructor(
|
||||||
fun showContent(showing: Boolean, position: Int) {
|
fun showContent(showing: Boolean, position: Int) {
|
||||||
conversations.value?.getOrNull(position)?.let { conversation ->
|
conversations.value?.getOrNull(position)?.let { conversation ->
|
||||||
val newConversation = conversation.copy(
|
val newConversation = conversation.copy(
|
||||||
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
|
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
|
||||||
)
|
)
|
||||||
saveConversationToDb(newConversation)
|
saveConversationToDb(newConversation)
|
||||||
}
|
}
|
||||||
|
@ -135,8 +157,8 @@ class ConversationsViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun saveConversationToDb(conversation: ConversationEntity) {
|
private fun saveConversationToDb(conversation: ConversationEntity) {
|
||||||
database.conversationDao().insert(conversation)
|
database.conversationDao().insert(conversation)
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.subscribe()
|
.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -316,7 +316,7 @@ public class NotificationHelper {
|
||||||
Status actionableStatus = status.getActionableStatus();
|
Status actionableStatus = status.getActionableStatus();
|
||||||
Status.Visibility replyVisibility = actionableStatus.getVisibility();
|
Status.Visibility replyVisibility = actionableStatus.getVisibility();
|
||||||
String contentWarning = actionableStatus.getSpoilerText();
|
String contentWarning = actionableStatus.getSpoilerText();
|
||||||
Status.Mention[] mentions = actionableStatus.getMentions();
|
List<Status.Mention> mentions = actionableStatus.getMentions();
|
||||||
List<String> mentionedUsernames = new ArrayList<>();
|
List<String> mentionedUsernames = new ArrayList<>();
|
||||||
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
|
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
|
||||||
for (Status.Mention mention : mentions) {
|
for (Status.Mention mention : mentions) {
|
||||||
|
@ -381,7 +381,6 @@ public class NotificationHelper {
|
||||||
|
|
||||||
NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
|
NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
|
||||||
|
|
||||||
//noinspection ConstantConditions
|
|
||||||
notificationManager.createNotificationChannelGroup(channelGroup);
|
notificationManager.createNotificationChannelGroup(channelGroup);
|
||||||
|
|
||||||
for (int i = 0; i < channelIds.length; i++) {
|
for (int i = 0; i < channelIds.length; i++) {
|
||||||
|
|
|
@ -118,7 +118,7 @@ class StatusViewHolder(
|
||||||
|
|
||||||
private fun setTextVisible(expanded: Boolean,
|
private fun setTextVisible(expanded: Boolean,
|
||||||
content: Spanned,
|
content: Spanned,
|
||||||
mentions: Array<Status.Mention>?,
|
mentions: List<Status.Mention>?,
|
||||||
emojis: List<Emoji>,
|
emojis: List<Emoji>,
|
||||||
listener: LinkListener) {
|
listener: LinkListener) {
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
|
|
|
@ -15,13 +15,12 @@ import com.keylesspalace.tusky.util.*
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
mastodonApi: MastodonApi,
|
mastodonApi: MastodonApi,
|
||||||
private val timelineCases: TimelineCases,
|
private val timelineCases: TimelineCases,
|
||||||
private val accountManager: AccountManager
|
private val accountManager: AccountManager
|
||||||
) : RxAwareViewModel() {
|
) : RxAwareViewModel() {
|
||||||
|
|
||||||
var currentQuery: String = ""
|
var currentQuery: String = ""
|
||||||
|
@ -36,93 +35,109 @@ class SearchViewModel @Inject constructor(
|
||||||
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
|
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
|
||||||
val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
|
val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
|
||||||
|
|
||||||
private val statusesRepository = SearchRepository<Pair<Status, StatusViewData.Concrete>>(mastodonApi)
|
private val statusesRepository =
|
||||||
|
SearchRepository<Pair<Status, StatusViewData.Concrete>>(mastodonApi)
|
||||||
private val accountsRepository = SearchRepository<Account>(mastodonApi)
|
private val accountsRepository = SearchRepository<Account>(mastodonApi)
|
||||||
private val hashtagsRepository = SearchRepository<HashTag>(mastodonApi)
|
private val hashtagsRepository = SearchRepository<HashTag>(mastodonApi)
|
||||||
|
|
||||||
private val repoResultStatus = MutableLiveData<Listing<Pair<Status, StatusViewData.Concrete>>>()
|
private val repoResultStatus = MutableLiveData<Listing<Pair<Status, StatusViewData.Concrete>>>()
|
||||||
val statuses: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>> = repoResultStatus.switchMap { it.pagedList }
|
val statuses: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>> =
|
||||||
|
repoResultStatus.switchMap { it.pagedList }
|
||||||
val networkStateStatus: LiveData<NetworkState> = repoResultStatus.switchMap { it.networkState }
|
val networkStateStatus: LiveData<NetworkState> = repoResultStatus.switchMap { it.networkState }
|
||||||
val networkStateStatusRefresh: LiveData<NetworkState> = repoResultStatus.switchMap { it.refreshState }
|
val networkStateStatusRefresh: LiveData<NetworkState> =
|
||||||
|
repoResultStatus.switchMap { it.refreshState }
|
||||||
|
|
||||||
private val repoResultAccount = MutableLiveData<Listing<Account>>()
|
private val repoResultAccount = MutableLiveData<Listing<Account>>()
|
||||||
val accounts: LiveData<PagedList<Account>> = repoResultAccount.switchMap { it.pagedList }
|
val accounts: LiveData<PagedList<Account>> = repoResultAccount.switchMap { it.pagedList }
|
||||||
val networkStateAccount: LiveData<NetworkState> = repoResultAccount.switchMap { it.networkState }
|
val networkStateAccount: LiveData<NetworkState> =
|
||||||
val networkStateAccountRefresh: LiveData<NetworkState> = repoResultAccount.switchMap { it.refreshState }
|
repoResultAccount.switchMap { it.networkState }
|
||||||
|
val networkStateAccountRefresh: LiveData<NetworkState> =
|
||||||
|
repoResultAccount.switchMap { it.refreshState }
|
||||||
|
|
||||||
private val repoResultHashTag = MutableLiveData<Listing<HashTag>>()
|
private val repoResultHashTag = MutableLiveData<Listing<HashTag>>()
|
||||||
val hashtags: LiveData<PagedList<HashTag>> = repoResultHashTag.switchMap { it.pagedList }
|
val hashtags: LiveData<PagedList<HashTag>> = repoResultHashTag.switchMap { it.pagedList }
|
||||||
val networkStateHashTag: LiveData<NetworkState> = repoResultHashTag.switchMap { it.networkState }
|
val networkStateHashTag: LiveData<NetworkState> =
|
||||||
val networkStateHashTagRefresh: LiveData<NetworkState> = repoResultHashTag.switchMap { it.refreshState }
|
repoResultHashTag.switchMap { it.networkState }
|
||||||
|
val networkStateHashTagRefresh: LiveData<NetworkState> =
|
||||||
|
repoResultHashTag.switchMap { it.refreshState }
|
||||||
|
|
||||||
private val loadedStatuses = ArrayList<Pair<Status, StatusViewData.Concrete>>()
|
private val loadedStatuses = ArrayList<Pair<Status, StatusViewData.Concrete>>()
|
||||||
fun search(query: String) {
|
fun search(query: String) {
|
||||||
loadedStatuses.clear()
|
loadedStatuses.clear()
|
||||||
repoResultStatus.value = statusesRepository.getSearchData(SearchType.Status, query, disposables, initialItems = loadedStatuses) {
|
repoResultStatus.value = statusesRepository.getSearchData(
|
||||||
it?.statuses?.map { status -> Pair(status, ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia, alwaysOpenSpoiler)!!) }
|
SearchType.Status,
|
||||||
.orEmpty()
|
query,
|
||||||
.apply {
|
disposables,
|
||||||
loadedStatuses.addAll(this)
|
initialItems = loadedStatuses
|
||||||
}
|
) {
|
||||||
}
|
it?.statuses?.map { status ->
|
||||||
repoResultAccount.value = accountsRepository.getSearchData(SearchType.Account, query, disposables) {
|
Pair(
|
||||||
it?.accounts.orEmpty()
|
status,
|
||||||
|
status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.orEmpty()
|
||||||
|
.apply {
|
||||||
|
loadedStatuses.addAll(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
repoResultAccount.value =
|
||||||
|
accountsRepository.getSearchData(SearchType.Account, query, disposables) {
|
||||||
|
it?.accounts.orEmpty()
|
||||||
|
}
|
||||||
val hashtagQuery = if (query.startsWith("#")) query else "#$query"
|
val hashtagQuery = if (query.startsWith("#")) query else "#$query"
|
||||||
repoResultHashTag.value =
|
repoResultHashTag.value =
|
||||||
hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) {
|
hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) {
|
||||||
it?.hashtags.orEmpty()
|
it?.hashtags.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
|
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
|
||||||
timelineCases.delete(status.first.id)
|
timelineCases.delete(status.first.id)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
if (loadedStatuses.remove(status))
|
if (loadedStatuses.remove(status))
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
}, {
|
}, { err ->
|
||||||
err -> Log.d(TAG, "Failed to delete status", err)
|
Log.d(TAG, "Failed to delete status", err)
|
||||||
})
|
})
|
||||||
.autoDispose()
|
.autoDispose()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
|
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
|
||||||
val idx = loadedStatuses.indexOf(status)
|
val idx = loadedStatuses.indexOf(status)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsExpanded(expanded).createStatusViewData())
|
val newPair = Pair(status.first, status.second.copy(isExpanded = expanded))
|
||||||
loadedStatuses[idx] = newPair
|
loadedStatuses[idx] = newPair
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reblog(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
|
fun reblog(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
|
||||||
timelineCases.reblog(status.first, reblog)
|
timelineCases.reblog(status.first.id, reblog)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(
|
.subscribe(
|
||||||
{ setRebloggedForStatus(status, reblog) },
|
{ setRebloggedForStatus(status, reblog) },
|
||||||
{ err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) }
|
{ err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) }
|
||||||
)
|
)
|
||||||
.autoDispose()
|
.autoDispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setRebloggedForStatus(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
|
private fun setRebloggedForStatus(
|
||||||
|
status: Pair<Status, StatusViewData.Concrete>,
|
||||||
|
reblog: Boolean
|
||||||
|
) {
|
||||||
status.first.reblogged = reblog
|
status.first.reblogged = reblog
|
||||||
status.first.reblog?.reblogged = reblog
|
status.first.reblog?.reblogged = reblog
|
||||||
|
|
||||||
val idx = loadedStatuses.indexOf(status)
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
if (idx >= 0) {
|
|
||||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setReblogged(reblog).createStatusViewData())
|
|
||||||
loadedStatuses[idx] = newPair
|
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
|
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
|
||||||
val idx = loadedStatuses.indexOf(status)
|
val idx = loadedStatuses.indexOf(status)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsShowingSensitiveContent(isShowing).createStatusViewData())
|
val newPair = Pair(status.first, status.second.copy(isShowingContent = isShowing))
|
||||||
loadedStatuses[idx] = newPair
|
loadedStatuses[idx] = newPair
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
}
|
}
|
||||||
|
@ -131,7 +146,7 @@ class SearchViewModel @Inject constructor(
|
||||||
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
|
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
|
||||||
val idx = loadedStatuses.indexOf(status)
|
val idx = loadedStatuses.indexOf(status)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setCollapsed(collapsed).createStatusViewData())
|
val newPair = Pair(status.first, status.second.copy(isCollapsed = collapsed))
|
||||||
loadedStatuses[idx] = newPair
|
loadedStatuses[idx] = newPair
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
}
|
}
|
||||||
|
@ -140,54 +155,46 @@ class SearchViewModel @Inject constructor(
|
||||||
fun voteInPoll(status: Pair<Status, StatusViewData.Concrete>, choices: MutableList<Int>) {
|
fun voteInPoll(status: Pair<Status, StatusViewData.Concrete>, choices: MutableList<Int>) {
|
||||||
val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices)
|
val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices)
|
||||||
updateStatus(status, votedPoll)
|
updateStatus(status, votedPoll)
|
||||||
timelineCases.voteInPoll(status.first, choices)
|
timelineCases.voteInPoll(status.first.id, votedPoll.id, choices)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(
|
.subscribe(
|
||||||
{ newPoll -> updateStatus(status, newPoll) },
|
{ newPoll -> updateStatus(status, newPoll) },
|
||||||
{ t ->
|
{ t ->
|
||||||
Log.d(TAG,
|
Log.d(
|
||||||
"Failed to vote in poll: ${status.first.id}", t)
|
TAG,
|
||||||
}
|
"Failed to vote in poll: ${status.first.id}", t
|
||||||
)
|
)
|
||||||
.autoDispose()
|
}
|
||||||
|
)
|
||||||
|
.autoDispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateStatus(status: Pair<Status, StatusViewData.Concrete>, newPoll: Poll) {
|
private fun updateStatus(status: Pair<Status, StatusViewData.Concrete>, newPoll: Poll) {
|
||||||
val idx = loadedStatuses.indexOf(status)
|
val idx = loadedStatuses.indexOf(status)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
|
val newStatus = status.first.copy(poll = newPoll)
|
||||||
val newViewData = StatusViewData.Builder(status.second)
|
val newViewData = status.second.copy(status = newStatus)
|
||||||
.setPoll(newPoll)
|
loadedStatuses[idx] = Pair(newStatus, newViewData)
|
||||||
.createStatusViewData()
|
|
||||||
loadedStatuses[idx] = Pair(status.first, newViewData)
|
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
|
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
|
||||||
val idx = loadedStatuses.indexOf(status)
|
status.first.favourited = isFavorited
|
||||||
if (idx >= 0) {
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setFavourited(isFavorited).createStatusViewData())
|
timelineCases.favourite(status.first.id, isFavorited)
|
||||||
loadedStatuses[idx] = newPair
|
.onErrorReturnItem(status.first)
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
.subscribe()
|
||||||
}
|
.autoDispose()
|
||||||
timelineCases.favourite(status.first, isFavorited)
|
|
||||||
.onErrorReturnItem(status.first)
|
|
||||||
.subscribe()
|
|
||||||
.autoDispose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) {
|
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) {
|
||||||
val idx = loadedStatuses.indexOf(status)
|
status.first.bookmarked = isBookmarked
|
||||||
if (idx >= 0) {
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setBookmarked(isBookmarked).createStatusViewData())
|
timelineCases.bookmark(status.first.id, isBookmarked)
|
||||||
loadedStatuses[idx] = newPair
|
.onErrorReturnItem(status.first)
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
.subscribe()
|
||||||
}
|
.autoDispose()
|
||||||
timelineCases.bookmark(status.first, isBookmarked)
|
|
||||||
.onErrorReturnItem(status.first)
|
|
||||||
.subscribe()
|
|
||||||
.autoDispose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAllAccountsOrderedByActive(): List<AccountEntity> {
|
fun getAllAccountsOrderedByActive(): List<AccountEntity> {
|
||||||
|
@ -199,7 +206,7 @@ class SearchViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pinAccount(status: Status, isPin: Boolean) {
|
fun pinAccount(status: Status, isPin: Boolean) {
|
||||||
timelineCases.pin(status, isPin)
|
timelineCases.pin(status.id, isPin)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun blockAccount(accountId: String) {
|
fun blockAccount(accountId: String) {
|
||||||
|
@ -217,14 +224,18 @@ class SearchViewModel @Inject constructor(
|
||||||
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) {
|
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) {
|
||||||
val idx = loadedStatuses.indexOf(status)
|
val idx = loadedStatuses.indexOf(status)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setMuted(mute).createStatusViewData())
|
val newStatus = status.first.copy(muted = mute)
|
||||||
|
val newPair = Pair(
|
||||||
|
newStatus,
|
||||||
|
status.second.copy(status = newStatus)
|
||||||
|
)
|
||||||
loadedStatuses[idx] = newPair
|
loadedStatuses[idx] = newPair
|
||||||
repoResultStatus.value?.refresh?.invoke()
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
}
|
}
|
||||||
timelineCases.muteConversation(status.first, mute)
|
timelineCases.muteConversation(status.first.id, mute)
|
||||||
.onErrorReturnItem(status.first)
|
.onErrorReturnItem(status.first)
|
||||||
.subscribe()
|
.subscribe()
|
||||||
.autoDispose()
|
.autoDispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
@ -52,7 +52,7 @@ class SearchStatusesAdapter(
|
||||||
|
|
||||||
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
|
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
|
||||||
override fun areContentsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
|
override fun areContentsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
|
||||||
oldItem.second.deepEquals(newItem.second)
|
oldItem.second == newItem.second
|
||||||
|
|
||||||
override fun areItemsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
|
override fun areItemsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
|
||||||
oldItem.second.id == newItem.second.id
|
oldItem.second.id == newItem.second.id
|
||||||
|
|
|
@ -383,7 +383,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun accountIsInMentions(account: AccountEntity?, mentions: Array<Mention>): Boolean {
|
private fun accountIsInMentions(account: AccountEntity?, mentions: List<Mention>): Boolean {
|
||||||
return mentions.firstOrNull {
|
return mentions.firstOrNull {
|
||||||
account?.username == it.username && account.domain == Uri.parse(it.url)?.host
|
account?.username == it.username && account.domain == Uri.parse(it.url)?.host
|
||||||
} != null
|
} != null
|
||||||
|
|
|
@ -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.adapter;
|
package com.keylesspalace.tusky.components.timeline;
|
||||||
|
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
@ -24,6 +24,8 @@ import androidx.annotation.Nullable;
|
||||||
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.PlaceholderViewHolder;
|
||||||
|
import com.keylesspalace.tusky.adapter.StatusViewHolder;
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
|
@ -0,0 +1,563 @@
|
||||||
|
/* 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.timeline
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.accessibility.AccessibilityManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.recyclerview.widget.*
|
||||||
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||||
|
import at.connyduck.sparkbutton.helpers.Utils
|
||||||
|
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.*
|
||||||
|
import autodispose2.androidx.lifecycle.autoDispose
|
||||||
|
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.adapter.StatusBaseViewHolder
|
||||||
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
|
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||||
|
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
||||||
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
|
import com.keylesspalace.tusky.fragment.SFragment
|
||||||
|
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||||
|
import com.keylesspalace.tusky.interfaces.RefreshableFragment
|
||||||
|
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||||
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
|
import com.keylesspalace.tusky.util.*
|
||||||
|
import com.keylesspalace.tusky.view.EndlessOnScrollListener
|
||||||
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class TimelineFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable,
|
||||||
|
ReselectableFragment, RefreshableFragment {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var eventHub: EventHub
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var accountManager: AccountManager
|
||||||
|
|
||||||
|
private val viewModel: TimelineViewModel by viewModels { viewModelFactory }
|
||||||
|
|
||||||
|
private val binding by viewBinding(FragmentTimelineBinding::bind)
|
||||||
|
|
||||||
|
private lateinit var adapter: TimelineAdapter
|
||||||
|
|
||||||
|
private var isSwipeToRefreshEnabled = true
|
||||||
|
|
||||||
|
private var eventRegistered = false
|
||||||
|
|
||||||
|
private var layoutManager: LinearLayoutManager? = null
|
||||||
|
private var scrollListener: EndlessOnScrollListener? = null
|
||||||
|
private var hideFab = false
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val arguments = requireArguments()
|
||||||
|
val kind = TimelineViewModel.Kind.valueOf(arguments.getString(KIND_ARG)!!)
|
||||||
|
val id: String? = if (kind == TimelineViewModel.Kind.USER ||
|
||||||
|
kind == TimelineViewModel.Kind.USER_PINNED ||
|
||||||
|
kind == TimelineViewModel.Kind.USER_WITH_REPLIES ||
|
||||||
|
kind == TimelineViewModel.Kind.LIST
|
||||||
|
) {
|
||||||
|
arguments.getString(ID_ARG)!!
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val tags = if (kind == TimelineViewModel.Kind.TAG) {
|
||||||
|
arguments.getStringArrayList(HASHTAGS_ARG)!!
|
||||||
|
} else {
|
||||||
|
listOf()
|
||||||
|
}
|
||||||
|
viewModel.init(
|
||||||
|
kind,
|
||||||
|
id,
|
||||||
|
tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.viewUpdates
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.autoDispose(this)
|
||||||
|
.subscribe { this.updateViews() }
|
||||||
|
|
||||||
|
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
|
||||||
|
|
||||||
|
val preferences = PreferenceManager.getDefaultSharedPreferences(activity)
|
||||||
|
val statusDisplayOptions = StatusDisplayOptions(
|
||||||
|
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||||
|
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
|
||||||
|
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
|
||||||
|
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
|
||||||
|
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
|
||||||
|
cardViewMode = if (preferences.getBoolean(
|
||||||
|
PrefKeys.SHOW_CARDS_IN_TIMELINES,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
) CardViewMode.INDENTED else CardViewMode.NONE,
|
||||||
|
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
|
||||||
|
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||||
|
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||||
|
)
|
||||||
|
adapter = TimelineAdapter(
|
||||||
|
dataSource,
|
||||||
|
statusDisplayOptions,
|
||||||
|
this
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
setupSwipeRefreshLayout()
|
||||||
|
setupRecyclerView()
|
||||||
|
updateViews()
|
||||||
|
viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupSwipeRefreshLayout() {
|
||||||
|
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
|
||||||
|
binding.swipeRefreshLayout.setOnRefreshListener(this)
|
||||||
|
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupRecyclerView() {
|
||||||
|
binding.recyclerView.setAccessibilityDelegateCompat(
|
||||||
|
ListStatusAccessibilityDelegate(binding.recyclerView, this)
|
||||||
|
{ pos -> viewModel.statuses.getOrNull(pos) }
|
||||||
|
)
|
||||||
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
binding.recyclerView.layoutManager = layoutManager
|
||||||
|
val divider = DividerItemDecoration(context, RecyclerView.VERTICAL)
|
||||||
|
binding.recyclerView.addItemDecoration(divider)
|
||||||
|
|
||||||
|
// CWs are expanded without animation, buttons animate itself, we don't need it basically
|
||||||
|
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showEmptyView() {
|
||||||
|
binding.statusView.show()
|
||||||
|
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||||
|
super.onActivityCreated(savedInstanceState)
|
||||||
|
|
||||||
|
/* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't
|
||||||
|
* guaranteed to be set until then. */
|
||||||
|
scrollListener = if (actionButtonPresent()) {
|
||||||
|
/* Use a modified scroll listener that both loads more statuses as it goes, and hides
|
||||||
|
* the follow button on down-scroll. */
|
||||||
|
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
hideFab = preferences.getBoolean("fabHide", false)
|
||||||
|
object : EndlessOnScrollListener(layoutManager) {
|
||||||
|
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||||
|
super.onScrolled(view, dx, dy)
|
||||||
|
val composeButton = (activity as ActionButtonActivity).actionButton
|
||||||
|
if (composeButton != null) {
|
||||||
|
if (hideFab) {
|
||||||
|
if (dy > 0 && composeButton.isShown) {
|
||||||
|
composeButton.hide() // hides the button if we're scrolling down
|
||||||
|
} else if (dy < 0 && !composeButton.isShown) {
|
||||||
|
composeButton.show() // shows it if we are scrolling up
|
||||||
|
}
|
||||||
|
} else if (!composeButton.isShown) {
|
||||||
|
composeButton.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
|
||||||
|
this@TimelineFragment.onLoadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Just use the basic scroll listener to load more statuses.
|
||||||
|
object : EndlessOnScrollListener(layoutManager) {
|
||||||
|
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
|
||||||
|
this@TimelineFragment.onLoadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.also {
|
||||||
|
binding.recyclerView.addOnScrollListener(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventRegistered) {
|
||||||
|
eventHub.events
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||||
|
.subscribe { event ->
|
||||||
|
when (event) {
|
||||||
|
is PreferenceChangedEvent -> {
|
||||||
|
onPreferenceChanged(event.preferenceKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventRegistered = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRefresh() {
|
||||||
|
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
|
||||||
|
binding.statusView.hide()
|
||||||
|
|
||||||
|
viewModel.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReply(position: Int) {
|
||||||
|
val status = viewModel.statuses[position].asStatusOrNull() ?: return
|
||||||
|
super.reply(status.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReblog(reblog: Boolean, position: Int) {
|
||||||
|
viewModel.reblog(reblog, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||||
|
viewModel.favorite(favourite, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBookmark(bookmark: Boolean, position: Int) {
|
||||||
|
viewModel.bookmark(bookmark, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVoteInPoll(position: Int, choices: List<Int>) {
|
||||||
|
viewModel.voteInPoll(position, choices)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMore(view: View, position: Int) {
|
||||||
|
val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return
|
||||||
|
super.more(status, view, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOpenReblog(position: Int) {
|
||||||
|
val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return
|
||||||
|
super.openReblog(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||||
|
viewModel.changeExpanded(expanded, position)
|
||||||
|
updateViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
||||||
|
viewModel.changeContentHidden(isShowing, position)
|
||||||
|
updateViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShowReblogs(position: Int) {
|
||||||
|
val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return
|
||||||
|
val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId)
|
||||||
|
(activity as BaseActivity).startActivityWithSlideInAnimation(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShowFavs(position: Int) {
|
||||||
|
val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return
|
||||||
|
val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId)
|
||||||
|
(activity as BaseActivity).startActivityWithSlideInAnimation(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadMore(position: Int) {
|
||||||
|
viewModel.loadGap(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||||
|
viewModel.changeContentCollapsed(isCollapsed, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||||
|
val status = viewModel.statuses[position].asStatusOrNull() ?: return
|
||||||
|
super.viewMedia(
|
||||||
|
attachmentIndex,
|
||||||
|
AttachmentViewData.list(status.actionable),
|
||||||
|
view
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewThread(position: Int) {
|
||||||
|
val status = viewModel.statuses[position].asStatusOrNull() ?: return
|
||||||
|
super.viewThread(status.actionable.id, status.actionable.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewTag(tag: String) {
|
||||||
|
if (viewModel.kind == TimelineViewModel.Kind.TAG && viewModel.tags.size == 1 &&
|
||||||
|
viewModel.tags.contains(tag)
|
||||||
|
) {
|
||||||
|
// If already viewing a tag page, then ignore any request to view that tag again.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.viewTag(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewAccount(id: String) {
|
||||||
|
if ((viewModel.kind == TimelineViewModel.Kind.USER ||
|
||||||
|
viewModel.kind == TimelineViewModel.Kind.USER_WITH_REPLIES) &&
|
||||||
|
viewModel.id == id
|
||||||
|
) {
|
||||||
|
/* If already viewing an account page, then any requests to view that account page
|
||||||
|
* should be ignored. */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.viewAccount(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPreferenceChanged(key: String) {
|
||||||
|
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
when (key) {
|
||||||
|
PrefKeys.FAB_HIDE -> {
|
||||||
|
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||||
|
}
|
||||||
|
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
||||||
|
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
||||||
|
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
||||||
|
if (enabled != oldMediaPreviewEnabled) {
|
||||||
|
adapter.mediaPreviewEnabled = enabled
|
||||||
|
updateViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun removeItem(position: Int) {
|
||||||
|
viewModel.statuses.removeAt(position)
|
||||||
|
updateViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLoadMore() {
|
||||||
|
viewModel.loadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun actionButtonPresent(): Boolean {
|
||||||
|
return viewModel.kind != TimelineViewModel.Kind.TAG &&
|
||||||
|
viewModel.kind != TimelineViewModel.Kind.FAVOURITES &&
|
||||||
|
viewModel.kind != TimelineViewModel.Kind.BOOKMARKS &&
|
||||||
|
activity is ActionButtonActivity
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateViews() {
|
||||||
|
differ.submitList(viewModel.statuses.toList())
|
||||||
|
binding.swipeRefreshLayout.isEnabled = viewModel.failure == null
|
||||||
|
|
||||||
|
if (isAdded) {
|
||||||
|
binding.swipeRefreshLayout.isRefreshing = viewModel.isRefreshing
|
||||||
|
binding.progressBar.visible(viewModel.isLoadingInitially)
|
||||||
|
if (viewModel.failure == null && viewModel.statuses.isEmpty() && !viewModel.isLoadingInitially) {
|
||||||
|
showEmptyView()
|
||||||
|
} else {
|
||||||
|
when (viewModel.failure) {
|
||||||
|
TimelineViewModel.FailureReason.NETWORK -> {
|
||||||
|
binding.statusView.show()
|
||||||
|
binding.statusView.setup(
|
||||||
|
R.drawable.elephant_offline,
|
||||||
|
R.string.error_network
|
||||||
|
) {
|
||||||
|
binding.statusView.hide()
|
||||||
|
viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TimelineViewModel.FailureReason.OTHER -> {
|
||||||
|
binding.statusView.show()
|
||||||
|
binding.statusView.setup(
|
||||||
|
R.drawable.elephant_error,
|
||||||
|
R.string.error_generic
|
||||||
|
) {
|
||||||
|
binding.statusView.hide()
|
||||||
|
viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null -> binding.statusView.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val listUpdateCallback: ListUpdateCallback = object : ListUpdateCallback {
|
||||||
|
override fun onInserted(position: Int, count: Int) {
|
||||||
|
if (isAdded) {
|
||||||
|
adapter.notifyItemRangeInserted(position, count)
|
||||||
|
val context = context
|
||||||
|
// scroll up when new items at the top are loaded while being in the first position
|
||||||
|
// https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724
|
||||||
|
if (position == 0 && context != null && adapter.itemCount != count) {
|
||||||
|
if (isSwipeToRefreshEnabled) {
|
||||||
|
binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30))
|
||||||
|
} else binding.recyclerView.scrollToPosition(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemoved(position: Int, count: Int) {
|
||||||
|
adapter.notifyItemRangeRemoved(position, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||||
|
adapter.notifyItemMoved(fromPosition, toPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||||
|
adapter.notifyItemRangeChanged(position, count, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val differ = AsyncListDiffer(
|
||||||
|
listUpdateCallback,
|
||||||
|
AsyncDifferConfig.Builder(diffCallback).build()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val dataSource: TimelineAdapter.AdapterDataSource<StatusViewData> =
|
||||||
|
object : TimelineAdapter.AdapterDataSource<StatusViewData> {
|
||||||
|
override fun getItemCount(): Int {
|
||||||
|
return differ.currentList.size
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemAt(pos: Int): StatusViewData {
|
||||||
|
return differ.currentList[pos]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var talkBackWasEnabled = false
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
val a11yManager =
|
||||||
|
ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java)
|
||||||
|
|
||||||
|
val wasEnabled = talkBackWasEnabled
|
||||||
|
talkBackWasEnabled = a11yManager?.isEnabled == true
|
||||||
|
Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled")
|
||||||
|
if (talkBackWasEnabled && !wasEnabled) {
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
startUpdateTimestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start to update adapter every minute to refresh timestamp
|
||||||
|
* If setting absoluteTimeView is false
|
||||||
|
* Auto dispose observable on pause
|
||||||
|
*/
|
||||||
|
private fun startUpdateTimestamp() {
|
||||||
|
val preferences = PreferenceManager.getDefaultSharedPreferences(activity)
|
||||||
|
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
||||||
|
if (!useAbsoluteTime) {
|
||||||
|
Observable.interval(1, TimeUnit.MINUTES)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
|
||||||
|
.subscribe { updateViews() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReselect() {
|
||||||
|
if (isAdded) {
|
||||||
|
layoutManager!!.scrollToPosition(0)
|
||||||
|
binding.recyclerView.stopScroll()
|
||||||
|
scrollListener!!.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun refreshContent() {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TimelineF" // logging tag
|
||||||
|
private const val KIND_ARG = "kind"
|
||||||
|
private const val ID_ARG = "id"
|
||||||
|
private const val HASHTAGS_ARG = "hashtags"
|
||||||
|
private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh"
|
||||||
|
|
||||||
|
|
||||||
|
fun newInstance(
|
||||||
|
kind: TimelineViewModel.Kind,
|
||||||
|
hashtagOrId: String? = null,
|
||||||
|
enableSwipeToRefresh: Boolean = true
|
||||||
|
): TimelineFragment {
|
||||||
|
val fragment = TimelineFragment()
|
||||||
|
val arguments = Bundle(3)
|
||||||
|
arguments.putString(KIND_ARG, kind.name)
|
||||||
|
arguments.putString(ID_ARG, hashtagOrId)
|
||||||
|
arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh)
|
||||||
|
fragment.arguments = arguments
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun newHashtagInstance(hashtags: List<String>): TimelineFragment {
|
||||||
|
val fragment = TimelineFragment()
|
||||||
|
val arguments = Bundle(3)
|
||||||
|
arguments.putString(KIND_ARG, TimelineViewModel.Kind.TAG.name)
|
||||||
|
arguments.putStringArrayList(HASHTAGS_ARG, ArrayList(hashtags))
|
||||||
|
arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
|
||||||
|
fragment.arguments = arguments
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private val diffCallback: DiffUtil.ItemCallback<StatusViewData> =
|
||||||
|
object : DiffUtil.ItemCallback<StatusViewData>() {
|
||||||
|
override fun areItemsTheSame(
|
||||||
|
oldItem: StatusViewData,
|
||||||
|
newItem: StatusViewData
|
||||||
|
): Boolean {
|
||||||
|
return oldItem.viewDataId == newItem.viewDataId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(
|
||||||
|
oldItem: StatusViewData,
|
||||||
|
newItem: StatusViewData
|
||||||
|
): Boolean {
|
||||||
|
return false // Items are different always. It allows to refresh timestamp on every view holder update
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(
|
||||||
|
oldItem: StatusViewData,
|
||||||
|
newItem: StatusViewData
|
||||||
|
): 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,413 @@
|
||||||
|
package com.keylesspalace.tusky.components.timeline
|
||||||
|
|
||||||
|
import android.text.SpannedString
|
||||||
|
import androidx.core.text.parseAsHtml
|
||||||
|
import androidx.core.text.toHtml
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import com.keylesspalace.tusky.db.*
|
||||||
|
import com.keylesspalace.tusky.entity.*
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.DISK
|
||||||
|
import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.NETWORK
|
||||||
|
import com.keylesspalace.tusky.util.Either
|
||||||
|
import com.keylesspalace.tusky.util.dec
|
||||||
|
import com.keylesspalace.tusky.util.inc
|
||||||
|
import com.keylesspalace.tusky.util.trimTrailingWhitespace
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
|
data class Placeholder(val id: String)
|
||||||
|
|
||||||
|
typealias TimelineStatus = Either<Placeholder, Status>
|
||||||
|
|
||||||
|
enum class TimelineRequestMode {
|
||||||
|
DISK, NETWORK, ANY
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineRepository {
|
||||||
|
fun getStatuses(
|
||||||
|
maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int,
|
||||||
|
requestMode: TimelineRequestMode
|
||||||
|
): Single<out List<TimelineStatus>>
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimelineRepositoryImpl(
|
||||||
|
private val timelineDao: TimelineDao,
|
||||||
|
private val mastodonApi: MastodonApi,
|
||||||
|
private val accountManager: AccountManager,
|
||||||
|
private val gson: Gson
|
||||||
|
) : TimelineRepository {
|
||||||
|
|
||||||
|
init {
|
||||||
|
this.cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getStatuses(
|
||||||
|
maxId: String?, sinceId: String?, sincedIdMinusOne: String?,
|
||||||
|
limit: Int, requestMode: TimelineRequestMode
|
||||||
|
): Single<out List<TimelineStatus>> {
|
||||||
|
val acc = accountManager.activeAccount ?: throw IllegalStateException()
|
||||||
|
val accountId = acc.id
|
||||||
|
|
||||||
|
return if (requestMode == DISK) {
|
||||||
|
this.getStatusesFromDb(accountId, maxId, sinceId, limit)
|
||||||
|
} else {
|
||||||
|
getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStatusesFromNetwork(
|
||||||
|
maxId: String?, sinceId: String?,
|
||||||
|
sinceIdMinusOne: String?, limit: Int,
|
||||||
|
accountId: Long, requestMode: TimelineRequestMode
|
||||||
|
): Single<out List<TimelineStatus>> {
|
||||||
|
return mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1)
|
||||||
|
.map { response ->
|
||||||
|
this.saveStatusesToDb(accountId, response.body().orEmpty(), maxId, sinceId)
|
||||||
|
}
|
||||||
|
.flatMap { statuses ->
|
||||||
|
this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode)
|
||||||
|
}
|
||||||
|
.onErrorResumeNext { error ->
|
||||||
|
if (error is IOException && requestMode != NETWORK) {
|
||||||
|
this.getStatusesFromDb(accountId, maxId, sinceId, limit)
|
||||||
|
} else {
|
||||||
|
Single.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addFromDbIfNeeded(
|
||||||
|
accountId: Long, statuses: List<Either<Placeholder, Status>>,
|
||||||
|
maxId: String?, sinceId: String?, limit: Int,
|
||||||
|
requestMode: TimelineRequestMode
|
||||||
|
): Single<List<TimelineStatus>> {
|
||||||
|
return if (requestMode != NETWORK && statuses.size < 2) {
|
||||||
|
val newMaxID = if (statuses.isEmpty()) {
|
||||||
|
maxId
|
||||||
|
} else {
|
||||||
|
statuses.last { it.isRight() }.asRight().id
|
||||||
|
}
|
||||||
|
this.getStatusesFromDb(accountId, newMaxID, sinceId, limit)
|
||||||
|
.map { fromDb ->
|
||||||
|
// If it's just placeholders and less than limit (so we exhausted both
|
||||||
|
// db and server at this point)
|
||||||
|
if (fromDb.size < limit && fromDb.all { !it.isRight() }) {
|
||||||
|
statuses
|
||||||
|
} else {
|
||||||
|
statuses + fromDb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Single.just(statuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStatusesFromDb(
|
||||||
|
accountId: Long, maxId: String?, sinceId: String?,
|
||||||
|
limit: Int
|
||||||
|
): Single<out List<TimelineStatus>> {
|
||||||
|
return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.map { statuses ->
|
||||||
|
statuses.map { it.toStatus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveStatusesToDb(
|
||||||
|
accountId: Long, statuses: List<Status>,
|
||||||
|
maxId: String?, sinceId: String?
|
||||||
|
): List<Either<Placeholder, Status>> {
|
||||||
|
var placeholderToInsert: Placeholder? = null
|
||||||
|
|
||||||
|
// Look for overlap
|
||||||
|
val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) {
|
||||||
|
val indexOfSince = statuses.indexOfLast { it.id == sinceId }
|
||||||
|
if (indexOfSince == -1) {
|
||||||
|
// We didn't find the status which must be there. Add a placeholder
|
||||||
|
placeholderToInsert = Placeholder(sinceId.inc())
|
||||||
|
statuses.mapTo(mutableListOf(), Status::lift)
|
||||||
|
.apply {
|
||||||
|
add(Either.Left(placeholderToInsert))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// There was an overlap. Remove all overlapped statuses. No need for a placeholder.
|
||||||
|
statuses.mapTo(mutableListOf(), Status::lift)
|
||||||
|
.apply {
|
||||||
|
subList(indexOfSince, size).clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Just a normal case.
|
||||||
|
statuses.map(Status::lift)
|
||||||
|
}
|
||||||
|
|
||||||
|
Single.fromCallable {
|
||||||
|
|
||||||
|
if (statuses.isNotEmpty()) {
|
||||||
|
timelineDao.deleteRange(accountId, statuses.last().id, statuses.first().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (status in statuses) {
|
||||||
|
timelineDao.insertInTransaction(
|
||||||
|
status.toEntity(accountId, gson),
|
||||||
|
status.account.toEntity(accountId, gson),
|
||||||
|
status.reblog?.account?.toEntity(accountId, gson)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholderToInsert?.let {
|
||||||
|
timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're loading in the bottom insert placeholder after every load
|
||||||
|
// (for requests on next launches) but not return it.
|
||||||
|
if (sinceId == null && statuses.isNotEmpty()) {
|
||||||
|
timelineDao.insertStatusIfNotThere(
|
||||||
|
Placeholder(statuses.last().id.dec()).toEntity(accountId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// There may be placeholders which we thought could be from our TL but they are not
|
||||||
|
if (statuses.size > 2) {
|
||||||
|
timelineDao.removeAllPlaceholdersBetween(
|
||||||
|
accountId, statuses.first().id,
|
||||||
|
statuses.last().id
|
||||||
|
)
|
||||||
|
} else if (placeholderToInsert == null && maxId != null && sinceId != null) {
|
||||||
|
timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
return resultStatuses
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanup() {
|
||||||
|
Schedulers.io().scheduleDirect {
|
||||||
|
val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL
|
||||||
|
timelineDao.cleanup(olderThan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TimelineStatusWithAccount.toStatus(): TimelineStatus {
|
||||||
|
if (this.status.authorServerId == null) {
|
||||||
|
return Either.Left(Placeholder(this.status.serverId))
|
||||||
|
}
|
||||||
|
|
||||||
|
val attachments: ArrayList<Attachment> = gson.fromJson(
|
||||||
|
status.attachments,
|
||||||
|
object : TypeToken<List<Attachment>>() {}.type
|
||||||
|
) ?: ArrayList()
|
||||||
|
val mentions: List<Status.Mention> = gson.fromJson(
|
||||||
|
status.mentions,
|
||||||
|
object : TypeToken<List<Status.Mention>>() {}.type
|
||||||
|
) ?: listOf()
|
||||||
|
val application = gson.fromJson(status.application, Status.Application::class.java)
|
||||||
|
val emojis: List<Emoji> = gson.fromJson(
|
||||||
|
status.emojis,
|
||||||
|
object : TypeToken<List<Emoji>>() {}.type
|
||||||
|
) ?: listOf()
|
||||||
|
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
|
||||||
|
|
||||||
|
val reblog = status.reblogServerId?.let { id ->
|
||||||
|
Status(
|
||||||
|
id = id,
|
||||||
|
url = status.url,
|
||||||
|
account = account.toAccount(gson),
|
||||||
|
inReplyToId = status.inReplyToId,
|
||||||
|
inReplyToAccountId = status.inReplyToAccountId,
|
||||||
|
reblog = null,
|
||||||
|
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
|
||||||
|
?: SpannedString(""),
|
||||||
|
createdAt = Date(status.createdAt),
|
||||||
|
emojis = emojis,
|
||||||
|
reblogsCount = status.reblogsCount,
|
||||||
|
favouritesCount = status.favouritesCount,
|
||||||
|
reblogged = status.reblogged,
|
||||||
|
favourited = status.favourited,
|
||||||
|
bookmarked = status.bookmarked,
|
||||||
|
sensitive = status.sensitive,
|
||||||
|
spoilerText = status.spoilerText!!,
|
||||||
|
visibility = status.visibility!!,
|
||||||
|
attachments = attachments,
|
||||||
|
mentions = mentions,
|
||||||
|
application = application,
|
||||||
|
pinned = false,
|
||||||
|
muted = status.muted,
|
||||||
|
poll = poll,
|
||||||
|
card = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val status = if (reblog != null) {
|
||||||
|
Status(
|
||||||
|
id = status.serverId,
|
||||||
|
url = null, // no url for reblogs
|
||||||
|
account = this.reblogAccount!!.toAccount(gson),
|
||||||
|
inReplyToId = null,
|
||||||
|
inReplyToAccountId = null,
|
||||||
|
reblog = reblog,
|
||||||
|
content = SpannedString(""),
|
||||||
|
createdAt = Date(status.createdAt), // lie but whatever?
|
||||||
|
emojis = listOf(),
|
||||||
|
reblogsCount = 0,
|
||||||
|
favouritesCount = 0,
|
||||||
|
reblogged = false,
|
||||||
|
favourited = false,
|
||||||
|
bookmarked = false,
|
||||||
|
sensitive = false,
|
||||||
|
spoilerText = "",
|
||||||
|
visibility = status.visibility!!,
|
||||||
|
attachments = ArrayList(),
|
||||||
|
mentions = listOf(),
|
||||||
|
application = null,
|
||||||
|
pinned = false,
|
||||||
|
muted = status.muted,
|
||||||
|
poll = null,
|
||||||
|
card = null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Status(
|
||||||
|
id = status.serverId,
|
||||||
|
url = status.url,
|
||||||
|
account = account.toAccount(gson),
|
||||||
|
inReplyToId = status.inReplyToId,
|
||||||
|
inReplyToAccountId = status.inReplyToAccountId,
|
||||||
|
reblog = null,
|
||||||
|
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
|
||||||
|
?: SpannedString(""),
|
||||||
|
createdAt = Date(status.createdAt),
|
||||||
|
emojis = emojis,
|
||||||
|
reblogsCount = status.reblogsCount,
|
||||||
|
favouritesCount = status.favouritesCount,
|
||||||
|
reblogged = status.reblogged,
|
||||||
|
favourited = status.favourited,
|
||||||
|
bookmarked = status.bookmarked,
|
||||||
|
sensitive = status.sensitive,
|
||||||
|
spoilerText = status.spoilerText!!,
|
||||||
|
visibility = status.visibility!!,
|
||||||
|
attachments = attachments,
|
||||||
|
mentions = mentions,
|
||||||
|
application = application,
|
||||||
|
pinned = false,
|
||||||
|
muted = status.muted,
|
||||||
|
poll = poll,
|
||||||
|
card = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Either.Right(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val emojisListTypeToken = object : TypeToken<List<Emoji>>() {}
|
||||||
|
|
||||||
|
fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
|
||||||
|
return TimelineAccountEntity(
|
||||||
|
serverId = id,
|
||||||
|
timelineUserId = accountId,
|
||||||
|
localUsername = localUsername,
|
||||||
|
username = username,
|
||||||
|
displayName = name,
|
||||||
|
url = url,
|
||||||
|
avatar = avatar,
|
||||||
|
emojis = gson.toJson(emojis),
|
||||||
|
bot = bot
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun TimelineAccountEntity.toAccount(gson: Gson): Account {
|
||||||
|
return Account(
|
||||||
|
id = serverId,
|
||||||
|
localUsername = localUsername,
|
||||||
|
username = username,
|
||||||
|
displayName = displayName,
|
||||||
|
note = SpannedString(""),
|
||||||
|
url = url,
|
||||||
|
avatar = avatar,
|
||||||
|
header = "",
|
||||||
|
locked = false,
|
||||||
|
followingCount = 0,
|
||||||
|
followersCount = 0,
|
||||||
|
statusesCount = 0,
|
||||||
|
source = null,
|
||||||
|
bot = bot,
|
||||||
|
emojis = gson.fromJson(this.emojis, emojisListTypeToken.type),
|
||||||
|
fields = null,
|
||||||
|
moved = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
|
||||||
|
return TimelineStatusEntity(
|
||||||
|
serverId = this.id,
|
||||||
|
url = null,
|
||||||
|
timelineUserId = timelineUserId,
|
||||||
|
authorServerId = null,
|
||||||
|
inReplyToId = null,
|
||||||
|
inReplyToAccountId = null,
|
||||||
|
content = null,
|
||||||
|
createdAt = 0L,
|
||||||
|
emojis = null,
|
||||||
|
reblogsCount = 0,
|
||||||
|
favouritesCount = 0,
|
||||||
|
reblogged = false,
|
||||||
|
favourited = false,
|
||||||
|
bookmarked = false,
|
||||||
|
sensitive = false,
|
||||||
|
spoilerText = null,
|
||||||
|
visibility = null,
|
||||||
|
attachments = null,
|
||||||
|
mentions = null,
|
||||||
|
application = null,
|
||||||
|
reblogServerId = null,
|
||||||
|
reblogAccountId = null,
|
||||||
|
poll = null,
|
||||||
|
muted = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Status.toEntity(
|
||||||
|
timelineUserId: Long,
|
||||||
|
gson: Gson
|
||||||
|
): TimelineStatusEntity {
|
||||||
|
val actionable = actionableStatus
|
||||||
|
return TimelineStatusEntity(
|
||||||
|
serverId = this.id,
|
||||||
|
url = actionable.url!!,
|
||||||
|
timelineUserId = timelineUserId,
|
||||||
|
authorServerId = actionable.account.id,
|
||||||
|
inReplyToId = actionable.inReplyToId,
|
||||||
|
inReplyToAccountId = actionable.inReplyToAccountId,
|
||||||
|
content = actionable.content.toHtml(),
|
||||||
|
createdAt = actionable.createdAt.time,
|
||||||
|
emojis = actionable.emojis.let(gson::toJson),
|
||||||
|
reblogsCount = actionable.reblogsCount,
|
||||||
|
favouritesCount = actionable.favouritesCount,
|
||||||
|
reblogged = actionable.reblogged,
|
||||||
|
favourited = actionable.favourited,
|
||||||
|
bookmarked = actionable.bookmarked,
|
||||||
|
sensitive = actionable.sensitive,
|
||||||
|
spoilerText = actionable.spoilerText,
|
||||||
|
visibility = actionable.visibility,
|
||||||
|
attachments = actionable.attachments.let(gson::toJson),
|
||||||
|
mentions = actionable.mentions.let(gson::toJson),
|
||||||
|
application = actionable.application.let(gson::toJson),
|
||||||
|
reblogServerId = reblog?.id,
|
||||||
|
reblogAccountId = reblog?.let { this.account.id },
|
||||||
|
poll = actionable.poll.let(gson::toJson),
|
||||||
|
muted = actionable.muted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Status.lift(): Either<Placeholder, Status> = Either.Right(this)
|
|
@ -0,0 +1,903 @@
|
||||||
|
package com.keylesspalace.tusky.components.timeline
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.keylesspalace.tusky.appstore.*
|
||||||
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import com.keylesspalace.tusky.network.FilterModel
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.keylesspalace.tusky.network.TimelineCases
|
||||||
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
|
import com.keylesspalace.tusky.util.*
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.rx3.asFlow
|
||||||
|
import kotlinx.coroutines.rx3.await
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Response
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class TimelineViewModel @Inject constructor(
|
||||||
|
private val timelineRepo: TimelineRepository,
|
||||||
|
private val timelineCases: TimelineCases,
|
||||||
|
private val api: MastodonApi,
|
||||||
|
private val eventHub: EventHub,
|
||||||
|
private val accountManager: AccountManager,
|
||||||
|
private val sharedPreferences: SharedPreferences,
|
||||||
|
private val filterModel: FilterModel,
|
||||||
|
) : RxAwareViewModel() {
|
||||||
|
|
||||||
|
enum class FailureReason {
|
||||||
|
NETWORK,
|
||||||
|
OTHER,
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewUpdates: Observable<Unit>
|
||||||
|
get() = updateViewSubject
|
||||||
|
|
||||||
|
var kind: Kind = Kind.HOME
|
||||||
|
private set
|
||||||
|
|
||||||
|
var isLoadingInitially = false
|
||||||
|
private set
|
||||||
|
var isRefreshing = false
|
||||||
|
private set
|
||||||
|
var bottomLoading = false
|
||||||
|
private set
|
||||||
|
var initialUpdateFailed = false
|
||||||
|
private set
|
||||||
|
var failure: FailureReason? = null
|
||||||
|
private set
|
||||||
|
var id: String? = null
|
||||||
|
private set
|
||||||
|
var tags: List<String> = emptyList()
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var alwaysShowSensitiveMedia = false
|
||||||
|
private var alwaysOpenSpoilers = false
|
||||||
|
private var filterRemoveReplies = false
|
||||||
|
private var filterRemoveReblogs = false
|
||||||
|
private var didLoadEverythingBottom = false
|
||||||
|
|
||||||
|
private var updateViewSubject = PublishSubject.create<Unit>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For some timeline kinds we must use LINK headers and not just status ids.
|
||||||
|
*/
|
||||||
|
private var nextId: String? = null
|
||||||
|
|
||||||
|
val statuses = mutableListOf<StatusViewData>()
|
||||||
|
|
||||||
|
fun init(
|
||||||
|
kind: Kind,
|
||||||
|
id: String?,
|
||||||
|
tags: List<String>
|
||||||
|
) {
|
||||||
|
this.kind = kind
|
||||||
|
this.id = id
|
||||||
|
this.tags = tags
|
||||||
|
|
||||||
|
if (kind == Kind.HOME) {
|
||||||
|
filterRemoveReplies =
|
||||||
|
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
|
||||||
|
filterRemoveReblogs =
|
||||||
|
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true)
|
||||||
|
}
|
||||||
|
this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||||
|
this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
eventHub.events
|
||||||
|
.asFlow()
|
||||||
|
.collect { event -> handleEvent(event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateCurrent() {
|
||||||
|
val topId = statuses.firstIsInstanceOrNull<StatusViewData.Concrete>()?.id ?: return
|
||||||
|
// Request statuses including current top to refresh all of them
|
||||||
|
val topIdMinusOne = topId.inc()
|
||||||
|
val statuses = try {
|
||||||
|
loadStatuses(
|
||||||
|
maxId = topIdMinusOne,
|
||||||
|
sinceId = null,
|
||||||
|
sinceIdMinusOne = null,
|
||||||
|
TimelineRequestMode.NETWORK,
|
||||||
|
)
|
||||||
|
} catch (t: Exception) {
|
||||||
|
initialUpdateFailed = true
|
||||||
|
if (isExpectedRequestException(t)) {
|
||||||
|
Log.d(TAG, "Failed updating timeline", t)
|
||||||
|
triggerViewUpdate()
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initialUpdateFailed = false
|
||||||
|
|
||||||
|
// When cached timeline is too old, we would replace it with nothing
|
||||||
|
if (statuses.isNotEmpty()) {
|
||||||
|
val mutableStatuses = statuses.toMutableList()
|
||||||
|
filterStatuses(mutableStatuses)
|
||||||
|
this.statuses.removeAll { item ->
|
||||||
|
val id = when (item) {
|
||||||
|
is StatusViewData.Concrete -> item.id
|
||||||
|
is StatusViewData.Placeholder -> item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
id == topId || id.isLessThan(topId)
|
||||||
|
}
|
||||||
|
this.statuses.addAll(mutableStatuses.toViewData())
|
||||||
|
}
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException
|
||||||
|
|
||||||
|
fun refresh(): Job {
|
||||||
|
return viewModelScope.launch {
|
||||||
|
isRefreshing = true
|
||||||
|
failure = null
|
||||||
|
triggerViewUpdate()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (initialUpdateFailed) updateCurrent()
|
||||||
|
loadAbove()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (isExpectedRequestException(e)) {
|
||||||
|
Log.e(TAG, "Failed to refresh", e)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When reaching the end of list. WIll optionally show spinner in the end of list. */
|
||||||
|
fun loadMore(): Job {
|
||||||
|
return viewModelScope.launch {
|
||||||
|
if (didLoadEverythingBottom || bottomLoading) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (statuses.isEmpty()) {
|
||||||
|
loadInitial().join()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
setLoadingPlaceholderBelow()
|
||||||
|
|
||||||
|
val bottomId: String? =
|
||||||
|
if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) {
|
||||||
|
nextId
|
||||||
|
} else {
|
||||||
|
statuses.lastOrNull { it is StatusViewData.Concrete }
|
||||||
|
?.let { (it as StatusViewData.Concrete).id }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loadBelow(bottomId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (isExpectedRequestException(e)) {
|
||||||
|
if (statuses.lastOrNull() is StatusViewData.Placeholder) {
|
||||||
|
statuses.removeAt(statuses.lastIndex)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load and insert statuses below the [bottomId]. Does not indicate progress. */
|
||||||
|
private suspend fun loadBelow(bottomId: String?) {
|
||||||
|
this.bottomLoading = true
|
||||||
|
try {
|
||||||
|
val statuses = loadStatuses(
|
||||||
|
bottomId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
TimelineRequestMode.ANY
|
||||||
|
)
|
||||||
|
addStatusesBelow(statuses.toMutableList())
|
||||||
|
} finally {
|
||||||
|
this.bottomLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setLoadingPlaceholderBelow() {
|
||||||
|
val last = statuses.last()
|
||||||
|
val placeholder: StatusViewData.Placeholder
|
||||||
|
if (last is StatusViewData.Concrete) {
|
||||||
|
val placeholderId = last.id.dec()
|
||||||
|
placeholder = StatusViewData.Placeholder(placeholderId, true)
|
||||||
|
statuses.add(placeholder)
|
||||||
|
} else {
|
||||||
|
placeholder = last as StatusViewData.Placeholder
|
||||||
|
}
|
||||||
|
statuses[statuses.lastIndex] = placeholder
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addStatusesBelow(statuses: MutableList<Either<Placeholder, Status>>) {
|
||||||
|
val fullFetch = isFullFetch(statuses)
|
||||||
|
// Remove placeholder in the bottom if it's there
|
||||||
|
if (this.statuses.isNotEmpty()
|
||||||
|
&& this.statuses.last() !is StatusViewData.Concrete
|
||||||
|
) {
|
||||||
|
this.statuses.removeAt(this.statuses.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing placeholder if it's the last one from the cache
|
||||||
|
if (statuses.isNotEmpty() && !statuses[statuses.size - 1].isRight()) {
|
||||||
|
statuses.removeAt(statuses.size - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val oldSize = this.statuses.size
|
||||||
|
if (this.statuses.isNotEmpty()) {
|
||||||
|
addItems(statuses)
|
||||||
|
} else {
|
||||||
|
updateStatuses(statuses, fullFetch)
|
||||||
|
}
|
||||||
|
if (this.statuses.size == oldSize) {
|
||||||
|
// This may be a brittle check but seems like it works
|
||||||
|
// Can we check it using headers somehow? Do all server support them?
|
||||||
|
didLoadEverythingBottom = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadGap(position: Int): Job {
|
||||||
|
return viewModelScope.launch {
|
||||||
|
//check bounds before accessing list,
|
||||||
|
if (statuses.size < position || position <= 0) {
|
||||||
|
Log.e(TAG, "Wrong gap position: $position")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val fromStatus = statuses[position - 1].asStatusOrNull()
|
||||||
|
val toStatus = statuses[position + 1].asStatusOrNull()
|
||||||
|
val toMinusOne = statuses.getOrNull(position + 2)?.asStatusOrNull()?.id
|
||||||
|
if (fromStatus == null || toStatus == null) {
|
||||||
|
Log.e(TAG, "Failed to load more at $position, wrong placeholder position")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val placeholder = statuses[position].asPlaceholderOrNull() ?: run {
|
||||||
|
Log.e(TAG, "Not a placeholder at $position")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val newViewData: StatusViewData = StatusViewData.Placeholder(placeholder.id, true)
|
||||||
|
statuses[position] = newViewData
|
||||||
|
triggerViewUpdate()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val statuses = loadStatuses(
|
||||||
|
fromStatus.id,
|
||||||
|
toStatus.id,
|
||||||
|
toMinusOne,
|
||||||
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
replacePlaceholderWithStatuses(
|
||||||
|
statuses.toMutableList(),
|
||||||
|
isFullFetch(statuses),
|
||||||
|
position
|
||||||
|
)
|
||||||
|
} catch (t: Exception) {
|
||||||
|
if (isExpectedRequestException(t)) {
|
||||||
|
Log.e(TAG, "Failed to load gap", t)
|
||||||
|
if (statuses[position] is StatusViewData.Placeholder) {
|
||||||
|
statuses[position] = StatusViewData.Placeholder(placeholder.id, false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reblog(reblog: Boolean, position: Int): Job = viewModelScope.launch {
|
||||||
|
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||||
|
try {
|
||||||
|
timelineCases.reblog(status.id, reblog).await()
|
||||||
|
} catch (t: Exception) {
|
||||||
|
ifExpected(t) {
|
||||||
|
Log.d(TAG, "Failed to reblog status " + status.id, t)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun favorite(favorite: Boolean, position: Int): Job = viewModelScope.launch {
|
||||||
|
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||||
|
|
||||||
|
try {
|
||||||
|
timelineCases.favourite(status.id, favorite).await()
|
||||||
|
} catch (t: Exception) {
|
||||||
|
ifExpected(t) {
|
||||||
|
Log.d(TAG, "Failed to favourite status " + status.id, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bookmark(bookmark: Boolean, position: Int): Job = viewModelScope.launch {
|
||||||
|
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||||
|
try {
|
||||||
|
timelineCases.bookmark(status.id, bookmark).await()
|
||||||
|
} catch (t: Exception) {
|
||||||
|
ifExpected(t) {
|
||||||
|
Log.d(TAG, "Failed to favourite status " + status.id, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun voteInPoll(position: Int, choices: List<Int>): Job = viewModelScope.launch {
|
||||||
|
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||||
|
|
||||||
|
val poll = status.status.poll ?: run {
|
||||||
|
Log.w(TAG, "No poll on status ${status.id}")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val votedPoll = poll.votedCopy(choices)
|
||||||
|
updatePoll(status, votedPoll)
|
||||||
|
|
||||||
|
try {
|
||||||
|
timelineCases.voteInPoll(status.id, poll.id, choices).await()
|
||||||
|
} catch (t: Exception) {
|
||||||
|
ifExpected(t) {
|
||||||
|
Log.d(TAG, "Failed to vote in poll: " + status.id, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePoll(
|
||||||
|
status: StatusViewData.Concrete,
|
||||||
|
newPoll: Poll
|
||||||
|
) {
|
||||||
|
updateStatusById(status.id) {
|
||||||
|
it.copy(status = it.status.copy(poll = newPoll))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeExpanded(expanded: Boolean, position: Int) {
|
||||||
|
updateStatusAt(position) { it.copy(isExpanded = expanded) }
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeContentHidden(isShowing: Boolean, position: Int) {
|
||||||
|
updateStatusAt(position) { it.copy(isShowingContent = isShowing) }
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeContentCollapsed(isCollapsed: Boolean, position: Int) {
|
||||||
|
updateStatusAt(position) { it.copy(isCollapsed = isCollapsed) }
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeAllByAccountId(accountId: String) {
|
||||||
|
statuses.removeAll { vm ->
|
||||||
|
val status = vm.asStatusOrNull()?.status ?: return@removeAll false
|
||||||
|
status.account.id == accountId || status.actionableStatus.account.id == accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeAllByInstance(instance: String) {
|
||||||
|
statuses.removeAll { vd ->
|
||||||
|
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
||||||
|
LinkHelper.getDomain(status.account.url) == instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun triggerViewUpdate() {
|
||||||
|
this.updateViewSubject.onNext(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadStatuses(
|
||||||
|
maxId: String?,
|
||||||
|
sinceId: String?,
|
||||||
|
sinceIdMinusOne: String?,
|
||||||
|
homeMode: TimelineRequestMode,
|
||||||
|
): List<TimelineStatus> {
|
||||||
|
val statuses = if (kind == Kind.HOME) {
|
||||||
|
timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, homeMode)
|
||||||
|
.await()
|
||||||
|
} else {
|
||||||
|
val response = fetchStatusesForKind(maxId, sinceId, LOAD_AT_ONCE).await()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val newNextId = extractNextId(response)
|
||||||
|
if (newNextId != null) {
|
||||||
|
// when we reach the bottom of the list, we won't have a new link. If
|
||||||
|
// we blindly write `null` here we will start loading from the top
|
||||||
|
// again.
|
||||||
|
nextId = newNextId
|
||||||
|
}
|
||||||
|
response.body()?.map { Either.Right(it) } ?: listOf()
|
||||||
|
} else {
|
||||||
|
throw Exception(response.message())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterStatuses(statuses.toMutableList())
|
||||||
|
|
||||||
|
return statuses
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStatuses(
|
||||||
|
newStatuses: MutableList<Either<Placeholder, Status>>,
|
||||||
|
fullFetch: Boolean
|
||||||
|
) {
|
||||||
|
if (statuses.isEmpty()) {
|
||||||
|
statuses.addAll(newStatuses.toViewData())
|
||||||
|
} else {
|
||||||
|
val lastOfNew = newStatuses.lastOrNull()
|
||||||
|
val index = if (lastOfNew == null) -1
|
||||||
|
else statuses.indexOfLast { it.asStatusOrNull()?.id === lastOfNew.asRightOrNull()?.id }
|
||||||
|
if (index >= 0) {
|
||||||
|
statuses.subList(0, index).clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
val newIndex =
|
||||||
|
newStatuses.indexOfFirst {
|
||||||
|
it.isRight() && it.asRight().id == (statuses[0] as? StatusViewData.Concrete)?.id
|
||||||
|
}
|
||||||
|
if (newIndex == -1) {
|
||||||
|
if (index == -1 && fullFetch) {
|
||||||
|
val placeholderId =
|
||||||
|
newStatuses.last { status -> status.isRight() }.asRight().id.inc()
|
||||||
|
newStatuses.add(Either.Left(Placeholder(placeholderId)))
|
||||||
|
}
|
||||||
|
statuses.addAll(0, newStatuses.toViewData())
|
||||||
|
} else {
|
||||||
|
statuses.addAll(0, newStatuses.subList(0, newIndex).toViewData())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove all consecutive placeholders
|
||||||
|
removeConsecutivePlaceholders()
|
||||||
|
this.triggerViewUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterViewData(viewData: MutableList<StatusViewData>) {
|
||||||
|
viewData.removeAll { vd ->
|
||||||
|
vd.asStatusOrNull()?.status?.let { shouldFilterStatus(it) } ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterStatuses(statuses: MutableList<Either<Placeholder, Status>>) {
|
||||||
|
statuses.removeAll { status ->
|
||||||
|
status.asRightOrNull()?.let { shouldFilterStatus(it) } ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldFilterStatus(status: Status): Boolean {
|
||||||
|
return status.inReplyToId != null && filterRemoveReplies
|
||||||
|
|| status.reblog != null && filterRemoveReblogs
|
||||||
|
|| filterModel.shouldFilterStatus(status.actionableStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractNextId(response: Response<*>): String? {
|
||||||
|
val linkHeader = response.headers()["Link"] ?: return null
|
||||||
|
val links = HttpHeaderLink.parse(linkHeader)
|
||||||
|
val nextHeader = HttpHeaderLink.findByRelationType(links, "next") ?: return null
|
||||||
|
val nextLink = nextHeader.uri ?: return null
|
||||||
|
return nextLink.getQueryParameter("max_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun tryCache() {
|
||||||
|
// Request timeline from disk to make it quick, then replace it with timeline from
|
||||||
|
// the server to update it
|
||||||
|
val statuses =
|
||||||
|
timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK)
|
||||||
|
.await()
|
||||||
|
|
||||||
|
val mutableStatusResponse = statuses.toMutableList()
|
||||||
|
filterStatuses(mutableStatusResponse)
|
||||||
|
if (statuses.size > 1) {
|
||||||
|
clearPlaceholdersForResponse(mutableStatusResponse)
|
||||||
|
this.statuses.clear()
|
||||||
|
this.statuses.addAll(statuses.toViewData())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadInitial(): Job {
|
||||||
|
return viewModelScope.launch {
|
||||||
|
if (statuses.isNotEmpty() || initialUpdateFailed || isLoadingInitially) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
isLoadingInitially = true
|
||||||
|
failure = null
|
||||||
|
triggerViewUpdate()
|
||||||
|
|
||||||
|
if (kind == Kind.HOME) {
|
||||||
|
tryCache()
|
||||||
|
isLoadingInitially = statuses.isEmpty()
|
||||||
|
updateCurrent()
|
||||||
|
try {
|
||||||
|
loadAbove()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Loading above failed", e)
|
||||||
|
if (!isExpectedRequestException(e)) {
|
||||||
|
throw e
|
||||||
|
} else if (statuses.isEmpty()) {
|
||||||
|
failure =
|
||||||
|
if (e is IOException) FailureReason.NETWORK
|
||||||
|
else FailureReason.OTHER
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoadingInitially = false
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
loadBelow(null)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
failure = FailureReason.NETWORK
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
failure = FailureReason.OTHER
|
||||||
|
} finally {
|
||||||
|
isLoadingInitially = false
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadAbove() {
|
||||||
|
var firstOrNull: String? = null
|
||||||
|
var secondOrNull: String? = null
|
||||||
|
for (i in statuses.indices) {
|
||||||
|
val status = statuses[i].asStatusOrNull() ?: continue
|
||||||
|
firstOrNull = status.id
|
||||||
|
secondOrNull = statuses.getOrNull(i + 1)?.asStatusOrNull()?.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (firstOrNull != null) {
|
||||||
|
triggerViewUpdate()
|
||||||
|
|
||||||
|
val statuses = loadStatuses(
|
||||||
|
maxId = null,
|
||||||
|
sinceId = firstOrNull,
|
||||||
|
sinceIdMinusOne = secondOrNull,
|
||||||
|
homeMode = TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
|
||||||
|
val fullFetch = isFullFetch(statuses)
|
||||||
|
updateStatuses(statuses.toMutableList(), fullFetch)
|
||||||
|
} else {
|
||||||
|
loadBelow(null)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isFullFetch(statuses: List<TimelineStatus>) = statuses.size >= LOAD_AT_ONCE
|
||||||
|
|
||||||
|
private fun fullyRefresh(): Job {
|
||||||
|
this.statuses.clear()
|
||||||
|
return loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchStatusesForKind(
|
||||||
|
fromId: String?,
|
||||||
|
uptoId: String?,
|
||||||
|
limit: Int
|
||||||
|
): Single<Response<List<Status>>> {
|
||||||
|
return when (kind) {
|
||||||
|
Kind.HOME -> api.homeTimeline(fromId, uptoId, limit)
|
||||||
|
Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit)
|
||||||
|
Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit)
|
||||||
|
Kind.TAG -> {
|
||||||
|
val firstHashtag = tags[0]
|
||||||
|
val additionalHashtags = tags.subList(1, tags.size)
|
||||||
|
api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit)
|
||||||
|
}
|
||||||
|
Kind.USER -> api.accountStatuses(
|
||||||
|
id!!,
|
||||||
|
fromId,
|
||||||
|
uptoId,
|
||||||
|
limit,
|
||||||
|
excludeReplies = true,
|
||||||
|
onlyMedia = null,
|
||||||
|
pinned = null
|
||||||
|
)
|
||||||
|
Kind.USER_PINNED -> api.accountStatuses(
|
||||||
|
id!!,
|
||||||
|
fromId,
|
||||||
|
uptoId,
|
||||||
|
limit,
|
||||||
|
excludeReplies = null,
|
||||||
|
onlyMedia = null,
|
||||||
|
pinned = true
|
||||||
|
)
|
||||||
|
Kind.USER_WITH_REPLIES -> api.accountStatuses(
|
||||||
|
id!!,
|
||||||
|
fromId,
|
||||||
|
uptoId,
|
||||||
|
limit,
|
||||||
|
excludeReplies = null,
|
||||||
|
onlyMedia = null,
|
||||||
|
pinned = null
|
||||||
|
)
|
||||||
|
Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit)
|
||||||
|
Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit)
|
||||||
|
Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replacePlaceholderWithStatuses(
|
||||||
|
newStatuses: MutableList<Either<Placeholder, Status>>,
|
||||||
|
fullFetch: Boolean, pos: Int
|
||||||
|
) {
|
||||||
|
val placeholder = statuses[pos]
|
||||||
|
if (placeholder is StatusViewData.Placeholder) {
|
||||||
|
statuses.removeAt(pos)
|
||||||
|
}
|
||||||
|
if (newStatuses.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val newViewData = newStatuses
|
||||||
|
.toViewData()
|
||||||
|
.toMutableList()
|
||||||
|
|
||||||
|
if (fullFetch) {
|
||||||
|
newViewData.add(placeholder)
|
||||||
|
}
|
||||||
|
statuses.addAll(pos, newViewData)
|
||||||
|
removeConsecutivePlaceholders()
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeConsecutivePlaceholders() {
|
||||||
|
for (i in 0 until statuses.size - 1) {
|
||||||
|
if (statuses[i] is StatusViewData.Placeholder &&
|
||||||
|
statuses[i + 1] is StatusViewData.Placeholder
|
||||||
|
) {
|
||||||
|
statuses.removeAt(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addItems(newStatuses: List<Either<Placeholder, Status>>) {
|
||||||
|
if (newStatuses.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statuses.addAll(newStatuses.toViewData())
|
||||||
|
removeConsecutivePlaceholders()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For certain requests we don't want to see placeholders, they will be removed some other way
|
||||||
|
*/
|
||||||
|
private fun clearPlaceholdersForResponse(statuses: MutableList<Either<Placeholder, Status>>) {
|
||||||
|
statuses.removeAll { status -> status.isLeft() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleReblogEvent(reblogEvent: ReblogEvent) {
|
||||||
|
updateStatusById(reblogEvent.statusId) {
|
||||||
|
it.copy(status = it.status.copy(reblogged = reblogEvent.reblog))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFavEvent(favEvent: FavoriteEvent) {
|
||||||
|
updateStatusById(favEvent.statusId) {
|
||||||
|
it.copy(status = it.status.copy(favourited = favEvent.favourite))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) {
|
||||||
|
updateStatusById(bookmarkEvent.statusId) {
|
||||||
|
it.copy(status = it.status.copy(bookmarked = bookmarkEvent.bookmark))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePinEvent(pinEvent: PinEvent) {
|
||||||
|
updateStatusById(pinEvent.statusId) {
|
||||||
|
it.copy(status = it.status.copy(pinned = pinEvent.pinned))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleStatusComposeEvent(status: Status) {
|
||||||
|
when (kind) {
|
||||||
|
Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> refresh()
|
||||||
|
Kind.USER, Kind.USER_WITH_REPLIES -> if (status.account.id == id) {
|
||||||
|
refresh()
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteStatusById(id: String) {
|
||||||
|
for (i in statuses.indices) {
|
||||||
|
val either = statuses[i]
|
||||||
|
if (either.asStatusOrNull()?.id == id) {
|
||||||
|
statuses.removeAt(i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPreferenceChanged(key: String) {
|
||||||
|
when (key) {
|
||||||
|
PrefKeys.TAB_FILTER_HOME_REPLIES -> {
|
||||||
|
val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
|
||||||
|
val oldRemoveReplies = filterRemoveReplies
|
||||||
|
filterRemoveReplies = kind == Kind.HOME && !filter
|
||||||
|
if (statuses.isNotEmpty() && oldRemoveReplies != filterRemoveReplies) {
|
||||||
|
fullyRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrefKeys.TAB_FILTER_HOME_BOOSTS -> {
|
||||||
|
val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true)
|
||||||
|
val oldRemoveReblogs = filterRemoveReblogs
|
||||||
|
filterRemoveReblogs = kind == Kind.HOME && !filter
|
||||||
|
if (statuses.isNotEmpty() && oldRemoveReblogs != filterRemoveReblogs) {
|
||||||
|
fullyRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> {
|
||||||
|
if (filterContextMatchesKind(kind, listOf(key))) {
|
||||||
|
reloadFilters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> {
|
||||||
|
// it is ok if only newly loaded statuses are affected, no need to fully refresh
|
||||||
|
alwaysShowSensitiveMedia =
|
||||||
|
accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// public for now
|
||||||
|
fun filterContextMatchesKind(
|
||||||
|
kind: Kind,
|
||||||
|
filterContext: List<String>
|
||||||
|
): Boolean {
|
||||||
|
// home, notifications, public, thread
|
||||||
|
return when (kind) {
|
||||||
|
Kind.HOME, Kind.LIST -> filterContext.contains(
|
||||||
|
Filter.HOME
|
||||||
|
)
|
||||||
|
Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains(
|
||||||
|
Filter.PUBLIC
|
||||||
|
)
|
||||||
|
Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains(
|
||||||
|
Filter.NOTIFICATIONS
|
||||||
|
)
|
||||||
|
Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains(
|
||||||
|
Filter.ACCOUNT
|
||||||
|
)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleEvent(event: Event) {
|
||||||
|
when (event) {
|
||||||
|
is FavoriteEvent -> handleFavEvent(event)
|
||||||
|
is ReblogEvent -> handleReblogEvent(event)
|
||||||
|
is BookmarkEvent -> handleBookmarkEvent(event)
|
||||||
|
is PinEvent -> handlePinEvent(event)
|
||||||
|
is MuteConversationEvent -> fullyRefresh()
|
||||||
|
is UnfollowEvent -> {
|
||||||
|
if (kind == Kind.HOME) {
|
||||||
|
val id = event.accountId
|
||||||
|
removeAllByAccountId(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is BlockEvent -> {
|
||||||
|
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||||
|
val id = event.accountId
|
||||||
|
removeAllByAccountId(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MuteEvent -> {
|
||||||
|
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||||
|
val id = event.accountId
|
||||||
|
removeAllByAccountId(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is DomainMuteEvent -> {
|
||||||
|
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||||
|
val instance = event.instance
|
||||||
|
removeAllByInstance(instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is StatusDeletedEvent -> {
|
||||||
|
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||||
|
val id = event.statusId
|
||||||
|
deleteStatusById(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is StatusComposedEvent -> {
|
||||||
|
val status = event.status
|
||||||
|
handleStatusComposeEvent(status)
|
||||||
|
}
|
||||||
|
is PreferenceChangedEvent -> {
|
||||||
|
onPreferenceChanged(event.preferenceKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun updateStatusById(
|
||||||
|
id: String,
|
||||||
|
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
|
||||||
|
) {
|
||||||
|
val pos = statuses.indexOfFirst { it.asStatusOrNull()?.id == id }
|
||||||
|
if (pos == -1) return
|
||||||
|
updateStatusAt(pos, updater)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun updateStatusAt(
|
||||||
|
position: Int,
|
||||||
|
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
|
||||||
|
) {
|
||||||
|
val status = statuses.getOrNull(position)?.asStatusOrNull() ?: return
|
||||||
|
statuses[position] = updater(status)
|
||||||
|
triggerViewUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<TimelineStatus>.toViewData(): List<StatusViewData> = this.map {
|
||||||
|
when (it) {
|
||||||
|
is Either.Right -> it.value.toViewData(
|
||||||
|
alwaysShowSensitiveMedia,
|
||||||
|
alwaysOpenSpoilers
|
||||||
|
)
|
||||||
|
is Either.Left -> StatusViewData.Placeholder(it.value.id, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reloadFilters() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val filters = try {
|
||||||
|
api.getFilters().await()
|
||||||
|
} catch (t: Exception) {
|
||||||
|
Log.e(TAG, "Failed to fetch filters", t)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
filterModel.initWithFilters(filters.filter {
|
||||||
|
filterContextMatchesKind(kind, it.context)
|
||||||
|
})
|
||||||
|
filterViewData(this@TimelineViewModel.statuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun ifExpected(
|
||||||
|
t: Exception,
|
||||||
|
cb: () -> Unit
|
||||||
|
) {
|
||||||
|
if (isExpectedRequestException(t)) {
|
||||||
|
cb()
|
||||||
|
} else {
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TimelineVM"
|
||||||
|
internal const val LOAD_AT_ONCE = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Kind {
|
||||||
|
HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS
|
||||||
|
}
|
||||||
|
}
|
|
@ -105,13 +105,13 @@ class Converters @Inject constructor (
|
||||||
}
|
}
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun mentionArrayToJson(mentionArray: Array<Status.Mention>?): String? {
|
fun mentionListToJson(mentionArray: List<Status.Mention>?): String? {
|
||||||
return gson.toJson(mentionArray)
|
return gson.toJson(mentionArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun jsonToMentionArray(mentionListJson: String?): Array<Status.Mention>? {
|
fun jsonToMentionArray(mentionListJson: String?): List<Status.Mention>? {
|
||||||
return gson.fromJson(mentionListJson, object : TypeToken<Array<Status.Mention>>() {}.type)
|
return gson.fromJson(mentionListJson, object : TypeToken<List<Status.Mention>>() {}.type)
|
||||||
}
|
}
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
|
|
|
@ -29,6 +29,7 @@ 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.preference.PreferencesFragment
|
import com.keylesspalace.tusky.components.preference.PreferencesFragment
|
||||||
|
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.android.ContributesAndroidInjector
|
import dagger.android.ContributesAndroidInjector
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,8 @@ import com.google.gson.Gson
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.repository.TimelineRepository
|
import com.keylesspalace.tusky.components.timeline.TimelineRepository
|
||||||
import com.keylesspalace.tusky.repository.TimelineRepositoryImpl
|
import com.keylesspalace.tusky.components.timeline.TimelineRepositoryImpl
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,8 @@ import com.keylesspalace.tusky.components.drafts.DraftsViewModel
|
||||||
import com.keylesspalace.tusky.components.report.ReportViewModel
|
import com.keylesspalace.tusky.components.report.ReportViewModel
|
||||||
import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel
|
import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel
|
||||||
import com.keylesspalace.tusky.components.search.SearchViewModel
|
import com.keylesspalace.tusky.components.search.SearchViewModel
|
||||||
|
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||||
|
import com.keylesspalace.tusky.components.timeline.TimelineViewModel
|
||||||
import com.keylesspalace.tusky.viewmodel.AccountViewModel
|
import com.keylesspalace.tusky.viewmodel.AccountViewModel
|
||||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||||
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
||||||
|
@ -97,5 +99,10 @@ abstract class ViewModelModule {
|
||||||
@ViewModelKey(DraftsViewModel::class)
|
@ViewModelKey(DraftsViewModel::class)
|
||||||
internal abstract fun draftsViewModel(viewModel: DraftsViewModel): ViewModel
|
internal abstract fun draftsViewModel(viewModel: DraftsViewModel): ViewModel
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoMap
|
||||||
|
@ViewModelKey(TimelineViewModel::class)
|
||||||
|
internal abstract fun timelineViewModel(viewModel: TimelineViewModel): ViewModel
|
||||||
|
|
||||||
//Add more ViewModels here
|
//Add more ViewModels here
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,10 +22,11 @@ import com.google.gson.JsonParseException
|
||||||
import com.google.gson.annotations.JsonAdapter
|
import com.google.gson.annotations.JsonAdapter
|
||||||
|
|
||||||
data class Notification(
|
data class Notification(
|
||||||
val type: Type,
|
val type: Type,
|
||||||
val id: String,
|
val id: String,
|
||||||
val account: Account,
|
val account: Account,
|
||||||
val status: Status?) {
|
val status: Status?
|
||||||
|
) {
|
||||||
|
|
||||||
@JsonAdapter(NotificationTypeAdapter::class)
|
@JsonAdapter(NotificationTypeAdapter::class)
|
||||||
enum class Type(val presentation: String) {
|
enum class Type(val presentation: String) {
|
||||||
|
@ -71,18 +72,25 @@ data class Notification(
|
||||||
class NotificationTypeAdapter : JsonDeserializer<Type> {
|
class NotificationTypeAdapter : JsonDeserializer<Type> {
|
||||||
|
|
||||||
@Throws(JsonParseException::class)
|
@Throws(JsonParseException::class)
|
||||||
override fun deserialize(json: JsonElement, typeOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Type {
|
override fun deserialize(
|
||||||
|
json: JsonElement,
|
||||||
|
typeOfT: java.lang.reflect.Type,
|
||||||
|
context: JsonDeserializationContext
|
||||||
|
): Type {
|
||||||
return Type.byString(json.asString)
|
return Type.byString(json.asString)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Helper for Java */
|
||||||
|
fun copyWithStatus(status: Status?): Notification = copy(status = status)
|
||||||
|
|
||||||
// for Pleroma compatibility that uses Mention type
|
// for Pleroma compatibility that uses Mention type
|
||||||
fun rewriteToStatusTypeIfNeeded(accountId: String) : Notification {
|
fun rewriteToStatusTypeIfNeeded(accountId: String): Notification {
|
||||||
if (type == Type.MENTION && status != null) {
|
if (type == Type.MENTION && status != null) {
|
||||||
return if (status.mentions.any {
|
return if (status.mentions.any {
|
||||||
it.id == accountId
|
it.id == accountId
|
||||||
}) this else copy(type = Type.STATUS)
|
}) this else copy(type = Type.STATUS)
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,8 +22,8 @@ import com.google.gson.annotations.SerializedName
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
data class Status(
|
data class Status(
|
||||||
var id: String,
|
val id: String,
|
||||||
var url: String?, // not present if it's reblog
|
val url: String?, // not present if it's reblog
|
||||||
val account: Account,
|
val account: Account,
|
||||||
@SerializedName("in_reply_to_id") var inReplyToId: String?,
|
@SerializedName("in_reply_to_id") var inReplyToId: String?,
|
||||||
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
|
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
|
||||||
|
@ -40,10 +40,10 @@ data class Status(
|
||||||
@SerializedName("spoiler_text") val spoilerText: String,
|
@SerializedName("spoiler_text") val spoilerText: String,
|
||||||
val visibility: Visibility,
|
val visibility: Visibility,
|
||||||
@SerializedName("media_attachments") var attachments: ArrayList<Attachment>,
|
@SerializedName("media_attachments") var attachments: ArrayList<Attachment>,
|
||||||
val mentions: Array<Mention>,
|
val mentions: List<Mention>,
|
||||||
val application: Application?,
|
val application: Application?,
|
||||||
var pinned: Boolean?,
|
val pinned: Boolean?,
|
||||||
var muted: Boolean?,
|
val muted: Boolean?,
|
||||||
val poll: Poll?,
|
val poll: Poll?,
|
||||||
val card: Card?
|
val card: Card?
|
||||||
) {
|
) {
|
||||||
|
@ -54,6 +54,11 @@ data class Status(
|
||||||
val actionableStatus: Status
|
val actionableStatus: Status
|
||||||
get() = reblog ?: this
|
get() = reblog ?: this
|
||||||
|
|
||||||
|
/** Helper for Java */
|
||||||
|
fun copyWithPoll(poll: Poll?): Status = copy(poll = poll)
|
||||||
|
|
||||||
|
/** Helper for Java */
|
||||||
|
fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned)
|
||||||
|
|
||||||
enum class Visibility(val num: Int) {
|
enum class Visibility(val num: Int) {
|
||||||
UNKNOWN(0),
|
UNKNOWN(0),
|
||||||
|
|
|
@ -58,6 +58,7 @@ import com.keylesspalace.tusky.appstore.BlockEvent;
|
||||||
import com.keylesspalace.tusky.appstore.BookmarkEvent;
|
import com.keylesspalace.tusky.appstore.BookmarkEvent;
|
||||||
import com.keylesspalace.tusky.appstore.EventHub;
|
import com.keylesspalace.tusky.appstore.EventHub;
|
||||||
import com.keylesspalace.tusky.appstore.FavoriteEvent;
|
import com.keylesspalace.tusky.appstore.FavoriteEvent;
|
||||||
|
import com.keylesspalace.tusky.appstore.PinEvent;
|
||||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent;
|
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent;
|
||||||
import com.keylesspalace.tusky.appstore.ReblogEvent;
|
import com.keylesspalace.tusky.appstore.ReblogEvent;
|
||||||
import com.keylesspalace.tusky.db.AccountEntity;
|
import com.keylesspalace.tusky.db.AccountEntity;
|
||||||
|
@ -83,6 +84,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||||
import com.keylesspalace.tusky.util.ViewDataUtils;
|
import com.keylesspalace.tusky.util.ViewDataUtils;
|
||||||
import com.keylesspalace.tusky.view.BackgroundMessageView;
|
import com.keylesspalace.tusky.view.BackgroundMessageView;
|
||||||
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
|
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
|
||||||
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData;
|
||||||
import com.keylesspalace.tusky.viewdata.NotificationViewData;
|
import com.keylesspalace.tusky.viewdata.NotificationViewData;
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||||
|
|
||||||
|
@ -92,6 +94,7 @@ import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
@ -311,35 +314,6 @@ public class NotificationsFragment extends SFragment implements
|
||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleFavEvent(FavoriteEvent event) {
|
|
||||||
Pair<Integer, Notification> posAndNotification =
|
|
||||||
findReplyPosition(event.getStatusId());
|
|
||||||
if (posAndNotification == null) return;
|
|
||||||
//noinspection ConstantConditions
|
|
||||||
setFavouriteForStatus(posAndNotification.first,
|
|
||||||
posAndNotification.second.getStatus(),
|
|
||||||
event.getFavourite());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleBookmarkEvent(BookmarkEvent event) {
|
|
||||||
Pair<Integer, Notification> posAndNotification =
|
|
||||||
findReplyPosition(event.getStatusId());
|
|
||||||
if (posAndNotification == null) return;
|
|
||||||
//noinspection ConstantConditions
|
|
||||||
setBookmarkForStatus(posAndNotification.first,
|
|
||||||
posAndNotification.second.getStatus(),
|
|
||||||
event.getBookmark());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleReblogEvent(ReblogEvent event) {
|
|
||||||
Pair<Integer, Notification> posAndNotification = findReplyPosition(event.getStatusId());
|
|
||||||
if (posAndNotification == null) return;
|
|
||||||
//noinspection ConstantConditions
|
|
||||||
setReblogForStatus(posAndNotification.first,
|
|
||||||
posAndNotification.second.getStatus(),
|
|
||||||
event.getReblog());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
|
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
|
||||||
super.onActivityCreated(savedInstanceState);
|
super.onActivityCreated(savedInstanceState);
|
||||||
|
@ -386,11 +360,13 @@ public class NotificationsFragment extends SFragment implements
|
||||||
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||||
.subscribe(event -> {
|
.subscribe(event -> {
|
||||||
if (event instanceof FavoriteEvent) {
|
if (event instanceof FavoriteEvent) {
|
||||||
handleFavEvent((FavoriteEvent) event);
|
setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite());
|
||||||
} else if (event instanceof BookmarkEvent) {
|
} else if (event instanceof BookmarkEvent) {
|
||||||
handleBookmarkEvent((BookmarkEvent) event);
|
setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark());
|
||||||
} else if (event instanceof ReblogEvent) {
|
} else if (event instanceof ReblogEvent) {
|
||||||
handleReblogEvent((ReblogEvent) event);
|
setReblogForStatus(((ReblogEvent) event).getStatusId(), ((ReblogEvent) event).getReblog());
|
||||||
|
} else if (event instanceof PinEvent) {
|
||||||
|
setPinForStatus(((PinEvent) event).getStatusId(), ((PinEvent) event).getPinned());
|
||||||
} else if (event instanceof BlockEvent) {
|
} else if (event instanceof BlockEvent) {
|
||||||
removeAllByAccountId(((BlockEvent) event).getAccountId());
|
removeAllByAccountId(((BlockEvent) event).getAccountId());
|
||||||
} else if (event instanceof PreferenceChangedEvent) {
|
} else if (event instanceof PreferenceChangedEvent) {
|
||||||
|
@ -423,34 +399,21 @@ public class NotificationsFragment extends SFragment implements
|
||||||
final Notification notification = notifications.get(position).asRight();
|
final Notification notification = notifications.get(position).asRight();
|
||||||
final Status status = notification.getStatus();
|
final Status status = notification.getStatus();
|
||||||
Objects.requireNonNull(status, "Reblog on notification without status");
|
Objects.requireNonNull(status, "Reblog on notification without status");
|
||||||
timelineCases.reblog(status, reblog)
|
timelineCases.reblog(status.getId(), reblog)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.to(autoDisposable(from(this)))
|
.to(autoDisposable(from(this)))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(newStatus) -> setReblogForStatus(position, status, reblog),
|
(newStatus) -> setReblogForStatus(status.getId(), reblog),
|
||||||
(t) -> Log.d(getClass().getSimpleName(),
|
(t) -> Log.d(getClass().getSimpleName(),
|
||||||
"Failed to reblog status: " + status.getId(), t)
|
"Failed to reblog status: " + status.getId(), t)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setReblogForStatus(int position, Status status, boolean reblog) {
|
private void setReblogForStatus(String statusId, boolean reblog) {
|
||||||
status.setReblogged(reblog);
|
updateStatus(statusId, (s) -> {
|
||||||
|
s.setReblogged(reblog);
|
||||||
if (status.getReblog() != null) {
|
return s;
|
||||||
status.getReblog().setReblogged(reblog);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position);
|
|
||||||
|
|
||||||
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData());
|
|
||||||
viewDataBuilder.setReblogged(reblog);
|
|
||||||
|
|
||||||
NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete(
|
|
||||||
viewdata.getType(), viewdata.getId(), viewdata.getAccount(),
|
|
||||||
viewDataBuilder.createStatusViewData());
|
|
||||||
|
|
||||||
notifications.setPairedItem(position, newViewData);
|
|
||||||
updateAdapter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -458,34 +421,21 @@ public class NotificationsFragment extends SFragment implements
|
||||||
final Notification notification = notifications.get(position).asRight();
|
final Notification notification = notifications.get(position).asRight();
|
||||||
final Status status = notification.getStatus();
|
final Status status = notification.getStatus();
|
||||||
|
|
||||||
timelineCases.favourite(status, favourite)
|
timelineCases.favourite(status.getId(), favourite)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.to(autoDisposable(from(this)))
|
.to(autoDisposable(from(this)))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(newStatus) -> setFavouriteForStatus(position, status, favourite),
|
(newStatus) -> setFavouriteForStatus(status.getId(), favourite),
|
||||||
(t) -> Log.d(getClass().getSimpleName(),
|
(t) -> Log.d(getClass().getSimpleName(),
|
||||||
"Failed to favourite status: " + status.getId(), t)
|
"Failed to favourite status: " + status.getId(), t)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setFavouriteForStatus(int position, Status status, boolean favourite) {
|
private void setFavouriteForStatus(String statusId, boolean favourite) {
|
||||||
status.setFavourited(favourite);
|
updateStatus(statusId, (s) -> {
|
||||||
|
s.setFavourited(favourite);
|
||||||
if (status.getReblog() != null) {
|
return s;
|
||||||
status.getReblog().setFavourited(favourite);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position);
|
|
||||||
|
|
||||||
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData());
|
|
||||||
viewDataBuilder.setFavourited(favourite);
|
|
||||||
|
|
||||||
NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete(
|
|
||||||
viewdata.getType(), viewdata.getId(), viewdata.getAccount(),
|
|
||||||
viewDataBuilder.createStatusViewData());
|
|
||||||
|
|
||||||
notifications.setPairedItem(position, newViewData);
|
|
||||||
updateAdapter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -493,63 +443,38 @@ public class NotificationsFragment extends SFragment implements
|
||||||
final Notification notification = notifications.get(position).asRight();
|
final Notification notification = notifications.get(position).asRight();
|
||||||
final Status status = notification.getStatus();
|
final Status status = notification.getStatus();
|
||||||
|
|
||||||
timelineCases.bookmark(status, bookmark)
|
timelineCases.bookmark(status.getActionableId(), bookmark)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.to(autoDisposable(from(this)))
|
.to(autoDisposable(from(this)))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(newStatus) -> setBookmarkForStatus(position, status, bookmark),
|
(newStatus) -> setBookmarkForStatus(status.getId(), bookmark),
|
||||||
(t) -> Log.d(getClass().getSimpleName(),
|
(t) -> Log.d(getClass().getSimpleName(),
|
||||||
"Failed to bookmark status: " + status.getId(), t)
|
"Failed to bookmark status: " + status.getId(), t)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setBookmarkForStatus(int position, Status status, boolean bookmark) {
|
private void setBookmarkForStatus(String statusId, boolean bookmark) {
|
||||||
status.setBookmarked(bookmark);
|
updateStatus(statusId, (s) -> {
|
||||||
|
s.setBookmarked(bookmark);
|
||||||
if (status.getReblog() != null) {
|
return s;
|
||||||
status.getReblog().setBookmarked(bookmark);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position);
|
|
||||||
|
|
||||||
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData());
|
|
||||||
viewDataBuilder.setBookmarked(bookmark);
|
|
||||||
|
|
||||||
NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete(
|
|
||||||
viewdata.getType(), viewdata.getId(), viewdata.getAccount(),
|
|
||||||
viewDataBuilder.createStatusViewData());
|
|
||||||
|
|
||||||
notifications.setPairedItem(position, newViewData);
|
|
||||||
updateAdapter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
|
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
|
||||||
final Notification notification = notifications.get(position).asRight();
|
final Notification notification = notifications.get(position).asRight();
|
||||||
final Status status = notification.getStatus();
|
final Status status = notification.getStatus().getActionableStatus();
|
||||||
|
timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices)
|
||||||
timelineCases.voteInPoll(status, choices)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.to(autoDisposable(from(this)))
|
.to(autoDisposable(from(this)))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(newPoll) -> setVoteForPoll(position, newPoll),
|
(newPoll) -> setVoteForPoll(status, newPoll),
|
||||||
(t) -> Log.d(TAG,
|
(t) -> Log.d(TAG,
|
||||||
"Failed to vote in poll: " + status.getId(), t)
|
"Failed to vote in poll: " + status.getId(), t)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setVoteForPoll(int position, Poll poll) {
|
private void setVoteForPoll(Status status, Poll poll) {
|
||||||
|
updateStatus(status.getId(), (s) -> s.copyWithPoll(poll));
|
||||||
NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position);
|
|
||||||
|
|
||||||
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData());
|
|
||||||
viewDataBuilder.setPoll(poll);
|
|
||||||
|
|
||||||
NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete(
|
|
||||||
viewdata.getType(), viewdata.getId(), viewdata.getAccount(),
|
|
||||||
viewDataBuilder.createStatusViewData());
|
|
||||||
|
|
||||||
notifications.setPairedItem(position, newViewData);
|
|
||||||
updateAdapter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -562,13 +487,17 @@ public class NotificationsFragment extends SFragment implements
|
||||||
public void onViewMedia(int position, int attachmentIndex, @Nullable View view) {
|
public void onViewMedia(int position, int attachmentIndex, @Nullable View view) {
|
||||||
Notification notification = notifications.get(position).asRightOrNull();
|
Notification notification = notifications.get(position).asRightOrNull();
|
||||||
if (notification == null || notification.getStatus() == null) return;
|
if (notification == null || notification.getStatus() == null) return;
|
||||||
super.viewMedia(attachmentIndex, notification.getStatus(), view);
|
Status status = notification.getStatus();
|
||||||
|
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onViewThread(int position) {
|
public void onViewThread(int position) {
|
||||||
Notification notification = notifications.get(position).asRight();
|
Notification notification = notifications.get(position).asRight();
|
||||||
super.viewThread(notification.getStatus());
|
Status status = notification.getStatus();
|
||||||
|
if (status == null) return;
|
||||||
|
;
|
||||||
|
super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -579,30 +508,19 @@ public class NotificationsFragment extends SFragment implements
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onExpandedChange(boolean expanded, int position) {
|
public void onExpandedChange(boolean expanded, int position) {
|
||||||
NotificationViewData.Concrete old =
|
updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded));
|
||||||
(NotificationViewData.Concrete) notifications.getPairedItem(position);
|
|
||||||
StatusViewData.Concrete statusViewData =
|
|
||||||
new StatusViewData.Builder(old.getStatusViewData())
|
|
||||||
.setIsExpanded(expanded)
|
|
||||||
.createStatusViewData();
|
|
||||||
NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(),
|
|
||||||
old.getId(), old.getAccount(), statusViewData);
|
|
||||||
notifications.setPairedItem(position, notificationViewData);
|
|
||||||
updateAdapter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onContentHiddenChange(boolean isShowing, int position) {
|
public void onContentHiddenChange(boolean isShowing, int position) {
|
||||||
NotificationViewData.Concrete old =
|
updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing));
|
||||||
(NotificationViewData.Concrete) notifications.getPairedItem(position);
|
}
|
||||||
StatusViewData.Concrete statusViewData =
|
|
||||||
new StatusViewData.Builder(old.getStatusViewData())
|
private void setPinForStatus(String statusId, boolean pinned) {
|
||||||
.setIsShowingSensitiveContent(isShowing)
|
updateStatus(statusId, status -> {
|
||||||
.createStatusViewData();
|
status.copyWithPinned(pinned);
|
||||||
NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(),
|
return status;
|
||||||
old.getId(), old.getAccount(), statusViewData);
|
});
|
||||||
notifications.setPairedItem(position, notificationViewData);
|
|
||||||
updateAdapter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -628,42 +546,74 @@ public class NotificationsFragment extends SFragment implements
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onContentCollapsedChange(boolean isCollapsed, int position) {
|
public void onContentCollapsedChange(boolean isCollapsed, int position) {
|
||||||
if (position < 0 || position >= notifications.size()) {
|
updateViewDataAt(position, (vd) -> vd.copyWIthCollapsed(isCollapsed));
|
||||||
Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, notifications.size() - 1));
|
;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
NotificationViewData notification = notifications.getPairedItem(position);
|
private void updateStatus(String statusId, Function<Status, Status> mapper) {
|
||||||
if (!(notification instanceof NotificationViewData.Concrete)) {
|
int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() &&
|
||||||
Log.e(TAG, String.format(
|
s.asRight().getStatus() != null &&
|
||||||
"Expected NotificationViewData.Concrete, got %s instead at position: %d of %d",
|
s.asRight().getStatus().getId().equals(statusId));
|
||||||
notification == null ? "null" : notification.getClass().getSimpleName(),
|
if (index == -1) return;
|
||||||
|
|
||||||
|
// We have quite some graph here:
|
||||||
|
//
|
||||||
|
// Notification --------> Status
|
||||||
|
// ^
|
||||||
|
// |
|
||||||
|
// StatusViewData
|
||||||
|
// ^
|
||||||
|
// |
|
||||||
|
// NotificationViewData -----+
|
||||||
|
//
|
||||||
|
// So if we have "new" status we need to update all references to be sure that data is
|
||||||
|
// up-to-date:
|
||||||
|
// 1. update status
|
||||||
|
// 2. update notification
|
||||||
|
// 3. update statusViewData
|
||||||
|
// 4. update notificationViewData
|
||||||
|
|
||||||
|
Status oldStatus = notifications.get(index).asRight().getStatus();
|
||||||
|
NotificationViewData.Concrete oldViewData =
|
||||||
|
(NotificationViewData.Concrete) this.notifications.getPairedItem(index);
|
||||||
|
Status newStatus = mapper.apply(oldStatus);
|
||||||
|
Notification newNotification = this.notifications.get(index).asRight()
|
||||||
|
.copyWithStatus(newStatus);
|
||||||
|
StatusViewData.Concrete newStatusViewData =
|
||||||
|
Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus);
|
||||||
|
NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData);
|
||||||
|
|
||||||
|
notifications.set(index, new Either.Right<>(newNotification));
|
||||||
|
notifications.setPairedItem(index, newViewData);
|
||||||
|
|
||||||
|
updateAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateViewDataAt(int position,
|
||||||
|
Function<StatusViewData.Concrete, StatusViewData.Concrete> mapper) {
|
||||||
|
if (position < 0 || position >= notifications.size()) {
|
||||||
|
String message = String.format(
|
||||||
|
Locale.getDefault(),
|
||||||
|
"Tried to access out of bounds status position: %d of %d",
|
||||||
position,
|
position,
|
||||||
notifications.size() - 1
|
notifications.size() - 1
|
||||||
));
|
);
|
||||||
|
Log.e(TAG, message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
NotificationViewData someViewData = this.notifications.getPairedItem(position);
|
||||||
|
if (!(someViewData instanceof NotificationViewData.Concrete)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NotificationViewData.Concrete oldViewData = (NotificationViewData.Concrete) someViewData;
|
||||||
|
StatusViewData.Concrete oldStatusViewData = oldViewData.getStatusViewData();
|
||||||
|
if (oldStatusViewData == null) return;
|
||||||
|
|
||||||
StatusViewData.Concrete status = ((NotificationViewData.Concrete) notification).getStatusViewData();
|
NotificationViewData.Concrete newViewData =
|
||||||
StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status)
|
oldViewData.copyWithStatus(mapper.apply(oldStatusViewData));
|
||||||
.setCollapsed(isCollapsed)
|
notifications.setPairedItem(position, newViewData);
|
||||||
.createStatusViewData();
|
|
||||||
|
|
||||||
NotificationViewData.Concrete concreteNotification = (NotificationViewData.Concrete) notification;
|
|
||||||
NotificationViewData updatedNotification = new NotificationViewData.Concrete(
|
|
||||||
concreteNotification.getType(),
|
|
||||||
concreteNotification.getId(),
|
|
||||||
concreteNotification.getAccount(),
|
|
||||||
updatedStatus
|
|
||||||
);
|
|
||||||
notifications.setPairedItem(position, updatedNotification);
|
|
||||||
updateAdapter();
|
updateAdapter();
|
||||||
|
|
||||||
// Since we cannot notify to the RecyclerView right away because it may be scrolling
|
|
||||||
// we run this when the RecyclerView is done doing measurements and other calculations.
|
|
||||||
// To test this is not bs: try getting a notification while scrolling, without wrapping
|
|
||||||
// notifyItemChanged in a .post() call. App will crash.
|
|
||||||
recyclerView.post(() -> adapter.notifyItemChanged(position, notification));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -844,8 +794,11 @@ public class NotificationsFragment extends SFragment implements
|
||||||
for (Either<Placeholder, Notification> either : notifications) {
|
for (Either<Placeholder, Notification> either : notifications) {
|
||||||
Notification notification = either.asRightOrNull();
|
Notification notification = either.asRightOrNull();
|
||||||
if (notification != null && notification.getId().equals(notificationId)) {
|
if (notification != null && notification.getId().equals(notificationId)) {
|
||||||
super.viewThread(notification.getStatus());
|
Status status = notification.getStatus();
|
||||||
return;
|
if (status != null) {
|
||||||
|
super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl());
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.w(TAG, "Didn't find a notification for ID: " + notificationId);
|
Log.w(TAG, "Didn't find a notification for ID: " + notificationId);
|
||||||
|
@ -951,7 +904,7 @@ public class NotificationsFragment extends SFragment implements
|
||||||
}
|
}
|
||||||
|
|
||||||
Disposable notificationCall = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null)
|
Disposable notificationCall = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
response -> {
|
response -> {
|
||||||
|
|
|
@ -24,7 +24,6 @@ import android.content.Intent;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Environment;
|
import android.os.Environment;
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.Menu;
|
import android.view.Menu;
|
||||||
import android.view.MenuItem;
|
import android.view.MenuItem;
|
||||||
|
@ -33,7 +32,6 @@ import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.VisibleForTesting;
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.appcompat.widget.PopupMenu;
|
import androidx.appcompat.widget.PopupMenu;
|
||||||
import androidx.core.app.ActivityOptionsCompat;
|
import androidx.core.app.ActivityOptionsCompat;
|
||||||
|
@ -55,8 +53,6 @@ import com.keylesspalace.tusky.db.AccountEntity;
|
||||||
import com.keylesspalace.tusky.db.AccountManager;
|
import com.keylesspalace.tusky.db.AccountManager;
|
||||||
import com.keylesspalace.tusky.di.Injectable;
|
import com.keylesspalace.tusky.di.Injectable;
|
||||||
import com.keylesspalace.tusky.entity.Attachment;
|
import com.keylesspalace.tusky.entity.Attachment;
|
||||||
import com.keylesspalace.tusky.entity.Filter;
|
|
||||||
import com.keylesspalace.tusky.entity.PollOption;
|
|
||||||
import com.keylesspalace.tusky.entity.Status;
|
import com.keylesspalace.tusky.entity.Status;
|
||||||
import com.keylesspalace.tusky.network.MastodonApi;
|
import com.keylesspalace.tusky.network.MastodonApi;
|
||||||
import com.keylesspalace.tusky.network.TimelineCases;
|
import com.keylesspalace.tusky.network.TimelineCases;
|
||||||
|
@ -64,20 +60,14 @@ import com.keylesspalace.tusky.util.LinkHelper;
|
||||||
import com.keylesspalace.tusky.view.MuteAccountDialog;
|
import com.keylesspalace.tusky.view.MuteAccountDialog;
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData;
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import javax.inject.Inject;
|
import javax.inject.Inject;
|
||||||
|
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
import kotlin.Unit;
|
import kotlin.Unit;
|
||||||
import retrofit2.Call;
|
|
||||||
import retrofit2.Callback;
|
|
||||||
import retrofit2.Response;
|
|
||||||
|
|
||||||
import static autodispose2.AutoDispose.autoDisposable;
|
import static autodispose2.AutoDispose.autoDisposable;
|
||||||
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
|
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
|
||||||
|
@ -96,11 +86,6 @@ public abstract class SFragment extends Fragment implements Injectable {
|
||||||
|
|
||||||
private BottomSheetActivity bottomSheetActivity;
|
private BottomSheetActivity bottomSheetActivity;
|
||||||
|
|
||||||
private static List<Filter> filters;
|
|
||||||
private boolean filterRemoveRegex;
|
|
||||||
private Matcher filterRemoveRegexMatcher;
|
|
||||||
private static final Matcher alphanumeric = Pattern.compile("^\\w+$").matcher("");
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public MastodonApi mastodonApi;
|
public MastodonApi mastodonApi;
|
||||||
@Inject
|
@Inject
|
||||||
|
@ -131,9 +116,8 @@ public abstract class SFragment extends Fragment implements Injectable {
|
||||||
bottomSheetActivity.viewAccount(status.getAccount().getId());
|
bottomSheetActivity.viewAccount(status.getAccount().getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void viewThread(Status status) {
|
protected void viewThread(String statusId, @Nullable String statusUrl) {
|
||||||
Status actionableStatus = status.getActionableStatus();
|
bottomSheetActivity.viewThread(statusId, statusUrl);
|
||||||
bottomSheetActivity.viewThread(actionableStatus.getId(), actionableStatus.getUrl());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void viewAccount(String accountId) {
|
protected void viewAccount(String accountId) {
|
||||||
|
@ -149,7 +133,7 @@ public abstract class SFragment extends Fragment implements Injectable {
|
||||||
Status actionableStatus = status.getActionableStatus();
|
Status actionableStatus = status.getActionableStatus();
|
||||||
Status.Visibility replyVisibility = actionableStatus.getVisibility();
|
Status.Visibility replyVisibility = actionableStatus.getVisibility();
|
||||||
String contentWarning = actionableStatus.getSpoilerText();
|
String contentWarning = actionableStatus.getSpoilerText();
|
||||||
Status.Mention[] mentions = actionableStatus.getMentions();
|
List<Status.Mention> mentions = actionableStatus.getMentions();
|
||||||
Set<String> mentionedUsernames = new LinkedHashSet<>();
|
Set<String> mentionedUsernames = new LinkedHashSet<>();
|
||||||
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
|
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
|
||||||
String loggedInUsername = null;
|
String loggedInUsername = null;
|
||||||
|
@ -316,11 +300,11 @@ public abstract class SFragment extends Fragment implements Injectable {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case R.id.pin: {
|
case R.id.pin: {
|
||||||
timelineCases.pin(status, !status.isPinned());
|
timelineCases.pin(status.getId(), !status.isPinned());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case R.id.status_mute_conversation: {
|
case R.id.status_mute_conversation: {
|
||||||
timelineCases.muteConversation(status, status.getMuted() == null || !status.getMuted())
|
timelineCases.muteConversation(status.getId(), status.getMuted() == null || !status.getMuted())
|
||||||
.onErrorReturnItem(status)
|
.onErrorReturnItem(status)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||||
|
@ -335,12 +319,12 @@ public abstract class SFragment extends Fragment implements Injectable {
|
||||||
|
|
||||||
private void onMute(String accountId, String accountUsername) {
|
private void onMute(String accountId, String accountUsername) {
|
||||||
MuteAccountDialog.showMuteAccountDialog(
|
MuteAccountDialog.showMuteAccountDialog(
|
||||||
this.getActivity(),
|
this.getActivity(),
|
||||||
accountUsername,
|
accountUsername,
|
||||||
(notifications, duration) -> {
|
(notifications, duration) -> {
|
||||||
timelineCases.mute(accountId, notifications, duration);
|
timelineCases.mute(accountId, notifications, duration);
|
||||||
return Unit.INSTANCE;
|
return Unit.INSTANCE;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -352,7 +336,7 @@ public abstract class SFragment extends Fragment implements Injectable {
|
||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean accountIsInMentions(AccountEntity account, Status.Mention[] mentions) {
|
private static boolean accountIsInMentions(AccountEntity account, List<Status.Mention> mentions) {
|
||||||
if (account == null) {
|
if (account == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -368,20 +352,18 @@ public abstract class SFragment extends Fragment implements Injectable {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void viewMedia(int urlIndex, Status status, @Nullable View view) {
|
protected void viewMedia(int urlIndex, List<AttachmentViewData> attachments, @Nullable View view) {
|
||||||
final Status actionable = status.getActionableStatus();
|
final AttachmentViewData active = attachments.get(urlIndex);
|
||||||
final Attachment active = actionable.getAttachments().get(urlIndex);
|
Attachment.Type type = active.getAttachment().getType();
|
||||||
Attachment.Type type = active.getType();
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case GIFV:
|
case GIFV:
|
||||||
case VIDEO:
|
case VIDEO:
|
||||||
case IMAGE:
|
case IMAGE:
|
||||||
case AUDIO: {
|
case AUDIO: {
|
||||||
final List<AttachmentViewData> attachments = AttachmentViewData.list(actionable);
|
|
||||||
final Intent intent = ViewMediaActivity.newIntent(getContext(), attachments,
|
final Intent intent = ViewMediaActivity.newIntent(getContext(), attachments,
|
||||||
urlIndex);
|
urlIndex);
|
||||||
if (view != null) {
|
if (view != null) {
|
||||||
String url = active.getUrl();
|
String url = active.getAttachment().getUrl();
|
||||||
ViewCompat.setTransitionName(view, url);
|
ViewCompat.setTransitionName(view, url);
|
||||||
ActivityOptionsCompat options =
|
ActivityOptionsCompat options =
|
||||||
ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(),
|
ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(),
|
||||||
|
@ -394,7 +376,7 @@ public abstract class SFragment extends Fragment implements Injectable {
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
case UNKNOWN: {
|
case UNKNOWN: {
|
||||||
LinkHelper.openLink(active.getUrl(), getContext());
|
LinkHelper.openLink(active.getAttachment().getUrl(), getContext());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -510,83 +492,4 @@ public abstract class SFragment extends Fragment implements Injectable {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
|
|
||||||
public void reloadFilters(boolean forceRefresh) {
|
|
||||||
if (filters != null && !forceRefresh) {
|
|
||||||
applyFilters(forceRefresh);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
mastodonApi.getFilters().enqueue(new Callback<List<Filter>>() {
|
|
||||||
@Override
|
|
||||||
public void onResponse(@NonNull Call<List<Filter>> call, @NonNull Response<List<Filter>> response) {
|
|
||||||
filters = response.body();
|
|
||||||
if (response.isSuccessful() && filters != null) {
|
|
||||||
applyFilters(forceRefresh);
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Error getting filters from server");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onFailure(@NonNull Call<List<Filter>> call, @NonNull Throwable t) {
|
|
||||||
Log.e(TAG, "Error getting filters from server", t);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected boolean filterIsRelevant(@NonNull Filter filter) {
|
|
||||||
// Called when building local filter expression
|
|
||||||
// Override to select relevant filters for your fragment
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void refreshAfterApplyingFilters() {
|
|
||||||
// Called after filters are updated
|
|
||||||
// Override to refresh your fragment
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
|
|
||||||
public boolean shouldFilterStatus(Status status) {
|
|
||||||
|
|
||||||
if (filterRemoveRegex && status.getPoll() != null) {
|
|
||||||
for (PollOption option : status.getPoll().getOptions()) {
|
|
||||||
if (filterRemoveRegexMatcher.reset(option.getTitle()).find()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (filterRemoveRegex && (filterRemoveRegexMatcher.reset(status.getActionableStatus().getContent()).find()
|
|
||||||
|| (!status.getSpoilerText().isEmpty() && filterRemoveRegexMatcher.reset(status.getActionableStatus().getSpoilerText()).find())));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyFilters(boolean refresh) {
|
|
||||||
List<String> tokens = new ArrayList<>();
|
|
||||||
for (Filter filter : filters) {
|
|
||||||
if (filterIsRelevant(filter)) {
|
|
||||||
tokens.add(filterToRegexToken(filter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filterRemoveRegex = !tokens.isEmpty();
|
|
||||||
if (filterRemoveRegex) {
|
|
||||||
filterRemoveRegexMatcher = Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE).matcher("");
|
|
||||||
}
|
|
||||||
if (refresh) {
|
|
||||||
refreshAfterApplyingFilters();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String filterToRegexToken(Filter filter) {
|
|
||||||
String phrase = filter.getPhrase();
|
|
||||||
String quotedPhrase = Pattern.quote(phrase);
|
|
||||||
return (filter.getWholeWord() && alphanumeric.reset(phrase).matches()) ? // "whole word" should only apply to alphanumeric filters, #1543
|
|
||||||
String.format("(^|\\W)%s($|\\W)", quotedPhrase) :
|
|
||||||
quotedPhrase;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void flushFilters() {
|
|
||||||
filters = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -28,7 +28,6 @@ import android.view.ViewGroup;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.arch.core.util.Function;
|
import androidx.arch.core.util.Function;
|
||||||
import androidx.core.util.Pair;
|
|
||||||
import androidx.lifecycle.Lifecycle;
|
import androidx.lifecycle.Lifecycle;
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration;
|
import androidx.recyclerview.widget.DividerItemDecoration;
|
||||||
|
@ -48,6 +47,7 @@ import com.keylesspalace.tusky.appstore.BlockEvent;
|
||||||
import com.keylesspalace.tusky.appstore.BookmarkEvent;
|
import com.keylesspalace.tusky.appstore.BookmarkEvent;
|
||||||
import com.keylesspalace.tusky.appstore.EventHub;
|
import com.keylesspalace.tusky.appstore.EventHub;
|
||||||
import com.keylesspalace.tusky.appstore.FavoriteEvent;
|
import com.keylesspalace.tusky.appstore.FavoriteEvent;
|
||||||
|
import com.keylesspalace.tusky.appstore.PinEvent;
|
||||||
import com.keylesspalace.tusky.appstore.ReblogEvent;
|
import com.keylesspalace.tusky.appstore.ReblogEvent;
|
||||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
|
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
|
||||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent;
|
import com.keylesspalace.tusky.appstore.StatusDeletedEvent;
|
||||||
|
@ -56,6 +56,7 @@ import com.keylesspalace.tusky.entity.Filter;
|
||||||
import com.keylesspalace.tusky.entity.Poll;
|
import com.keylesspalace.tusky.entity.Poll;
|
||||||
import com.keylesspalace.tusky.entity.Status;
|
import com.keylesspalace.tusky.entity.Status;
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||||
|
import com.keylesspalace.tusky.network.FilterModel;
|
||||||
import com.keylesspalace.tusky.network.MastodonApi;
|
import com.keylesspalace.tusky.network.MastodonApi;
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys;
|
import com.keylesspalace.tusky.settings.PrefKeys;
|
||||||
import com.keylesspalace.tusky.util.CardViewMode;
|
import com.keylesspalace.tusky.util.CardViewMode;
|
||||||
|
@ -64,6 +65,7 @@ import com.keylesspalace.tusky.util.PairedList;
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||||
import com.keylesspalace.tusky.util.ViewDataUtils;
|
import com.keylesspalace.tusky.util.ViewDataUtils;
|
||||||
import com.keylesspalace.tusky.view.ConversationLineItemDecoration;
|
import com.keylesspalace.tusky.view.ConversationLineItemDecoration;
|
||||||
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData;
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
@ -73,7 +75,9 @@ import java.util.Locale;
|
||||||
|
|
||||||
import javax.inject.Inject;
|
import javax.inject.Inject;
|
||||||
|
|
||||||
|
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider;
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
|
import kotlin.collections.CollectionsKt;
|
||||||
|
|
||||||
import static autodispose2.AutoDispose.autoDisposable;
|
import static autodispose2.AutoDispose.autoDisposable;
|
||||||
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
|
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
|
||||||
|
@ -86,6 +90,8 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
public MastodonApi mastodonApi;
|
public MastodonApi mastodonApi;
|
||||||
@Inject
|
@Inject
|
||||||
public EventHub eventHub;
|
public EventHub eventHub;
|
||||||
|
@Inject
|
||||||
|
public FilterModel filterModel;
|
||||||
|
|
||||||
private SwipeRefreshLayout swipeRefreshLayout;
|
private SwipeRefreshLayout swipeRefreshLayout;
|
||||||
private RecyclerView recyclerView;
|
private RecyclerView recyclerView;
|
||||||
|
@ -163,7 +169,7 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
recyclerView.addItemDecoration(new ConversationLineItemDecoration(context));
|
recyclerView.addItemDecoration(new ConversationLineItemDecoration(context));
|
||||||
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
|
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
|
||||||
alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler();
|
alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler();
|
||||||
reloadFilters(false);
|
reloadFilters();
|
||||||
|
|
||||||
recyclerView.setAdapter(adapter);
|
recyclerView.setAdapter(adapter);
|
||||||
|
|
||||||
|
@ -190,6 +196,8 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
handleReblogEvent((ReblogEvent) event);
|
handleReblogEvent((ReblogEvent) event);
|
||||||
} else if (event instanceof BookmarkEvent) {
|
} else if (event instanceof BookmarkEvent) {
|
||||||
handleBookmarkEvent((BookmarkEvent) event);
|
handleBookmarkEvent((BookmarkEvent) event);
|
||||||
|
} else if (event instanceof PinEvent) {
|
||||||
|
handlePinEvent(((PinEvent) event));
|
||||||
} else if (event instanceof BlockEvent) {
|
} else if (event instanceof BlockEvent) {
|
||||||
removeAllByAccountId(((BlockEvent) event).getAccountId());
|
removeAllByAccountId(((BlockEvent) event).getAccountId());
|
||||||
} else if (event instanceof StatusComposedEvent) {
|
} else if (event instanceof StatusComposedEvent) {
|
||||||
|
@ -203,13 +211,8 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
public void onRevealPressed() {
|
public void onRevealPressed() {
|
||||||
boolean allExpanded = allExpanded();
|
boolean allExpanded = allExpanded();
|
||||||
for (int i = 0; i < statuses.size(); i++) {
|
for (int i = 0; i < statuses.size(); i++) {
|
||||||
StatusViewData.Concrete newViewData =
|
updateViewData(i, statuses.getPairedItem(i).copyWithExpanded(!allExpanded));
|
||||||
new StatusViewData.Concrete.Builder(statuses.getPairedItem(i))
|
|
||||||
.setIsExpanded(!allExpanded)
|
|
||||||
.createStatusViewData();
|
|
||||||
statuses.setPairedItem(i, newViewData);
|
|
||||||
}
|
}
|
||||||
adapter.setStatuses(statuses.getPairedCopy());
|
|
||||||
updateRevealIcon();
|
updateRevealIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -239,11 +242,11 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
public void onReblog(final boolean reblog, final int position) {
|
public void onReblog(final boolean reblog, final int position) {
|
||||||
final Status status = statuses.get(position);
|
final Status status = statuses.get(position);
|
||||||
|
|
||||||
timelineCases.reblog(statuses.get(position), reblog)
|
timelineCases.reblog(statuses.get(position).getId(), reblog)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.to(autoDisposable(from(this)))
|
.to(autoDisposable(from(this)))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(newStatus) -> updateStatus(position, newStatus),
|
this::replaceStatus,
|
||||||
(t) -> Log.d(TAG,
|
(t) -> Log.d(TAG,
|
||||||
"Failed to reblog status: " + status.getId(), t)
|
"Failed to reblog status: " + status.getId(), t)
|
||||||
);
|
);
|
||||||
|
@ -253,11 +256,11 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
public void onFavourite(final boolean favourite, final int position) {
|
public void onFavourite(final boolean favourite, final int position) {
|
||||||
final Status status = statuses.get(position);
|
final Status status = statuses.get(position);
|
||||||
|
|
||||||
timelineCases.favourite(statuses.get(position), favourite)
|
timelineCases.favourite(statuses.get(position).getId(), favourite)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.to(autoDisposable(from(this)))
|
.to(autoDisposable(from(this)))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(newStatus) -> updateStatus(position, newStatus),
|
this::replaceStatus,
|
||||||
(t) -> Log.d(TAG,
|
(t) -> Log.d(TAG,
|
||||||
"Failed to favourite status: " + status.getId(), t)
|
"Failed to favourite status: " + status.getId(), t)
|
||||||
);
|
);
|
||||||
|
@ -267,32 +270,29 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
public void onBookmark(final boolean bookmark, final int position) {
|
public void onBookmark(final boolean bookmark, final int position) {
|
||||||
final Status status = statuses.get(position);
|
final Status status = statuses.get(position);
|
||||||
|
|
||||||
timelineCases.bookmark(statuses.get(position), bookmark)
|
timelineCases.bookmark(statuses.get(position).getId(), bookmark)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.to(autoDisposable(from(this)))
|
.to(autoDisposable(from(this)))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(newStatus) -> updateStatus(position, newStatus),
|
this::replaceStatus,
|
||||||
(t) -> Log.d(TAG,
|
(t) -> Log.d(TAG,
|
||||||
"Failed to bookmark status: " + status.getId(), t)
|
"Failed to bookmark status: " + status.getId(), t)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateStatus(int position, Status status) {
|
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()) {
|
if (position >= 0 && position < statuses.size()) {
|
||||||
|
Status oldStatus = statuses.get(position);
|
||||||
Status actionableStatus = status.getActionableStatus();
|
Status newStatus = mapper.apply(oldStatus);
|
||||||
|
StatusViewData.Concrete oldViewData = statuses.getPairedItem(position);
|
||||||
StatusViewData.Concrete viewData = new StatusViewData.Builder(statuses.getPairedItem(position))
|
statuses.set(position, newStatus);
|
||||||
.setReblogged(actionableStatus.getReblogged())
|
updateViewData(position, oldViewData.copyWithStatus(newStatus));
|
||||||
.setReblogsCount(actionableStatus.getReblogsCount())
|
|
||||||
.setFavourited(actionableStatus.getFavourited())
|
|
||||||
.setBookmarked(actionableStatus.getBookmarked())
|
|
||||||
.setFavouritesCount(actionableStatus.getFavouritesCount())
|
|
||||||
.createStatusViewData();
|
|
||||||
statuses.setPairedItem(position, viewData);
|
|
||||||
|
|
||||||
adapter.setItem(position, viewData, true);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -304,7 +304,7 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
@Override
|
@Override
|
||||||
public void onViewMedia(int position, int attachmentIndex, @NonNull View view) {
|
public void onViewMedia(int position, int attachmentIndex, @NonNull View view) {
|
||||||
Status status = statuses.get(position);
|
Status status = statuses.get(position);
|
||||||
super.viewMedia(attachmentIndex, status, view);
|
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -314,7 +314,7 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
// If already viewing this thread, don't reopen it.
|
// If already viewing this thread, don't reopen it.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
super.viewThread(status);
|
super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -325,21 +325,22 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onExpandedChange(boolean expanded, int position) {
|
public void onExpandedChange(boolean expanded, int position) {
|
||||||
StatusViewData.Concrete newViewData =
|
updateViewData(
|
||||||
new StatusViewData.Builder(statuses.getPairedItem(position))
|
position,
|
||||||
.setIsExpanded(expanded)
|
statuses.getPairedItem(position).copyWithExpanded(expanded)
|
||||||
.createStatusViewData();
|
);
|
||||||
statuses.setPairedItem(position, newViewData);
|
|
||||||
adapter.setItem(position, newViewData, true);
|
|
||||||
updateRevealIcon();
|
updateRevealIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onContentHiddenChange(boolean isShowing, int position) {
|
public void onContentHiddenChange(boolean isShowing, int position) {
|
||||||
StatusViewData.Concrete newViewData =
|
updateViewData(
|
||||||
new StatusViewData.Builder(statuses.getPairedItem(position))
|
position,
|
||||||
.setIsShowingSensitiveContent(isShowing)
|
statuses.getPairedItem(position).copyWithShowingContent(isShowing)
|
||||||
.createStatusViewData();
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateViewData(int position, StatusViewData.Concrete newViewData) {
|
||||||
statuses.setPairedItem(position, newViewData);
|
statuses.setPairedItem(position, newViewData);
|
||||||
adapter.setItem(position, newViewData, true);
|
adapter.setItem(position, newViewData, true);
|
||||||
}
|
}
|
||||||
|
@ -365,28 +366,11 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onContentCollapsedChange(boolean isCollapsed, int position) {
|
public void onContentCollapsedChange(boolean isCollapsed, int position) {
|
||||||
if (position < 0 || position >= statuses.size()) {
|
adapter.setItem(
|
||||||
Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size() - 1));
|
position,
|
||||||
return;
|
statuses.getPairedItem(position).copyWIthCollapsed(isCollapsed),
|
||||||
}
|
true
|
||||||
|
);
|
||||||
StatusViewData.Concrete status = statuses.getPairedItem(position);
|
|
||||||
if (status == null) {
|
|
||||||
// Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't
|
|
||||||
// check for null values when adding values to it although this doesn't seem to be an issue.
|
|
||||||
Log.e(TAG, String.format(
|
|
||||||
"Expected StatusViewData.Concrete, got null instead at position: %d of %d",
|
|
||||||
position,
|
|
||||||
statuses.size() - 1
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status)
|
|
||||||
.setCollapsed(isCollapsed)
|
|
||||||
.createStatusViewData();
|
|
||||||
statuses.setPairedItem(position, updatedStatus);
|
|
||||||
recyclerView.post(() -> adapter.setItem(position, updatedStatus, true));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -412,28 +396,21 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
|
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
|
||||||
final Status status = statuses.get(position).getActionableStatus();
|
final Status status = statuses.get(position).getActionableStatus();
|
||||||
|
|
||||||
setVoteForPoll(position, status.getPoll().votedCopy(choices));
|
setVoteForPoll(status.getId(), status.getPoll().votedCopy(choices));
|
||||||
|
|
||||||
timelineCases.voteInPoll(status, choices)
|
timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.to(autoDisposable(from(this)))
|
.to(autoDisposable(from(this)))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(newPoll) -> setVoteForPoll(position, newPoll),
|
(newPoll) -> setVoteForPoll(status.getId(), newPoll),
|
||||||
(t) -> Log.d(TAG,
|
(t) -> Log.d(TAG,
|
||||||
"Failed to vote in poll: " + status.getId(), t)
|
"Failed to vote in poll: " + status.getId(), t)
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setVoteForPoll(int position, Poll newPoll) {
|
private void setVoteForPoll(String statusId, Poll newPoll) {
|
||||||
|
updateStatus(statusId, s -> s.copyWithPoll(newPoll));
|
||||||
StatusViewData.Concrete viewData = statuses.getPairedItem(position);
|
|
||||||
|
|
||||||
StatusViewData.Concrete newViewData = new StatusViewData.Builder(viewData)
|
|
||||||
.setPoll(newPoll)
|
|
||||||
.createStatusViewData();
|
|
||||||
statuses.setPairedItem(position, newViewData);
|
|
||||||
adapter.setItem(position, newViewData, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void removeAllByAccountId(String accountId) {
|
private void removeAllByAccountId(String accountId) {
|
||||||
|
@ -530,7 +507,7 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
|
|
||||||
ArrayList<Status> ancestors = new ArrayList<>();
|
ArrayList<Status> ancestors = new ArrayList<>();
|
||||||
for (Status status : unfilteredAncestors)
|
for (Status status : unfilteredAncestors)
|
||||||
if (!shouldFilterStatus(status))
|
if (!filterModel.shouldFilterStatus(status))
|
||||||
ancestors.add(status);
|
ancestors.add(status);
|
||||||
|
|
||||||
// Insert newly fetched ancestors
|
// Insert newly fetched ancestors
|
||||||
|
@ -560,7 +537,7 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
|
|
||||||
ArrayList<Status> descendants = new ArrayList<>();
|
ArrayList<Status> descendants = new ArrayList<>();
|
||||||
for (Status status : unfilteredDescendants)
|
for (Status status : unfilteredDescendants)
|
||||||
if (!shouldFilterStatus(status))
|
if (!filterModel.shouldFilterStatus(status))
|
||||||
descendants.add(status);
|
descendants.add(status);
|
||||||
|
|
||||||
// Insert newly fetched descendants
|
// Insert newly fetched descendants
|
||||||
|
@ -581,71 +558,31 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleFavEvent(FavoriteEvent event) {
|
private void handleFavEvent(FavoriteEvent event) {
|
||||||
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
|
updateStatus(event.getStatusId(), (s) -> {
|
||||||
if (posAndStatus == null) return;
|
s.setFavourited(event.getFavourite());
|
||||||
|
return s;
|
||||||
boolean favourite = event.getFavourite();
|
});
|
||||||
posAndStatus.second.setFavourited(favourite);
|
|
||||||
|
|
||||||
if (posAndStatus.second.getReblog() != null) {
|
|
||||||
posAndStatus.second.getReblog().setFavourited(favourite);
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first);
|
|
||||||
|
|
||||||
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata));
|
|
||||||
viewDataBuilder.setFavourited(favourite);
|
|
||||||
|
|
||||||
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
|
|
||||||
|
|
||||||
statuses.setPairedItem(posAndStatus.first, newViewData);
|
|
||||||
adapter.setItem(posAndStatus.first, newViewData, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleReblogEvent(ReblogEvent event) {
|
private void handleReblogEvent(ReblogEvent event) {
|
||||||
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
|
updateStatus(event.getStatusId(), (s) -> {
|
||||||
if (posAndStatus == null) return;
|
s.setReblogged(event.getReblog());
|
||||||
|
return s;
|
||||||
boolean reblog = event.getReblog();
|
});
|
||||||
posAndStatus.second.setReblogged(reblog);
|
|
||||||
|
|
||||||
if (posAndStatus.second.getReblog() != null) {
|
|
||||||
posAndStatus.second.getReblog().setReblogged(reblog);
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first);
|
|
||||||
|
|
||||||
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata));
|
|
||||||
viewDataBuilder.setReblogged(reblog);
|
|
||||||
|
|
||||||
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
|
|
||||||
|
|
||||||
statuses.setPairedItem(posAndStatus.first, newViewData);
|
|
||||||
adapter.setItem(posAndStatus.first, newViewData, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleBookmarkEvent(BookmarkEvent event) {
|
private void handleBookmarkEvent(BookmarkEvent event) {
|
||||||
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
|
updateStatus(event.getStatusId(), (s) -> {
|
||||||
if (posAndStatus == null) return;
|
s.setBookmarked(event.getBookmark());
|
||||||
|
return s;
|
||||||
boolean bookmark = event.getBookmark();
|
});
|
||||||
posAndStatus.second.setBookmarked(bookmark);
|
|
||||||
|
|
||||||
if (posAndStatus.second.getReblog() != null) {
|
|
||||||
posAndStatus.second.getReblog().setBookmarked(bookmark);
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first);
|
|
||||||
|
|
||||||
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata));
|
|
||||||
viewDataBuilder.setBookmarked(bookmark);
|
|
||||||
|
|
||||||
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
|
|
||||||
|
|
||||||
statuses.setPairedItem(posAndStatus.first, newViewData);
|
|
||||||
adapter.setItem(posAndStatus.first, newViewData, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handlePinEvent(PinEvent event) {
|
||||||
|
updateStatus(event.getStatusId(), (s) -> s.copyWithPinned(event.getPinned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private void handleStatusComposedEvent(StatusComposedEvent event) {
|
private void handleStatusComposedEvent(StatusComposedEvent event) {
|
||||||
Status eventStatus = event.getStatus();
|
Status eventStatus = event.getStatus();
|
||||||
if (eventStatus.getInReplyToId() == null) return;
|
if (eventStatus.getInReplyToId() == null) return;
|
||||||
|
@ -671,23 +608,16 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleStatusDeletedEvent(StatusDeletedEvent event) {
|
private void handleStatusDeletedEvent(StatusDeletedEvent event) {
|
||||||
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
|
int index = this.indexOfStatus(event.getStatusId());
|
||||||
if (posAndStatus == null) return;
|
if (index != -1) {
|
||||||
|
statuses.remove(index);
|
||||||
@SuppressWarnings("ConstantConditions")
|
adapter.removeItem(index);
|
||||||
int pos = posAndStatus.first;
|
}
|
||||||
statuses.remove(pos);
|
|
||||||
adapter.removeItem(pos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
|
||||||
private Pair<Integer, Status> findStatusAndPos(@NonNull String statusId) {
|
private int indexOfStatus(String statusId) {
|
||||||
for (int i = 0; i < statuses.size(); i++) {
|
return CollectionsKt.indexOfFirst(this.statuses, (s) -> s.getId().equals(statusId));
|
||||||
if (statusId.equals(statuses.get(i).getId())) {
|
|
||||||
return new Pair<>(i, statuses.get(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateRevealIcon() {
|
private void updateRevealIcon() {
|
||||||
|
@ -710,13 +640,25 @@ public final class ViewThreadFragment extends SFragment implements
|
||||||
ViewThreadActivity.REVEAL_BUTTON_REVEAL);
|
ViewThreadActivity.REVEAL_BUTTON_REVEAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private void reloadFilters() {
|
||||||
protected boolean filterIsRelevant(@NonNull Filter filter) {
|
mastodonApi.getFilters()
|
||||||
return filter.getContext().contains(Filter.THREAD);
|
.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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private void applyFilters() {
|
||||||
protected void refreshAfterApplyingFilters() {
|
CollectionsKt.removeAll(this.statuses, filterModel::shouldFilterStatus);
|
||||||
onRefresh();
|
adapter.setStatuses(this.statuses.getPairedCopy());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ public interface StatusActionListener extends LinkListener {
|
||||||
void onOpenReblog(int position);
|
void onOpenReblog(int position);
|
||||||
void onExpandedChange(boolean expanded, int position);
|
void onExpandedChange(boolean expanded, int position);
|
||||||
void onContentHiddenChange(boolean isShowing, int position);
|
void onContentHiddenChange(boolean isShowing, int position);
|
||||||
void onLoadMore(int position);
|
void onLoadMore(int position);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
|
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
package com.keylesspalace.tusky.network
|
||||||
|
|
||||||
|
import android.text.TextUtils
|
||||||
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-stop for status filtering logic using Mastodon's filters.
|
||||||
|
*
|
||||||
|
* 1. You init with [initWithFilters], this compiles regex pattern.
|
||||||
|
* 2. You call [shouldFilterStatus] to figure out what to display when you load statuses.
|
||||||
|
*/
|
||||||
|
class FilterModel @Inject constructor() {
|
||||||
|
private var pattern: Pattern? = null
|
||||||
|
|
||||||
|
fun initWithFilters(filters: List<Filter>) {
|
||||||
|
this.pattern = makeFilter(filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldFilterStatus(status: Status): Boolean {
|
||||||
|
// Patterns are expensive and thread-safe, matchers are neither.
|
||||||
|
val matcher = pattern?.matcher("") ?: return false
|
||||||
|
|
||||||
|
if (status.poll != null) {
|
||||||
|
val pollMatches = status.poll.options.any { matcher.reset(it.title).find() }
|
||||||
|
if (pollMatches) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val spoilerText = status.actionableStatus.spoilerText
|
||||||
|
return (matcher.reset(status.actionableStatus.content).find() ||
|
||||||
|
spoilerText.isNotEmpty() && matcher.reset(spoilerText).find())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterToRegexToken(filter: Filter): String? {
|
||||||
|
val phrase = filter.phrase
|
||||||
|
val quotedPhrase = Pattern.quote(phrase)
|
||||||
|
return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) {
|
||||||
|
String.format("(^|\\W)%s($|\\W)", quotedPhrase)
|
||||||
|
} else {
|
||||||
|
quotedPhrase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeFilter(filters: List<Filter>): Pattern? {
|
||||||
|
if (filters.isEmpty()) return null
|
||||||
|
val tokens = filters.map { filterToRegexToken(it) }
|
||||||
|
|
||||||
|
return Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val ALPHANUMERIC = Pattern.compile("^\\w+$")
|
||||||
|
}
|
||||||
|
}
|
|
@ -49,7 +49,7 @@ interface MastodonApi {
|
||||||
fun getInstance(): Single<Instance>
|
fun getInstance(): Single<Instance>
|
||||||
|
|
||||||
@GET("api/v1/filters")
|
@GET("api/v1/filters")
|
||||||
fun getFilters(): Call<List<Filter>>
|
fun getFilters(): Single<List<Filter>>
|
||||||
|
|
||||||
@GET("api/v1/timelines/home")
|
@GET("api/v1/timelines/home")
|
||||||
fun homeTimeline(
|
fun homeTimeline(
|
||||||
|
|
|
@ -30,20 +30,20 @@ import java.lang.IllegalStateException
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface TimelineCases {
|
interface TimelineCases {
|
||||||
fun reblog(status: Status, reblog: Boolean): Single<Status>
|
fun reblog(statusId: String, reblog: Boolean): Single<Status>
|
||||||
fun favourite(status: Status, favourite: Boolean): Single<Status>
|
fun favourite(statusId: String, favourite: Boolean): Single<Status>
|
||||||
fun bookmark(status: Status, bookmark: Boolean): Single<Status>
|
fun bookmark(statusId: String, bookmark: Boolean): Single<Status>
|
||||||
fun mute(id: String, notifications: Boolean, duration: Int?)
|
fun mute(statusId: String, notifications: Boolean, duration: Int?)
|
||||||
fun block(id: String)
|
fun block(statusId: String)
|
||||||
fun delete(id: String): Single<DeletedStatus>
|
fun delete(statusId: String): Single<DeletedStatus>
|
||||||
fun pin(status: Status, pin: Boolean)
|
fun pin(statusId: String, pin: Boolean): Single<Status>
|
||||||
fun voteInPoll(status: Status, choices: List<Int>): Single<Poll>
|
fun voteInPoll(statusId: String, pollId: String, choices: List<Int>): Single<Poll>
|
||||||
fun muteConversation(status: Status, mute: Boolean): Single<Status>
|
fun muteConversation(statusId: String, mute: Boolean): Single<Status>
|
||||||
}
|
}
|
||||||
|
|
||||||
class TimelineCasesImpl(
|
class TimelineCasesImpl(
|
||||||
private val mastodonApi: MastodonApi,
|
private val mastodonApi: MastodonApi,
|
||||||
private val eventHub: EventHub
|
private val eventHub: EventHub
|
||||||
) : TimelineCases {
|
) : TimelineCases {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -52,103 +52,92 @@ class TimelineCasesImpl(
|
||||||
*/
|
*/
|
||||||
private val cancelDisposable = CompositeDisposable()
|
private val cancelDisposable = CompositeDisposable()
|
||||||
|
|
||||||
override fun reblog(status: Status, reblog: Boolean): Single<Status> {
|
override fun reblog(statusId: String, reblog: Boolean): Single<Status> {
|
||||||
val id = status.actionableId
|
|
||||||
|
|
||||||
val call = if (reblog) {
|
val call = if (reblog) {
|
||||||
mastodonApi.reblogStatus(id)
|
mastodonApi.reblogStatus(statusId)
|
||||||
} else {
|
} else {
|
||||||
mastodonApi.unreblogStatus(id)
|
mastodonApi.unreblogStatus(statusId)
|
||||||
}
|
}
|
||||||
return call.doAfterSuccess {
|
return call.doAfterSuccess {
|
||||||
eventHub.dispatch(ReblogEvent(status.id, reblog))
|
eventHub.dispatch(ReblogEvent(statusId, reblog))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun favourite(status: Status, favourite: Boolean): Single<Status> {
|
override fun favourite(statusId: String, favourite: Boolean): Single<Status> {
|
||||||
val id = status.actionableId
|
|
||||||
|
|
||||||
val call = if (favourite) {
|
val call = if (favourite) {
|
||||||
mastodonApi.favouriteStatus(id)
|
mastodonApi.favouriteStatus(statusId)
|
||||||
} else {
|
} else {
|
||||||
mastodonApi.unfavouriteStatus(id)
|
mastodonApi.unfavouriteStatus(statusId)
|
||||||
}
|
}
|
||||||
return call.doAfterSuccess {
|
return call.doAfterSuccess {
|
||||||
eventHub.dispatch(FavoriteEvent(status.id, favourite))
|
eventHub.dispatch(FavoriteEvent(statusId, favourite))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bookmark(status: Status, bookmark: Boolean): Single<Status> {
|
override fun bookmark(statusId: String, bookmark: Boolean): Single<Status> {
|
||||||
val id = status.actionableId
|
|
||||||
|
|
||||||
val call = if (bookmark) {
|
val call = if (bookmark) {
|
||||||
mastodonApi.bookmarkStatus(id)
|
mastodonApi.bookmarkStatus(statusId)
|
||||||
} else {
|
} else {
|
||||||
mastodonApi.unbookmarkStatus(id)
|
mastodonApi.unbookmarkStatus(statusId)
|
||||||
}
|
}
|
||||||
return call.doAfterSuccess {
|
return call.doAfterSuccess {
|
||||||
eventHub.dispatch(BookmarkEvent(status.id, bookmark))
|
eventHub.dispatch(BookmarkEvent(statusId, bookmark))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun muteConversation(status: Status, mute: Boolean): Single<Status> {
|
override fun muteConversation(statusId: String, mute: Boolean): Single<Status> {
|
||||||
val id = status.actionableId
|
|
||||||
|
|
||||||
val call = if (mute) {
|
val call = if (mute) {
|
||||||
mastodonApi.muteConversation(id)
|
mastodonApi.muteConversation(statusId)
|
||||||
} else {
|
} else {
|
||||||
mastodonApi.unmuteConversation(id)
|
mastodonApi.unmuteConversation(statusId)
|
||||||
}
|
}
|
||||||
return call.doAfterSuccess {
|
return call.doAfterSuccess {
|
||||||
eventHub.dispatch(MuteConversationEvent(status.id, mute))
|
eventHub.dispatch(MuteConversationEvent(statusId, mute))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun mute(id: String, notifications: Boolean, duration: Int?) {
|
override fun mute(statusId: String, notifications: Boolean, duration: Int?) {
|
||||||
mastodonApi.muteAccount(id, notifications, duration)
|
mastodonApi.muteAccount(statusId, notifications, duration)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
eventHub.dispatch(MuteEvent(id))
|
eventHub.dispatch(MuteEvent(statusId))
|
||||||
}, { t ->
|
}, { t ->
|
||||||
Log.w("Failed to mute account", t)
|
Log.w("Failed to mute account", t)
|
||||||
})
|
})
|
||||||
.addTo(cancelDisposable)
|
.addTo(cancelDisposable)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun block(id: String) {
|
override fun block(statusId: String) {
|
||||||
mastodonApi.blockAccount(id)
|
mastodonApi.blockAccount(statusId)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
eventHub.dispatch(BlockEvent(id))
|
eventHub.dispatch(BlockEvent(statusId))
|
||||||
}, { t ->
|
}, { t ->
|
||||||
Log.w("Failed to block account", t)
|
Log.w("Failed to block account", t)
|
||||||
})
|
})
|
||||||
.addTo(cancelDisposable)
|
.addTo(cancelDisposable)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(id: String): Single<DeletedStatus> {
|
override fun delete(statusId: String): Single<DeletedStatus> {
|
||||||
return mastodonApi.deleteStatus(id)
|
return mastodonApi.deleteStatus(statusId)
|
||||||
.doAfterSuccess {
|
.doAfterSuccess {
|
||||||
eventHub.dispatch(StatusDeletedEvent(id))
|
eventHub.dispatch(StatusDeletedEvent(statusId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pin(status: Status, pin: Boolean) {
|
override fun pin(statusId: String, pin: Boolean): Single<Status> {
|
||||||
// Replace with extension method if we use RxKotlin
|
// Replace with extension method if we use RxKotlin
|
||||||
(if (pin) mastodonApi.pinStatus(status.id) else mastodonApi.unpinStatus(status.id))
|
return (if (pin) mastodonApi.pinStatus(statusId) else mastodonApi.unpinStatus(statusId))
|
||||||
.subscribe({ updatedStatus ->
|
.doAfterSuccess {
|
||||||
status.pinned = updatedStatus.pinned
|
eventHub.dispatch(PinEvent(statusId, pin))
|
||||||
}, {})
|
}
|
||||||
.addTo(this.cancelDisposable)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun voteInPoll(status: Status, choices: List<Int>): Single<Poll> {
|
override fun voteInPoll(statusId: String, pollId: String, choices: List<Int>): Single<Poll> {
|
||||||
val pollId = status.actionableStatus.poll?.id
|
if (choices.isEmpty()) {
|
||||||
|
|
||||||
if(pollId == null || choices.isEmpty()) {
|
|
||||||
return Single.error(IllegalStateException())
|
return Single.error(IllegalStateException())
|
||||||
}
|
}
|
||||||
|
|
||||||
return mastodonApi.voteInPoll(pollId, choices).doAfterSuccess {
|
return mastodonApi.voteInPoll(pollId, choices).doAfterSuccess {
|
||||||
eventHub.dispatch(PollVoteEvent(status.id, it))
|
eventHub.dispatch(PollVoteEvent(statusId, it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,8 @@ package com.keylesspalace.tusky.pager
|
||||||
import androidx.fragment.app.*
|
import androidx.fragment.app.*
|
||||||
|
|
||||||
import com.keylesspalace.tusky.fragment.AccountMediaFragment
|
import com.keylesspalace.tusky.fragment.AccountMediaFragment
|
||||||
import com.keylesspalace.tusky.fragment.TimelineFragment
|
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||||
|
import com.keylesspalace.tusky.components.timeline.TimelineViewModel
|
||||||
import com.keylesspalace.tusky.interfaces.RefreshableFragment
|
import com.keylesspalace.tusky.interfaces.RefreshableFragment
|
||||||
|
|
||||||
import com.keylesspalace.tusky.util.CustomFragmentStateAdapter
|
import com.keylesspalace.tusky.util.CustomFragmentStateAdapter
|
||||||
|
@ -32,9 +33,9 @@ class AccountPagerAdapter(
|
||||||
|
|
||||||
override fun createFragment(position: Int): Fragment {
|
override fun createFragment(position: Int): Fragment {
|
||||||
return when (position) {
|
return when (position) {
|
||||||
0 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER, accountId, false)
|
0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false)
|
||||||
1 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_WITH_REPLIES, accountId, false)
|
1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false)
|
||||||
2 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_PINNED, accountId, false)
|
2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false)
|
||||||
3 -> AccountMediaFragment.newInstance(accountId, false)
|
3 -> AccountMediaFragment.newInstance(accountId, false)
|
||||||
else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds")
|
else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds")
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,392 +0,0 @@
|
||||||
package com.keylesspalace.tusky.repository
|
|
||||||
|
|
||||||
import android.text.SpannedString
|
|
||||||
import androidx.core.text.parseAsHtml
|
|
||||||
import androidx.core.text.toHtml
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import com.keylesspalace.tusky.db.*
|
|
||||||
import com.keylesspalace.tusky.entity.*
|
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
|
||||||
import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK
|
|
||||||
import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK
|
|
||||||
import com.keylesspalace.tusky.util.Either
|
|
||||||
import com.keylesspalace.tusky.util.dec
|
|
||||||
import com.keylesspalace.tusky.util.inc
|
|
||||||
import com.keylesspalace.tusky.util.trimTrailingWhitespace
|
|
||||||
import io.reactivex.rxjava3.core.Single
|
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.*
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.collections.ArrayList
|
|
||||||
|
|
||||||
data class Placeholder(val id: String)
|
|
||||||
|
|
||||||
typealias TimelineStatus = Either<Placeholder, Status>
|
|
||||||
|
|
||||||
enum class TimelineRequestMode {
|
|
||||||
DISK, NETWORK, ANY
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TimelineRepository {
|
|
||||||
fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int,
|
|
||||||
requestMode: TimelineRequestMode): Single<out List<TimelineStatus>>
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TimelineRepositoryImpl(
|
|
||||||
private val timelineDao: TimelineDao,
|
|
||||||
private val mastodonApi: MastodonApi,
|
|
||||||
private val accountManager: AccountManager,
|
|
||||||
private val gson: Gson
|
|
||||||
) : TimelineRepository {
|
|
||||||
|
|
||||||
init {
|
|
||||||
this.cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?,
|
|
||||||
limit: Int, requestMode: TimelineRequestMode
|
|
||||||
): Single<out List<TimelineStatus>> {
|
|
||||||
val acc = accountManager.activeAccount ?: throw IllegalStateException()
|
|
||||||
val accountId = acc.id
|
|
||||||
|
|
||||||
return if (requestMode == DISK) {
|
|
||||||
this.getStatusesFromDb(accountId, maxId, sinceId, limit)
|
|
||||||
} else {
|
|
||||||
getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStatusesFromNetwork(maxId: String?, sinceId: String?,
|
|
||||||
sinceIdMinusOne: String?, limit: Int,
|
|
||||||
accountId: Long, requestMode: TimelineRequestMode
|
|
||||||
): Single<out List<TimelineStatus>> {
|
|
||||||
return mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1)
|
|
||||||
.map { response ->
|
|
||||||
this.saveStatusesToDb(accountId, response.body().orEmpty(), maxId, sinceId)
|
|
||||||
}
|
|
||||||
.flatMap { statuses ->
|
|
||||||
this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode)
|
|
||||||
}
|
|
||||||
.onErrorResumeNext { error ->
|
|
||||||
if (error is IOException && requestMode != NETWORK) {
|
|
||||||
this.getStatusesFromDb(accountId, maxId, sinceId, limit)
|
|
||||||
} else {
|
|
||||||
Single.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addFromDbIfNeeded(accountId: Long, statuses: List<Either<Placeholder, Status>>,
|
|
||||||
maxId: String?, sinceId: String?, limit: Int,
|
|
||||||
requestMode: TimelineRequestMode
|
|
||||||
): Single<List<TimelineStatus>> {
|
|
||||||
return if (requestMode != NETWORK && statuses.size < 2) {
|
|
||||||
val newMaxID = if (statuses.isEmpty()) {
|
|
||||||
maxId
|
|
||||||
} else {
|
|
||||||
statuses.last { it.isRight() }.asRight().id
|
|
||||||
}
|
|
||||||
this.getStatusesFromDb(accountId, newMaxID, sinceId, limit)
|
|
||||||
.map { fromDb ->
|
|
||||||
// If it's just placeholders and less than limit (so we exhausted both
|
|
||||||
// db and server at this point)
|
|
||||||
if (fromDb.size < limit && fromDb.all { !it.isRight() }) {
|
|
||||||
statuses
|
|
||||||
} else {
|
|
||||||
statuses + fromDb
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Single.just(statuses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStatusesFromDb(accountId: Long, maxId: String?, sinceId: String?,
|
|
||||||
limit: Int): Single<out List<TimelineStatus>> {
|
|
||||||
return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit)
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.map { statuses ->
|
|
||||||
statuses.map { it.toStatus() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveStatusesToDb(accountId: Long, statuses: List<Status>,
|
|
||||||
maxId: String?, sinceId: String?
|
|
||||||
): List<Either<Placeholder, Status>> {
|
|
||||||
var placeholderToInsert: Placeholder? = null
|
|
||||||
|
|
||||||
// Look for overlap
|
|
||||||
val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) {
|
|
||||||
val indexOfSince = statuses.indexOfLast { it.id == sinceId }
|
|
||||||
if (indexOfSince == -1) {
|
|
||||||
// We didn't find the status which must be there. Add a placeholder
|
|
||||||
placeholderToInsert = Placeholder(sinceId.inc())
|
|
||||||
statuses.mapTo(mutableListOf(), Status::lift)
|
|
||||||
.apply {
|
|
||||||
add(Either.Left(placeholderToInsert))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// There was an overlap. Remove all overlapped statuses. No need for a placeholder.
|
|
||||||
statuses.mapTo(mutableListOf(), Status::lift)
|
|
||||||
.apply {
|
|
||||||
subList(indexOfSince, size).clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Just a normal case.
|
|
||||||
statuses.map(Status::lift)
|
|
||||||
}
|
|
||||||
|
|
||||||
Single.fromCallable {
|
|
||||||
|
|
||||||
if(statuses.isNotEmpty()) {
|
|
||||||
timelineDao.deleteRange(accountId, statuses.last().id, statuses.first().id)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (status in statuses) {
|
|
||||||
timelineDao.insertInTransaction(
|
|
||||||
status.toEntity(accountId, gson),
|
|
||||||
status.account.toEntity(accountId, gson),
|
|
||||||
status.reblog?.account?.toEntity(accountId, gson)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
placeholderToInsert?.let {
|
|
||||||
timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId))
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're loading in the bottom insert placeholder after every load
|
|
||||||
// (for requests on next launches) but not return it.
|
|
||||||
if (sinceId == null && statuses.isNotEmpty()) {
|
|
||||||
timelineDao.insertStatusIfNotThere(
|
|
||||||
Placeholder(statuses.last().id.dec()).toEntity(accountId))
|
|
||||||
}
|
|
||||||
|
|
||||||
// There may be placeholders which we thought could be from our TL but they are not
|
|
||||||
if (statuses.size > 2) {
|
|
||||||
timelineDao.removeAllPlaceholdersBetween(accountId, statuses.first().id,
|
|
||||||
statuses.last().id)
|
|
||||||
} else if (placeholderToInsert == null && maxId != null && sinceId != null) {
|
|
||||||
timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.subscribe()
|
|
||||||
|
|
||||||
return resultStatuses
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cleanup() {
|
|
||||||
Schedulers.io().scheduleDirect {
|
|
||||||
val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL
|
|
||||||
timelineDao.cleanup(olderThan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun TimelineStatusWithAccount.toStatus(): TimelineStatus {
|
|
||||||
if (this.status.authorServerId == null) {
|
|
||||||
return Either.Left(Placeholder(this.status.serverId))
|
|
||||||
}
|
|
||||||
|
|
||||||
val attachments: ArrayList<Attachment> = gson.fromJson(status.attachments,
|
|
||||||
object : TypeToken<List<Attachment>>() {}.type) ?: ArrayList()
|
|
||||||
val mentions: Array<Status.Mention> = gson.fromJson(status.mentions,
|
|
||||||
Array<Status.Mention>::class.java) ?: arrayOf()
|
|
||||||
val application = gson.fromJson(status.application, Status.Application::class.java)
|
|
||||||
val emojis: List<Emoji> = gson.fromJson(status.emojis,
|
|
||||||
object : TypeToken<List<Emoji>>() {}.type) ?: listOf()
|
|
||||||
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
|
|
||||||
|
|
||||||
val reblog = status.reblogServerId?.let { id ->
|
|
||||||
Status(
|
|
||||||
id = id,
|
|
||||||
url = status.url,
|
|
||||||
account = account.toAccount(gson),
|
|
||||||
inReplyToId = status.inReplyToId,
|
|
||||||
inReplyToAccountId = status.inReplyToAccountId,
|
|
||||||
reblog = null,
|
|
||||||
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() ?: SpannedString(""),
|
|
||||||
createdAt = Date(status.createdAt),
|
|
||||||
emojis = emojis,
|
|
||||||
reblogsCount = status.reblogsCount,
|
|
||||||
favouritesCount = status.favouritesCount,
|
|
||||||
reblogged = status.reblogged,
|
|
||||||
favourited = status.favourited,
|
|
||||||
bookmarked = status.bookmarked,
|
|
||||||
sensitive = status.sensitive,
|
|
||||||
spoilerText = status.spoilerText!!,
|
|
||||||
visibility = status.visibility!!,
|
|
||||||
attachments = attachments,
|
|
||||||
mentions = mentions,
|
|
||||||
application = application,
|
|
||||||
pinned = false,
|
|
||||||
muted = status.muted,
|
|
||||||
poll = poll,
|
|
||||||
card = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val status = if (reblog != null) {
|
|
||||||
Status(
|
|
||||||
id = status.serverId,
|
|
||||||
url = null, // no url for reblogs
|
|
||||||
account = this.reblogAccount!!.toAccount(gson),
|
|
||||||
inReplyToId = null,
|
|
||||||
inReplyToAccountId = null,
|
|
||||||
reblog = reblog,
|
|
||||||
content = SpannedString(""),
|
|
||||||
createdAt = Date(status.createdAt), // lie but whatever?
|
|
||||||
emojis = listOf(),
|
|
||||||
reblogsCount = 0,
|
|
||||||
favouritesCount = 0,
|
|
||||||
reblogged = false,
|
|
||||||
favourited = false,
|
|
||||||
bookmarked = false,
|
|
||||||
sensitive = false,
|
|
||||||
spoilerText = "",
|
|
||||||
visibility = status.visibility!!,
|
|
||||||
attachments = ArrayList(),
|
|
||||||
mentions = arrayOf(),
|
|
||||||
application = null,
|
|
||||||
pinned = false,
|
|
||||||
muted = status.muted,
|
|
||||||
poll = null,
|
|
||||||
card = null
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Status(
|
|
||||||
id = status.serverId,
|
|
||||||
url = status.url,
|
|
||||||
account = account.toAccount(gson),
|
|
||||||
inReplyToId = status.inReplyToId,
|
|
||||||
inReplyToAccountId = status.inReplyToAccountId,
|
|
||||||
reblog = null,
|
|
||||||
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() ?: SpannedString(""),
|
|
||||||
createdAt = Date(status.createdAt),
|
|
||||||
emojis = emojis,
|
|
||||||
reblogsCount = status.reblogsCount,
|
|
||||||
favouritesCount = status.favouritesCount,
|
|
||||||
reblogged = status.reblogged,
|
|
||||||
favourited = status.favourited,
|
|
||||||
bookmarked = status.bookmarked,
|
|
||||||
sensitive = status.sensitive,
|
|
||||||
spoilerText = status.spoilerText!!,
|
|
||||||
visibility = status.visibility!!,
|
|
||||||
attachments = attachments,
|
|
||||||
mentions = mentions,
|
|
||||||
application = application,
|
|
||||||
pinned = false,
|
|
||||||
muted = status.muted,
|
|
||||||
poll = poll,
|
|
||||||
card = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return Either.Right(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val emojisListTypeToken = object : TypeToken<List<Emoji>>() {}
|
|
||||||
|
|
||||||
fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
|
|
||||||
return TimelineAccountEntity(
|
|
||||||
serverId = id,
|
|
||||||
timelineUserId = accountId,
|
|
||||||
localUsername = localUsername,
|
|
||||||
username = username,
|
|
||||||
displayName = name,
|
|
||||||
url = url,
|
|
||||||
avatar = avatar,
|
|
||||||
emojis = gson.toJson(emojis),
|
|
||||||
bot = bot
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun TimelineAccountEntity.toAccount(gson: Gson): Account {
|
|
||||||
return Account(
|
|
||||||
id = serverId,
|
|
||||||
localUsername = localUsername,
|
|
||||||
username = username,
|
|
||||||
displayName = displayName,
|
|
||||||
note = SpannedString(""),
|
|
||||||
url = url,
|
|
||||||
avatar = avatar,
|
|
||||||
header = "",
|
|
||||||
locked = false,
|
|
||||||
followingCount = 0,
|
|
||||||
followersCount = 0,
|
|
||||||
statusesCount = 0,
|
|
||||||
source = null,
|
|
||||||
bot = bot,
|
|
||||||
emojis = gson.fromJson(this.emojis, emojisListTypeToken.type),
|
|
||||||
fields = null,
|
|
||||||
moved = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
|
|
||||||
return TimelineStatusEntity(
|
|
||||||
serverId = this.id,
|
|
||||||
url = null,
|
|
||||||
timelineUserId = timelineUserId,
|
|
||||||
authorServerId = null,
|
|
||||||
inReplyToId = null,
|
|
||||||
inReplyToAccountId = null,
|
|
||||||
content = null,
|
|
||||||
createdAt = 0L,
|
|
||||||
emojis = null,
|
|
||||||
reblogsCount = 0,
|
|
||||||
favouritesCount = 0,
|
|
||||||
reblogged = false,
|
|
||||||
favourited = false,
|
|
||||||
bookmarked = false,
|
|
||||||
sensitive = false,
|
|
||||||
spoilerText = null,
|
|
||||||
visibility = null,
|
|
||||||
attachments = null,
|
|
||||||
mentions = null,
|
|
||||||
application = null,
|
|
||||||
reblogServerId = null,
|
|
||||||
reblogAccountId = null,
|
|
||||||
poll = null,
|
|
||||||
muted = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Status.toEntity(timelineUserId: Long,
|
|
||||||
gson: Gson): TimelineStatusEntity {
|
|
||||||
val actionable = actionableStatus
|
|
||||||
return TimelineStatusEntity(
|
|
||||||
serverId = this.id,
|
|
||||||
url = actionable.url!!,
|
|
||||||
timelineUserId = timelineUserId,
|
|
||||||
authorServerId = actionable.account.id,
|
|
||||||
inReplyToId = actionable.inReplyToId,
|
|
||||||
inReplyToAccountId = actionable.inReplyToAccountId,
|
|
||||||
content = actionable.content.toHtml(),
|
|
||||||
createdAt = actionable.createdAt.time,
|
|
||||||
emojis = actionable.emojis.let(gson::toJson),
|
|
||||||
reblogsCount = actionable.reblogsCount,
|
|
||||||
favouritesCount = actionable.favouritesCount,
|
|
||||||
reblogged = actionable.reblogged,
|
|
||||||
favourited = actionable.favourited,
|
|
||||||
bookmarked = actionable.bookmarked,
|
|
||||||
sensitive = actionable.sensitive,
|
|
||||||
spoilerText = actionable.spoilerText,
|
|
||||||
visibility = actionable.visibility,
|
|
||||||
attachments = actionable.attachments.let(gson::toJson),
|
|
||||||
mentions = actionable.mentions.let(gson::toJson),
|
|
||||||
application = actionable.application.let(gson::toJson),
|
|
||||||
reblogServerId = reblog?.id,
|
|
||||||
reblogAccountId = reblog?.let { this.account.id },
|
|
||||||
poll = actionable.poll.let(gson::toJson),
|
|
||||||
muted = actionable.muted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Status.lift(): Either<Placeholder, Status> = Either.Right(this)
|
|
|
@ -40,6 +40,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class LinkHelper {
|
public class LinkHelper {
|
||||||
public static String getDomain(String urlString) {
|
public static String getDomain(String urlString) {
|
||||||
|
@ -69,7 +70,7 @@ public class LinkHelper {
|
||||||
* @param listener to notify about particular spans that are clicked
|
* @param listener to notify about particular spans that are clicked
|
||||||
*/
|
*/
|
||||||
public static void setClickableText(TextView view, CharSequence content,
|
public static void setClickableText(TextView view, CharSequence content,
|
||||||
@Nullable Status.Mention[] mentions, final LinkListener listener) {
|
@Nullable List<Status.Mention> mentions, final LinkListener listener) {
|
||||||
SpannableStringBuilder builder = SpannableStringBuilder.valueOf(content);
|
SpannableStringBuilder builder = SpannableStringBuilder.valueOf(content);
|
||||||
URLSpan[] urlSpans = builder.getSpans(0, content.length(), URLSpan.class);
|
URLSpan[] urlSpans = builder.getSpans(0, content.length(), URLSpan.class);
|
||||||
for (URLSpan span : urlSpans) {
|
for (URLSpan span : urlSpans) {
|
||||||
|
@ -85,7 +86,7 @@ public class LinkHelper {
|
||||||
@Override
|
@Override
|
||||||
public void onClick(@NonNull View widget) { listener.onViewTag(tag); }
|
public void onClick(@NonNull View widget) { listener.onViewTag(tag); }
|
||||||
};
|
};
|
||||||
} else if (text.charAt(0) == '@' && mentions != null && mentions.length > 0) {
|
} else if (text.charAt(0) == '@' && mentions != null && mentions.size() > 0) {
|
||||||
String accountUsername = text.subSequence(1, text.length()).toString();
|
String accountUsername = text.subSequence(1, text.length()).toString();
|
||||||
/* There may be multiple matches for users on different instances with the same
|
/* There may be multiple matches for users on different instances with the same
|
||||||
* username. If a match has the same domain we know it's for sure the same, but if
|
* username. If a match has the same domain we know it's for sure the same, but if
|
||||||
|
@ -141,8 +142,8 @@ public class LinkHelper {
|
||||||
* @param listener to notify about particular spans that are clicked
|
* @param listener to notify about particular spans that are clicked
|
||||||
*/
|
*/
|
||||||
public static void setClickableMentions(
|
public static void setClickableMentions(
|
||||||
TextView view, @Nullable Status.Mention[] mentions, final LinkListener listener) {
|
TextView view, @Nullable List<Status.Mention> mentions, final LinkListener listener) {
|
||||||
if (mentions == null || mentions.length == 0) {
|
if (mentions == null || mentions.size() == 0) {
|
||||||
view.setText(null);
|
view.setText(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,9 +27,9 @@ fun interface StatusProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ListStatusAccessibilityDelegate(
|
class ListStatusAccessibilityDelegate(
|
||||||
private val recyclerView: RecyclerView,
|
private val recyclerView: RecyclerView,
|
||||||
private val statusActionListener: StatusActionListener,
|
private val statusActionListener: StatusActionListener,
|
||||||
private val statusProvider: StatusProvider
|
private val statusProvider: StatusProvider
|
||||||
) : RecyclerViewAccessibilityDelegate(recyclerView) {
|
) : RecyclerViewAccessibilityDelegate(recyclerView) {
|
||||||
private val a11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE)
|
private val a11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE)
|
||||||
as AccessibilityManager
|
as AccessibilityManager
|
||||||
|
@ -39,8 +39,10 @@ class ListStatusAccessibilityDelegate(
|
||||||
private val context: Context get() = recyclerView.context
|
private val context: Context get() = recyclerView.context
|
||||||
|
|
||||||
private val itemDelegate = object : RecyclerViewAccessibilityDelegate.ItemDelegate(this) {
|
private val itemDelegate = object : RecyclerViewAccessibilityDelegate.ItemDelegate(this) {
|
||||||
override fun onInitializeAccessibilityNodeInfo(host: View,
|
override fun onInitializeAccessibilityNodeInfo(
|
||||||
info: AccessibilityNodeInfoCompat) {
|
host: View,
|
||||||
|
info: AccessibilityNodeInfoCompat
|
||||||
|
) {
|
||||||
super.onInitializeAccessibilityNodeInfo(host, info)
|
super.onInitializeAccessibilityNodeInfo(host, info)
|
||||||
|
|
||||||
val pos = recyclerView.getChildAdapterPosition(host)
|
val pos = recyclerView.getChildAdapterPosition(host)
|
||||||
|
@ -52,44 +54,51 @@ class ListStatusAccessibilityDelegate(
|
||||||
|
|
||||||
info.addAction(replyAction)
|
info.addAction(replyAction)
|
||||||
|
|
||||||
if (status.rebloggingEnabled) {
|
val actionable = status.actionable
|
||||||
info.addAction(if (status.isReblogged) unreblogAction else reblogAction)
|
if (actionable.rebloggingAllowed()) {
|
||||||
|
info.addAction(if (actionable.reblogged) unreblogAction else reblogAction)
|
||||||
}
|
}
|
||||||
info.addAction(if (status.isFavourited) unfavouriteAction else favouriteAction)
|
info.addAction(if (actionable.favourited) unfavouriteAction else favouriteAction)
|
||||||
info.addAction(if (status.isBookmarked) unbookmarkAction else bookmarkAction)
|
info.addAction(if (actionable.bookmarked) unbookmarkAction else bookmarkAction)
|
||||||
|
|
||||||
val mediaActions = intArrayOf(
|
val mediaActions = intArrayOf(
|
||||||
R.id.action_open_media_1,
|
R.id.action_open_media_1,
|
||||||
R.id.action_open_media_2,
|
R.id.action_open_media_2,
|
||||||
R.id.action_open_media_3,
|
R.id.action_open_media_3,
|
||||||
R.id.action_open_media_4)
|
R.id.action_open_media_4
|
||||||
val attachmentCount = min(status.attachments.size, MAX_MEDIA_ATTACHMENTS)
|
)
|
||||||
|
val attachmentCount = min(actionable.attachments.size, MAX_MEDIA_ATTACHMENTS)
|
||||||
for (i in 0 until attachmentCount) {
|
for (i in 0 until attachmentCount) {
|
||||||
info.addAction(AccessibilityActionCompat(
|
info.addAction(
|
||||||
|
AccessibilityActionCompat(
|
||||||
mediaActions[i],
|
mediaActions[i],
|
||||||
context.getString(R.string.action_open_media_n, i + 1)))
|
context.getString(R.string.action_open_media_n, i + 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
info.addAction(openProfileAction)
|
info.addAction(openProfileAction)
|
||||||
if (getLinks(status).any()) info.addAction(linksAction)
|
if (getLinks(status).any()) info.addAction(linksAction)
|
||||||
|
|
||||||
val mentions = status.mentions
|
val mentions = actionable.mentions
|
||||||
if (mentions != null && mentions.isNotEmpty()) info.addAction(mentionsAction)
|
if (mentions.isNotEmpty()) info.addAction(mentionsAction)
|
||||||
|
|
||||||
if (getHashtags(status).any()) info.addAction(hashtagsAction)
|
if (getHashtags(status).any()) info.addAction(hashtagsAction)
|
||||||
if (!status.rebloggedByUsername.isNullOrEmpty()) {
|
if (!status.status.reblog?.account?.username.isNullOrEmpty()) {
|
||||||
info.addAction(openRebloggerAction)
|
info.addAction(openRebloggerAction)
|
||||||
}
|
}
|
||||||
if (status.reblogsCount > 0) info.addAction(openRebloggedByAction)
|
if (actionable.reblogsCount > 0) info.addAction(openRebloggedByAction)
|
||||||
if (status.favouritesCount > 0) info.addAction(openFavsAction)
|
if (actionable.favouritesCount > 0) info.addAction(openFavsAction)
|
||||||
|
|
||||||
info.addAction(moreAction)
|
info.addAction(moreAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun performAccessibilityAction(host: View, action: Int,
|
override fun performAccessibilityAction(
|
||||||
args: Bundle?): Boolean {
|
host: View, action: Int,
|
||||||
|
args: Bundle?
|
||||||
|
): Boolean {
|
||||||
val pos = recyclerView.getChildAdapterPosition(host)
|
val pos = recyclerView.getChildAdapterPosition(host)
|
||||||
when (action) {
|
when (action) {
|
||||||
R.id.action_reply -> {
|
R.id.action_reply -> {
|
||||||
|
@ -105,7 +114,8 @@ class ListStatusAccessibilityDelegate(
|
||||||
R.id.action_open_profile -> {
|
R.id.action_open_profile -> {
|
||||||
interrupt()
|
interrupt()
|
||||||
statusActionListener.onViewAccount(
|
statusActionListener.onViewAccount(
|
||||||
(statusProvider.getStatus(pos) as StatusViewData.Concrete).senderId)
|
(statusProvider.getStatus(pos) as StatusViewData.Concrete).actionable.account.id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
R.id.action_open_media_1 -> {
|
R.id.action_open_media_1 -> {
|
||||||
interrupt()
|
interrupt()
|
||||||
|
@ -166,43 +176,51 @@ class ListStatusAccessibilityDelegate(
|
||||||
val links = getLinks(status).toList()
|
val links = getLinks(status).toList()
|
||||||
val textLinks = links.map { item -> item.link }
|
val textLinks = links.map { item -> item.link }
|
||||||
AlertDialog.Builder(host.context)
|
AlertDialog.Builder(host.context)
|
||||||
.setTitle(R.string.title_links_dialog)
|
.setTitle(R.string.title_links_dialog)
|
||||||
.setAdapter(ArrayAdapter(
|
.setAdapter(
|
||||||
host.context,
|
ArrayAdapter(
|
||||||
android.R.layout.simple_list_item_1,
|
host.context,
|
||||||
textLinks)
|
android.R.layout.simple_list_item_1,
|
||||||
) { _, which -> LinkHelper.openLink(links[which].link, host.context) }
|
textLinks
|
||||||
.show()
|
)
|
||||||
.let { forceFocus(it.listView) }
|
) { _, which -> LinkHelper.openLink(links[which].link, host.context) }
|
||||||
|
.show()
|
||||||
|
.let { forceFocus(it.listView) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showMentionsDialog(host: View) {
|
private fun showMentionsDialog(host: View) {
|
||||||
val status = getStatus(host) as? StatusViewData.Concrete ?: return
|
val status = getStatus(host) as? StatusViewData.Concrete ?: return
|
||||||
val mentions = status.mentions ?: return
|
val mentions = status.actionable.mentions
|
||||||
val stringMentions = mentions.map { it.username }
|
val stringMentions = mentions.map { it.username }
|
||||||
AlertDialog.Builder(host.context)
|
AlertDialog.Builder(host.context)
|
||||||
.setTitle(R.string.title_mentions_dialog)
|
.setTitle(R.string.title_mentions_dialog)
|
||||||
.setAdapter(ArrayAdapter<CharSequence>(host.context,
|
.setAdapter(
|
||||||
android.R.layout.simple_list_item_1, stringMentions)
|
ArrayAdapter<CharSequence>(
|
||||||
) { _, which ->
|
host.context,
|
||||||
statusActionListener.onViewAccount(mentions[which].id)
|
android.R.layout.simple_list_item_1, stringMentions
|
||||||
}
|
)
|
||||||
.show()
|
) { _, which ->
|
||||||
.let { forceFocus(it.listView) }
|
statusActionListener.onViewAccount(mentions[which].id)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
.let { forceFocus(it.listView) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showHashtagsDialog(host: View) {
|
private fun showHashtagsDialog(host: View) {
|
||||||
val status = getStatus(host) as? StatusViewData.Concrete ?: return
|
val status = getStatus(host) as? StatusViewData.Concrete ?: return
|
||||||
val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList()
|
val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList()
|
||||||
AlertDialog.Builder(host.context)
|
AlertDialog.Builder(host.context)
|
||||||
.setTitle(R.string.title_hashtags_dialog)
|
.setTitle(R.string.title_hashtags_dialog)
|
||||||
.setAdapter(ArrayAdapter(host.context,
|
.setAdapter(
|
||||||
android.R.layout.simple_list_item_1, tags)
|
ArrayAdapter(
|
||||||
) { _, which ->
|
host.context,
|
||||||
statusActionListener.onViewTag(tags[which].toString())
|
android.R.layout.simple_list_item_1, tags
|
||||||
}
|
)
|
||||||
.show()
|
) { _, which ->
|
||||||
.let { forceFocus(it.listView) }
|
statusActionListener.onViewTag(tags[which].toString())
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
.let { forceFocus(it.listView) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getStatus(childView: View): StatusViewData {
|
private fun getStatus(childView: View): StatusViewData {
|
||||||
|
@ -215,14 +233,15 @@ class ListStatusAccessibilityDelegate(
|
||||||
val content = status.content
|
val content = status.content
|
||||||
return if (content is Spannable) {
|
return if (content is Spannable) {
|
||||||
content.getSpans(0, content.length, URLSpan::class.java)
|
content.getSpans(0, content.length, URLSpan::class.java)
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.map { span ->
|
.map { span ->
|
||||||
val text = content.subSequence(
|
val text = content.subSequence(
|
||||||
content.getSpanStart(span),
|
content.getSpanStart(span),
|
||||||
content.getSpanEnd(span))
|
content.getSpanEnd(span)
|
||||||
if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url)
|
)
|
||||||
}
|
if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url)
|
||||||
.filterNotNull()
|
}
|
||||||
|
.filterNotNull()
|
||||||
} else {
|
} else {
|
||||||
emptySequence()
|
emptySequence()
|
||||||
}
|
}
|
||||||
|
@ -231,11 +250,11 @@ class ListStatusAccessibilityDelegate(
|
||||||
private fun getHashtags(status: StatusViewData.Concrete): Sequence<CharSequence> {
|
private fun getHashtags(status: StatusViewData.Concrete): Sequence<CharSequence> {
|
||||||
val content = status.content
|
val content = status.content
|
||||||
return content.getSpans(0, content.length, Object::class.java)
|
return content.getSpans(0, content.length, Object::class.java)
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.map { span ->
|
.map { span ->
|
||||||
content.subSequence(content.getSpanStart(span), content.getSpanEnd(span))
|
content.subSequence(content.getSpanStart(span), content.getSpanEnd(span))
|
||||||
}
|
}
|
||||||
.filter(this::isHashtag)
|
.filter(this::isHashtag)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun forceFocus(host: View) {
|
private fun forceFocus(host: View) {
|
||||||
|
@ -253,72 +272,88 @@ class ListStatusAccessibilityDelegate(
|
||||||
private fun isHashtag(text: CharSequence) = text.startsWith("#")
|
private fun isHashtag(text: CharSequence) = text.startsWith("#")
|
||||||
|
|
||||||
private val collapseCwAction = AccessibilityActionCompat(
|
private val collapseCwAction = AccessibilityActionCompat(
|
||||||
R.id.action_collapse_cw,
|
R.id.action_collapse_cw,
|
||||||
context.getString(R.string.status_content_warning_show_less))
|
context.getString(R.string.status_content_warning_show_less)
|
||||||
|
)
|
||||||
|
|
||||||
private val expandCwAction = AccessibilityActionCompat(
|
private val expandCwAction = AccessibilityActionCompat(
|
||||||
R.id.action_expand_cw,
|
R.id.action_expand_cw,
|
||||||
context.getString(R.string.status_content_warning_show_more))
|
context.getString(R.string.status_content_warning_show_more)
|
||||||
|
)
|
||||||
|
|
||||||
private val replyAction = AccessibilityActionCompat(
|
private val replyAction = AccessibilityActionCompat(
|
||||||
R.id.action_reply,
|
R.id.action_reply,
|
||||||
context.getString(R.string.action_reply))
|
context.getString(R.string.action_reply)
|
||||||
|
)
|
||||||
|
|
||||||
private val unreblogAction = AccessibilityActionCompat(
|
private val unreblogAction = AccessibilityActionCompat(
|
||||||
R.id.action_unreblog,
|
R.id.action_unreblog,
|
||||||
context.getString(R.string.action_unreblog))
|
context.getString(R.string.action_unreblog)
|
||||||
|
)
|
||||||
|
|
||||||
private val reblogAction = AccessibilityActionCompat(
|
private val reblogAction = AccessibilityActionCompat(
|
||||||
R.id.action_reblog,
|
R.id.action_reblog,
|
||||||
context.getString(R.string.action_reblog))
|
context.getString(R.string.action_reblog)
|
||||||
|
)
|
||||||
|
|
||||||
private val unfavouriteAction = AccessibilityActionCompat(
|
private val unfavouriteAction = AccessibilityActionCompat(
|
||||||
R.id.action_unfavourite,
|
R.id.action_unfavourite,
|
||||||
context.getString(R.string.action_unfavourite))
|
context.getString(R.string.action_unfavourite)
|
||||||
|
)
|
||||||
|
|
||||||
private val favouriteAction = AccessibilityActionCompat(
|
private val favouriteAction = AccessibilityActionCompat(
|
||||||
R.id.action_favourite,
|
R.id.action_favourite,
|
||||||
context.getString(R.string.action_favourite))
|
context.getString(R.string.action_favourite)
|
||||||
|
)
|
||||||
|
|
||||||
private val bookmarkAction = AccessibilityActionCompat(
|
private val bookmarkAction = AccessibilityActionCompat(
|
||||||
R.id.action_bookmark,
|
R.id.action_bookmark,
|
||||||
context.getString(R.string.action_bookmark))
|
context.getString(R.string.action_bookmark)
|
||||||
|
)
|
||||||
|
|
||||||
private val unbookmarkAction = AccessibilityActionCompat(
|
private val unbookmarkAction = AccessibilityActionCompat(
|
||||||
R.id.action_unbookmark,
|
R.id.action_unbookmark,
|
||||||
context.getString(R.string.action_bookmark))
|
context.getString(R.string.action_bookmark)
|
||||||
|
)
|
||||||
|
|
||||||
private val openProfileAction = AccessibilityActionCompat(
|
private val openProfileAction = AccessibilityActionCompat(
|
||||||
R.id.action_open_profile,
|
R.id.action_open_profile,
|
||||||
context.getString(R.string.action_view_profile))
|
context.getString(R.string.action_view_profile)
|
||||||
|
)
|
||||||
|
|
||||||
private val linksAction = AccessibilityActionCompat(
|
private val linksAction = AccessibilityActionCompat(
|
||||||
R.id.action_links,
|
R.id.action_links,
|
||||||
context.getString(R.string.action_links))
|
context.getString(R.string.action_links)
|
||||||
|
)
|
||||||
|
|
||||||
private val mentionsAction = AccessibilityActionCompat(
|
private val mentionsAction = AccessibilityActionCompat(
|
||||||
R.id.action_mentions,
|
R.id.action_mentions,
|
||||||
context.getString(R.string.action_mentions))
|
context.getString(R.string.action_mentions)
|
||||||
|
)
|
||||||
|
|
||||||
private val hashtagsAction = AccessibilityActionCompat(
|
private val hashtagsAction = AccessibilityActionCompat(
|
||||||
R.id.action_hashtags,
|
R.id.action_hashtags,
|
||||||
context.getString(R.string.action_hashtags))
|
context.getString(R.string.action_hashtags)
|
||||||
|
)
|
||||||
|
|
||||||
private val openRebloggerAction = AccessibilityActionCompat(
|
private val openRebloggerAction = AccessibilityActionCompat(
|
||||||
R.id.action_open_reblogger,
|
R.id.action_open_reblogger,
|
||||||
context.getString(R.string.action_open_reblogger))
|
context.getString(R.string.action_open_reblogger)
|
||||||
|
)
|
||||||
|
|
||||||
private val openRebloggedByAction = AccessibilityActionCompat(
|
private val openRebloggedByAction = AccessibilityActionCompat(
|
||||||
R.id.action_open_reblogged_by,
|
R.id.action_open_reblogged_by,
|
||||||
context.getString(R.string.action_open_reblogged_by))
|
context.getString(R.string.action_open_reblogged_by)
|
||||||
|
)
|
||||||
|
|
||||||
private val openFavsAction = AccessibilityActionCompat(
|
private val openFavsAction = AccessibilityActionCompat(
|
||||||
R.id.action_open_faved_by,
|
R.id.action_open_faved_by,
|
||||||
context.getString(R.string.action_open_faved_by))
|
context.getString(R.string.action_open_faved_by)
|
||||||
|
)
|
||||||
|
|
||||||
private val moreAction = AccessibilityActionCompat(
|
private val moreAction = AccessibilityActionCompat(
|
||||||
R.id.action_more,
|
R.id.action_more,
|
||||||
context.getString(R.string.action_more)
|
context.getString(R.string.action_more)
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class LinkSpanInfo(val text: String, val link: String)
|
private data class LinkSpanInfo(val text: String, val link: String)
|
||||||
|
|
|
@ -52,4 +52,8 @@ inline fun <T> List<T>.replacedFirstWhich(replacement: T, predicate: (T) -> Bool
|
||||||
newList[index] = replacement
|
newList[index] = replacement
|
||||||
}
|
}
|
||||||
return newList
|
return newList
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified R> Iterable<*>.firstIsInstanceOrNull(): R? {
|
||||||
|
return firstOrNull { it is R }?.let { it as R }
|
||||||
}
|
}
|
|
@ -73,6 +73,15 @@ fun String.isLessThan(other: String): Boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.idCompareTo(other: String): Int {
|
||||||
|
return when {
|
||||||
|
this === other -> 0
|
||||||
|
this.length < other.length -> -1
|
||||||
|
this.length > other.length -> 1
|
||||||
|
else -> this.compareTo(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun Spanned.trimTrailingWhitespace(): Spanned {
|
fun Spanned.trimTrailingWhitespace(): Spanned {
|
||||||
var i = length
|
var i = length
|
||||||
do {
|
do {
|
||||||
|
|
|
@ -1,86 +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.util;
|
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import com.keylesspalace.tusky.entity.Notification;
|
|
||||||
import com.keylesspalace.tusky.entity.Status;
|
|
||||||
import com.keylesspalace.tusky.viewdata.NotificationViewData;
|
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Created by charlag on 12/07/2017.
|
|
||||||
*/
|
|
||||||
|
|
||||||
public final class ViewDataUtils {
|
|
||||||
@Nullable
|
|
||||||
public static StatusViewData.Concrete statusToViewData(@Nullable Status status,
|
|
||||||
boolean alwaysShowSensitiveMedia,
|
|
||||||
boolean alwaysOpenSpoiler) {
|
|
||||||
if (status == null) return null;
|
|
||||||
Status visibleStatus = status.getReblog() == null ? status : status.getReblog();
|
|
||||||
return new StatusViewData.Builder().setId(status.getId())
|
|
||||||
.setAttachments(visibleStatus.getAttachments())
|
|
||||||
.setAvatar(visibleStatus.getAccount().getAvatar())
|
|
||||||
.setContent(visibleStatus.getContent())
|
|
||||||
.setCreatedAt(visibleStatus.getCreatedAt())
|
|
||||||
.setReblogsCount(visibleStatus.getReblogsCount())
|
|
||||||
.setFavouritesCount(visibleStatus.getFavouritesCount())
|
|
||||||
.setInReplyToId(visibleStatus.getInReplyToId())
|
|
||||||
.setFavourited(visibleStatus.getFavourited())
|
|
||||||
.setBookmarked(visibleStatus.getBookmarked())
|
|
||||||
.setReblogged(visibleStatus.getReblogged())
|
|
||||||
.setIsExpanded(alwaysOpenSpoiler)
|
|
||||||
.setIsShowingSensitiveContent(false)
|
|
||||||
.setMentions(visibleStatus.getMentions())
|
|
||||||
.setNickname(visibleStatus.getAccount().getUsername())
|
|
||||||
.setRebloggedAvatar(status.getReblog() == null ? null : status.getAccount().getAvatar())
|
|
||||||
.setSensitive(visibleStatus.getSensitive())
|
|
||||||
.setIsShowingSensitiveContent(alwaysShowSensitiveMedia || !visibleStatus.getSensitive())
|
|
||||||
.setSpoilerText(visibleStatus.getSpoilerText())
|
|
||||||
.setRebloggedByUsername(status.getReblog() == null ? null : status.getAccount().getName())
|
|
||||||
.setUserFullName(visibleStatus.getAccount().getName())
|
|
||||||
.setVisibility(visibleStatus.getVisibility())
|
|
||||||
.setSenderId(visibleStatus.getAccount().getId())
|
|
||||||
.setRebloggingEnabled(visibleStatus.rebloggingAllowed())
|
|
||||||
.setApplication(visibleStatus.getApplication())
|
|
||||||
.setStatusEmojis(visibleStatus.getEmojis())
|
|
||||||
.setAccountEmojis(visibleStatus.getAccount().getEmojis())
|
|
||||||
.setRebloggedByEmojis(status.getReblog() == null ? null : status.getAccount().getEmojis())
|
|
||||||
.setCollapsible(SmartLengthInputFilterKt.shouldTrimStatus(visibleStatus.getContent()))
|
|
||||||
.setCollapsed(true)
|
|
||||||
.setPoll(visibleStatus.getPoll())
|
|
||||||
.setCard(visibleStatus.getCard())
|
|
||||||
.setIsBot(visibleStatus.getAccount().getBot())
|
|
||||||
.createStatusViewData();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static NotificationViewData.Concrete notificationToViewData(Notification notification,
|
|
||||||
boolean alwaysShowSensitiveData,
|
|
||||||
boolean alwaysOpenSpoiler) {
|
|
||||||
return new NotificationViewData.Concrete(
|
|
||||||
notification.getType(),
|
|
||||||
notification.getId(),
|
|
||||||
notification.getAccount(),
|
|
||||||
statusToViewData(
|
|
||||||
notification.getStatus(),
|
|
||||||
alwaysShowSensitiveData,
|
|
||||||
alwaysOpenSpoiler
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
@file:JvmName("ViewDataUtils")
|
||||||
|
|
||||||
|
/* 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.util
|
||||||
|
|
||||||
|
import com.keylesspalace.tusky.entity.Notification
|
||||||
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.toViewData
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@JvmName("statusToViewData")
|
||||||
|
fun Status.toViewData(
|
||||||
|
alwaysShowSensitiveMedia: Boolean,
|
||||||
|
alwaysOpenSpoiler: Boolean
|
||||||
|
): StatusViewData.Concrete {
|
||||||
|
val visibleStatus = this.reblog ?: this
|
||||||
|
|
||||||
|
return StatusViewData.Concrete(
|
||||||
|
status = this,
|
||||||
|
isShowingContent = alwaysShowSensitiveMedia || !visibleStatus.sensitive,
|
||||||
|
isCollapsible = shouldTrimStatus(visibleStatus.content),
|
||||||
|
isCollapsed = false,
|
||||||
|
isExpanded = alwaysOpenSpoiler,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmName("notificationToViewData")
|
||||||
|
fun Notification.toViewData(
|
||||||
|
alwaysShowSensitiveData: Boolean,
|
||||||
|
alwaysOpenSpoiler: Boolean
|
||||||
|
): NotificationViewData.Concrete {
|
||||||
|
return NotificationViewData.Concrete(
|
||||||
|
this.type,
|
||||||
|
this.id,
|
||||||
|
this.account,
|
||||||
|
this.status?.toViewData(alwaysShowSensitiveData, alwaysOpenSpoiler)
|
||||||
|
)
|
||||||
|
}
|
|
@ -47,13 +47,13 @@ class ConversationLineItemDecoration(private val context: Context) : RecyclerVie
|
||||||
val dividerBottom: Int
|
val dividerBottom: Int
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
val above = adapter.getItem(position - 1)
|
val above = adapter.getItem(position - 1)
|
||||||
dividerTop = if (above != null && above.id == current.inReplyToId) {
|
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 = adapter.getItem(position + 1)
|
||||||
dividerBottom = if (below != null && current.id == below.inReplyToId &&
|
dividerBottom = if (below != null && current.id == below.status.inReplyToId &&
|
||||||
adapter.detailedStatusPosition != position) {
|
adapter.detailedStatusPosition != position) {
|
||||||
child.bottom
|
child.bottom
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -19,12 +19,5 @@ data class AttachmentViewData(
|
||||||
AttachmentViewData(it, actionable.id, actionable.url!!)
|
AttachmentViewData(it, actionable.id, actionable.url!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun list(attachments: List<Attachment>): List<AttachmentViewData> {
|
|
||||||
return attachments.map {
|
|
||||||
AttachmentViewData(it, it.id, it.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -86,9 +86,7 @@ public abstract class NotificationViewData {
|
||||||
return type == concrete.type &&
|
return type == concrete.type &&
|
||||||
Objects.equals(id, concrete.id) &&
|
Objects.equals(id, concrete.id) &&
|
||||||
account.getId().equals(concrete.account.getId()) &&
|
account.getId().equals(concrete.account.getId()) &&
|
||||||
(statusViewData == concrete.statusViewData ||
|
(Objects.equals(statusViewData, concrete.statusViewData));
|
||||||
statusViewData != null &&
|
|
||||||
statusViewData.deepEquals(concrete.statusViewData));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -96,6 +94,10 @@ public abstract class NotificationViewData {
|
||||||
|
|
||||||
return Objects.hash(type, id, account, statusViewData);
|
return Objects.hash(type, id, account, statusViewData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) {
|
||||||
|
return new Concrete(type, id, account, statusViewData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static final class Placeholder extends NotificationViewData {
|
public static final class Placeholder extends NotificationViewData {
|
||||||
|
|
|
@ -1,677 +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.viewdata;
|
|
||||||
|
|
||||||
import android.os.Build;
|
|
||||||
import android.text.SpannableStringBuilder;
|
|
||||||
import android.text.Spanned;
|
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import com.keylesspalace.tusky.entity.Attachment;
|
|
||||||
import com.keylesspalace.tusky.entity.Card;
|
|
||||||
import com.keylesspalace.tusky.entity.Emoji;
|
|
||||||
import com.keylesspalace.tusky.entity.Poll;
|
|
||||||
import com.keylesspalace.tusky.entity.Status;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Created by charlag on 11/07/2017.
|
|
||||||
* <p>
|
|
||||||
* Class to represent data required to display either a notification or a placeholder.
|
|
||||||
* It is either a {@link StatusViewData.Concrete} or a {@link StatusViewData.Placeholder}.
|
|
||||||
*/
|
|
||||||
|
|
||||||
public abstract class StatusViewData {
|
|
||||||
|
|
||||||
private StatusViewData() { }
|
|
||||||
|
|
||||||
public abstract long getViewDataId();
|
|
||||||
|
|
||||||
public abstract boolean deepEquals(StatusViewData other);
|
|
||||||
|
|
||||||
public static final class Concrete extends StatusViewData {
|
|
||||||
private static final char SOFT_HYPHEN = '\u00ad';
|
|
||||||
private static final char ASCII_HYPHEN = '-';
|
|
||||||
|
|
||||||
private final String id;
|
|
||||||
private final Spanned content;
|
|
||||||
final boolean reblogged;
|
|
||||||
final boolean favourited;
|
|
||||||
final boolean bookmarked;
|
|
||||||
private final boolean muted;
|
|
||||||
@Nullable
|
|
||||||
private final String spoilerText;
|
|
||||||
private final Status.Visibility visibility;
|
|
||||||
private final List<Attachment> attachments;
|
|
||||||
@Nullable
|
|
||||||
private final String rebloggedByUsername;
|
|
||||||
@Nullable
|
|
||||||
private final String rebloggedAvatar;
|
|
||||||
private final boolean isSensitive;
|
|
||||||
final boolean isExpanded;
|
|
||||||
private final boolean isShowingContent;
|
|
||||||
private final String userFullName;
|
|
||||||
private final String nickname;
|
|
||||||
private final String avatar;
|
|
||||||
private final Date createdAt;
|
|
||||||
private final int reblogsCount;
|
|
||||||
private final int favouritesCount;
|
|
||||||
@Nullable
|
|
||||||
private final String inReplyToId;
|
|
||||||
// I would rather have something else but it would be too much of a rewrite
|
|
||||||
@Nullable
|
|
||||||
private final Status.Mention[] mentions;
|
|
||||||
private final String senderId;
|
|
||||||
private final boolean rebloggingEnabled;
|
|
||||||
private final Status.Application application;
|
|
||||||
private final List<Emoji> statusEmojis;
|
|
||||||
private final List<Emoji> accountEmojis;
|
|
||||||
private final List<Emoji> rebloggedByAccountEmojis;
|
|
||||||
@Nullable
|
|
||||||
private final Card card;
|
|
||||||
private final boolean isCollapsible; /** Whether the status meets the requirement to be collapse */
|
|
||||||
final boolean isCollapsed; /** Whether the status is shown partially or fully */
|
|
||||||
@Nullable
|
|
||||||
private final PollViewData poll;
|
|
||||||
private final boolean isBot;
|
|
||||||
|
|
||||||
public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, boolean bookmarked, boolean muted,
|
|
||||||
@Nullable String spoilerText, Status.Visibility visibility, List<Attachment> attachments,
|
|
||||||
@Nullable String rebloggedByUsername, @Nullable String rebloggedAvatar, boolean sensitive, boolean isExpanded,
|
|
||||||
boolean isShowingContent, String userFullName, String nickname, String avatar,
|
|
||||||
Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId,
|
|
||||||
@Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled,
|
|
||||||
Status.Application application, List<Emoji> statusEmojis, List<Emoji> accountEmojis, List<Emoji> rebloggedByAccountEmojis, @Nullable Card card,
|
|
||||||
boolean isCollapsible, boolean isCollapsed, @Nullable PollViewData poll, boolean isBot) {
|
|
||||||
|
|
||||||
this.id = id;
|
|
||||||
if (Build.VERSION.SDK_INT == 23) {
|
|
||||||
// https://github.com/tuskyapp/Tusky/issues/563
|
|
||||||
this.content = replaceCrashingCharacters(content);
|
|
||||||
this.spoilerText = spoilerText == null ? null : replaceCrashingCharacters(spoilerText).toString();
|
|
||||||
this.nickname = replaceCrashingCharacters(nickname).toString();
|
|
||||||
} else {
|
|
||||||
this.content = content;
|
|
||||||
this.spoilerText = spoilerText;
|
|
||||||
this.nickname = nickname;
|
|
||||||
}
|
|
||||||
this.reblogged = reblogged;
|
|
||||||
this.favourited = favourited;
|
|
||||||
this.bookmarked = bookmarked;
|
|
||||||
this.muted = muted;
|
|
||||||
this.visibility = visibility;
|
|
||||||
this.attachments = attachments;
|
|
||||||
this.rebloggedByUsername = rebloggedByUsername;
|
|
||||||
this.rebloggedAvatar = rebloggedAvatar;
|
|
||||||
this.isSensitive = sensitive;
|
|
||||||
this.isExpanded = isExpanded;
|
|
||||||
this.isShowingContent = isShowingContent;
|
|
||||||
this.userFullName = userFullName;
|
|
||||||
this.avatar = avatar;
|
|
||||||
this.createdAt = createdAt;
|
|
||||||
this.reblogsCount = reblogsCount;
|
|
||||||
this.favouritesCount = favouritesCount;
|
|
||||||
this.inReplyToId = inReplyToId;
|
|
||||||
this.mentions = mentions;
|
|
||||||
this.senderId = senderId;
|
|
||||||
this.rebloggingEnabled = rebloggingEnabled;
|
|
||||||
this.application = application;
|
|
||||||
this.statusEmojis = statusEmojis;
|
|
||||||
this.accountEmojis = accountEmojis;
|
|
||||||
this.rebloggedByAccountEmojis = rebloggedByAccountEmojis;
|
|
||||||
this.card = card;
|
|
||||||
this.isCollapsible = isCollapsible;
|
|
||||||
this.isCollapsed = isCollapsed;
|
|
||||||
this.poll = poll;
|
|
||||||
this.isBot = isBot;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Spanned getContent() {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isReblogged() {
|
|
||||||
return reblogged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isFavourited() {
|
|
||||||
return favourited;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isBookmarked() {
|
|
||||||
return bookmarked;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isMuted() {
|
|
||||||
return muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
public String getSpoilerText() {
|
|
||||||
return spoilerText;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Status.Visibility getVisibility() {
|
|
||||||
return visibility;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Attachment> getAttachments() {
|
|
||||||
return attachments;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
public String getRebloggedByUsername() {
|
|
||||||
return rebloggedByUsername;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isSensitive() {
|
|
||||||
return isSensitive;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isExpanded() {
|
|
||||||
return isExpanded;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isShowingContent() {
|
|
||||||
return isShowingContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isBot(){ return isBot; }
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
public String getRebloggedAvatar() {
|
|
||||||
return rebloggedAvatar;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUserFullName() {
|
|
||||||
return userFullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getNickname() {
|
|
||||||
return nickname;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getAvatar() {
|
|
||||||
return avatar;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Date getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getReblogsCount() {
|
|
||||||
return reblogsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getFavouritesCount() {
|
|
||||||
return favouritesCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
public String getInReplyToId() {
|
|
||||||
return inReplyToId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getSenderId() {
|
|
||||||
return senderId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Boolean getRebloggingEnabled() {
|
|
||||||
return rebloggingEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
public Status.Mention[] getMentions() {
|
|
||||||
return mentions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Status.Application getApplication() {
|
|
||||||
return application;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Emoji> getStatusEmojis() {
|
|
||||||
return statusEmojis;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Emoji> getAccountEmojis() {
|
|
||||||
return accountEmojis;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Emoji> getRebloggedByAccountEmojis() {
|
|
||||||
return rebloggedByAccountEmojis;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
public Card getCard() {
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Specifies whether the content of this post is allowed to be collapsed or if it should show
|
|
||||||
* all content regardless.
|
|
||||||
*
|
|
||||||
* @return Whether the post is collapsible or never collapsed.
|
|
||||||
*/
|
|
||||||
public boolean isCollapsible() {
|
|
||||||
return isCollapsible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Specifies whether the content of this post is currently limited in visibility to the first
|
|
||||||
* 500 characters or not.
|
|
||||||
*
|
|
||||||
* @return Whether the post is collapsed or fully expanded.
|
|
||||||
*/
|
|
||||||
public boolean isCollapsed() {
|
|
||||||
return isCollapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
public PollViewData getPoll() {
|
|
||||||
return poll;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public long getViewDataId() {
|
|
||||||
// Chance of collision is super low and impact of mistake is low as well
|
|
||||||
return id.hashCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean deepEquals(StatusViewData o) {
|
|
||||||
if (this == o) return true;
|
|
||||||
if (o == null || getClass() != o.getClass()) return false;
|
|
||||||
Concrete concrete = (Concrete) o;
|
|
||||||
return reblogged == concrete.reblogged &&
|
|
||||||
favourited == concrete.favourited &&
|
|
||||||
bookmarked == concrete.bookmarked &&
|
|
||||||
isSensitive == concrete.isSensitive &&
|
|
||||||
isExpanded == concrete.isExpanded &&
|
|
||||||
isShowingContent == concrete.isShowingContent &&
|
|
||||||
isBot == concrete.isBot &&
|
|
||||||
reblogsCount == concrete.reblogsCount &&
|
|
||||||
favouritesCount == concrete.favouritesCount &&
|
|
||||||
rebloggingEnabled == concrete.rebloggingEnabled &&
|
|
||||||
Objects.equals(id, concrete.id) &&
|
|
||||||
Objects.equals(content, concrete.content) &&
|
|
||||||
Objects.equals(spoilerText, concrete.spoilerText) &&
|
|
||||||
visibility == concrete.visibility &&
|
|
||||||
Objects.equals(attachments, concrete.attachments) &&
|
|
||||||
Objects.equals(rebloggedByUsername, concrete.rebloggedByUsername) &&
|
|
||||||
Objects.equals(rebloggedAvatar, concrete.rebloggedAvatar) &&
|
|
||||||
Objects.equals(userFullName, concrete.userFullName) &&
|
|
||||||
Objects.equals(nickname, concrete.nickname) &&
|
|
||||||
Objects.equals(avatar, concrete.avatar) &&
|
|
||||||
Objects.equals(createdAt, concrete.createdAt) &&
|
|
||||||
Objects.equals(inReplyToId, concrete.inReplyToId) &&
|
|
||||||
Arrays.equals(mentions, concrete.mentions) &&
|
|
||||||
Objects.equals(senderId, concrete.senderId) &&
|
|
||||||
Objects.equals(application, concrete.application) &&
|
|
||||||
Objects.equals(statusEmojis, concrete.statusEmojis) &&
|
|
||||||
Objects.equals(accountEmojis, concrete.accountEmojis) &&
|
|
||||||
Objects.equals(rebloggedByAccountEmojis, concrete.rebloggedByAccountEmojis) &&
|
|
||||||
Objects.equals(card, concrete.card) &&
|
|
||||||
Objects.equals(poll, concrete.poll)
|
|
||||||
&& isCollapsed == concrete.isCollapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Spanned replaceCrashingCharacters(Spanned content) {
|
|
||||||
return (Spanned) replaceCrashingCharacters((CharSequence) content);
|
|
||||||
}
|
|
||||||
|
|
||||||
static CharSequence replaceCrashingCharacters(CharSequence content) {
|
|
||||||
boolean replacing = false;
|
|
||||||
SpannableStringBuilder builder = null;
|
|
||||||
int length = content.length();
|
|
||||||
|
|
||||||
for (int index = 0; index < length; ++index) {
|
|
||||||
char character = content.charAt(index);
|
|
||||||
|
|
||||||
// If there are more than one or two, switch to a map
|
|
||||||
if (character == SOFT_HYPHEN) {
|
|
||||||
if (!replacing) {
|
|
||||||
replacing = true;
|
|
||||||
builder = new SpannableStringBuilder(content, 0, index);
|
|
||||||
}
|
|
||||||
builder.append(ASCII_HYPHEN);
|
|
||||||
} else if (replacing) {
|
|
||||||
builder.append(character);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return replacing ? builder : content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static final class Placeholder extends StatusViewData {
|
|
||||||
private final boolean isLoading;
|
|
||||||
private final String id;
|
|
||||||
|
|
||||||
public Placeholder(String id, boolean isLoading) {
|
|
||||||
this.id = id;
|
|
||||||
this.isLoading = isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isLoading() {
|
|
||||||
return isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public long getViewDataId() {
|
|
||||||
return id.hashCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public boolean deepEquals(StatusViewData other) {
|
|
||||||
if (!(other instanceof Placeholder)) return false;
|
|
||||||
Placeholder that = (Placeholder) other;
|
|
||||||
return isLoading == that.isLoading && id.equals(that.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public boolean equals(Object o) {
|
|
||||||
if (this == o) return true;
|
|
||||||
if (o == null || getClass() != o.getClass()) return false;
|
|
||||||
|
|
||||||
Placeholder that = (Placeholder) o;
|
|
||||||
|
|
||||||
return deepEquals(that);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int hashCode() {
|
|
||||||
int result = (isLoading ? 1 : 0);
|
|
||||||
result = 31 * result + id.hashCode();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class Builder {
|
|
||||||
private String id;
|
|
||||||
private Spanned content;
|
|
||||||
private boolean reblogged;
|
|
||||||
private boolean favourited;
|
|
||||||
private boolean bookmarked;
|
|
||||||
private boolean muted;
|
|
||||||
private String spoilerText;
|
|
||||||
private Status.Visibility visibility;
|
|
||||||
private List<Attachment> attachments;
|
|
||||||
private String rebloggedByUsername;
|
|
||||||
private String rebloggedAvatar;
|
|
||||||
private boolean isSensitive;
|
|
||||||
private boolean isExpanded;
|
|
||||||
private boolean isShowingContent;
|
|
||||||
private String userFullName;
|
|
||||||
private String nickname;
|
|
||||||
private String avatar;
|
|
||||||
private Date createdAt;
|
|
||||||
private int reblogsCount;
|
|
||||||
private int favouritesCount;
|
|
||||||
private String inReplyToId;
|
|
||||||
private Status.Mention[] mentions;
|
|
||||||
private String senderId;
|
|
||||||
private boolean rebloggingEnabled;
|
|
||||||
private Status.Application application;
|
|
||||||
private List<Emoji> statusEmojis;
|
|
||||||
private List<Emoji> accountEmojis;
|
|
||||||
private List<Emoji> rebloggedByAccountEmojis;
|
|
||||||
private Card card;
|
|
||||||
private boolean isCollapsible; /** Whether the status meets the requirement to be collapsed */
|
|
||||||
private boolean isCollapsed; /** Whether the status is shown partially or fully */
|
|
||||||
private PollViewData poll;
|
|
||||||
private boolean isBot;
|
|
||||||
|
|
||||||
public Builder() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder(final StatusViewData.Concrete viewData) {
|
|
||||||
id = viewData.id;
|
|
||||||
content = viewData.content;
|
|
||||||
reblogged = viewData.reblogged;
|
|
||||||
favourited = viewData.favourited;
|
|
||||||
bookmarked = viewData.bookmarked;
|
|
||||||
muted = viewData.muted;
|
|
||||||
spoilerText = viewData.spoilerText;
|
|
||||||
visibility = viewData.visibility;
|
|
||||||
attachments = viewData.attachments == null ? null : new ArrayList<>(viewData.attachments);
|
|
||||||
rebloggedByUsername = viewData.rebloggedByUsername;
|
|
||||||
rebloggedAvatar = viewData.rebloggedAvatar;
|
|
||||||
isSensitive = viewData.isSensitive;
|
|
||||||
isExpanded = viewData.isExpanded;
|
|
||||||
isShowingContent = viewData.isShowingContent;
|
|
||||||
userFullName = viewData.userFullName;
|
|
||||||
nickname = viewData.nickname;
|
|
||||||
avatar = viewData.avatar;
|
|
||||||
createdAt = new Date(viewData.createdAt.getTime());
|
|
||||||
reblogsCount = viewData.reblogsCount;
|
|
||||||
favouritesCount = viewData.favouritesCount;
|
|
||||||
inReplyToId = viewData.inReplyToId;
|
|
||||||
mentions = viewData.mentions == null ? null : viewData.mentions.clone();
|
|
||||||
senderId = viewData.senderId;
|
|
||||||
rebloggingEnabled = viewData.rebloggingEnabled;
|
|
||||||
application = viewData.application;
|
|
||||||
statusEmojis = viewData.getStatusEmojis();
|
|
||||||
accountEmojis = viewData.getAccountEmojis();
|
|
||||||
rebloggedByAccountEmojis = viewData.getRebloggedByAccountEmojis();
|
|
||||||
card = viewData.getCard();
|
|
||||||
isCollapsible = viewData.isCollapsible();
|
|
||||||
isCollapsed = viewData.isCollapsed();
|
|
||||||
poll = viewData.poll;
|
|
||||||
isBot = viewData.isBot();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setId(String id) {
|
|
||||||
this.id = id;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setContent(Spanned content) {
|
|
||||||
this.content = content;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setReblogged(boolean reblogged) {
|
|
||||||
this.reblogged = reblogged;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setFavourited(boolean favourited) {
|
|
||||||
this.favourited = favourited;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setBookmarked(boolean bookmarked) {
|
|
||||||
this.bookmarked = bookmarked;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setMuted(boolean muted) {
|
|
||||||
this.muted = muted;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setSpoilerText(String spoilerText) {
|
|
||||||
this.spoilerText = spoilerText;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setVisibility(Status.Visibility visibility) {
|
|
||||||
this.visibility = visibility;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setAttachments(List<Attachment> attachments) {
|
|
||||||
this.attachments = attachments;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setRebloggedByUsername(String rebloggedByUsername) {
|
|
||||||
this.rebloggedByUsername = rebloggedByUsername;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setRebloggedAvatar(String rebloggedAvatar) {
|
|
||||||
this.rebloggedAvatar = rebloggedAvatar;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setSensitive(boolean sensitive) {
|
|
||||||
this.isSensitive = sensitive;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setIsExpanded(boolean isExpanded) {
|
|
||||||
this.isExpanded = isExpanded;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setIsShowingSensitiveContent(boolean isShowingSensitiveContent) {
|
|
||||||
this.isShowingContent = isShowingSensitiveContent;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setIsBot(boolean isBot) {
|
|
||||||
this.isBot = isBot;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setUserFullName(String userFullName) {
|
|
||||||
this.userFullName = userFullName;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setNickname(String nickname) {
|
|
||||||
this.nickname = nickname;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setAvatar(String avatar) {
|
|
||||||
this.avatar = avatar;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setCreatedAt(Date createdAt) {
|
|
||||||
this.createdAt = createdAt;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setReblogsCount(int reblogsCount) {
|
|
||||||
this.reblogsCount = reblogsCount;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setFavouritesCount(int favouritesCount) {
|
|
||||||
this.favouritesCount = favouritesCount;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setInReplyToId(String inReplyToId) {
|
|
||||||
this.inReplyToId = inReplyToId;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setMentions(Status.Mention[] mentions) {
|
|
||||||
this.mentions = mentions;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setSenderId(String senderId) {
|
|
||||||
this.senderId = senderId;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setRebloggingEnabled(boolean rebloggingEnabled) {
|
|
||||||
this.rebloggingEnabled = rebloggingEnabled;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setApplication(Status.Application application) {
|
|
||||||
this.application = application;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setStatusEmojis(List<Emoji> emojis) {
|
|
||||||
this.statusEmojis = emojis;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setAccountEmojis(List<Emoji> emojis) {
|
|
||||||
this.accountEmojis = emojis;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setRebloggedByEmojis(List<Emoji> emojis) {
|
|
||||||
this.rebloggedByAccountEmojis = emojis;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setCard(Card card) {
|
|
||||||
this.card = card;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to support collapsing
|
|
||||||
* its content limiting the visible length when collapsed at 500 characters,
|
|
||||||
*
|
|
||||||
* @param collapsible Whether the status should support being collapsed or not.
|
|
||||||
* @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance.
|
|
||||||
*/
|
|
||||||
public Builder setCollapsible(boolean collapsible) {
|
|
||||||
isCollapsible = collapsible;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to start in a collapsed
|
|
||||||
* state, hiding partially the content of the post if it exceeds a certain amount of characters.
|
|
||||||
*
|
|
||||||
* @param collapsed Whether to show the full content of the status or not.
|
|
||||||
* @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance.
|
|
||||||
*/
|
|
||||||
public Builder setCollapsed(boolean collapsed) {
|
|
||||||
isCollapsed = collapsed;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder setPoll(Poll poll) {
|
|
||||||
this.poll = PollViewDataKt.toViewData(poll);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public StatusViewData.Concrete createStatusViewData() {
|
|
||||||
if (this.statusEmojis == null) statusEmojis = Collections.emptyList();
|
|
||||||
if (this.accountEmojis == null) accountEmojis = Collections.emptyList();
|
|
||||||
if (this.createdAt == null) createdAt = new Date();
|
|
||||||
|
|
||||||
return new StatusViewData.Concrete(id, content, reblogged, favourited, bookmarked, muted, spoilerText,
|
|
||||||
visibility, attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded,
|
|
||||||
isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount,
|
|
||||||
favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application,
|
|
||||||
statusEmojis, accountEmojis, rebloggedByAccountEmojis, card, isCollapsible, isCollapsed, poll, isBot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,144 @@
|
||||||
|
/* 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.viewdata
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.Spanned
|
||||||
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Created by charlag on 11/07/2017.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Class to represent data required to display either a notification or a placeholder.
|
||||||
|
* It is either a [StatusViewData.Concrete] or a [StatusViewData.Placeholder].
|
||||||
|
*/
|
||||||
|
sealed class StatusViewData private constructor() {
|
||||||
|
abstract val viewDataId: Long
|
||||||
|
|
||||||
|
data class Concrete(
|
||||||
|
val status: Status,
|
||||||
|
val isExpanded: Boolean,
|
||||||
|
val isShowingContent: Boolean,
|
||||||
|
/**
|
||||||
|
* Specifies whether the content of this post is allowed to be collapsed or if it should show
|
||||||
|
* all content regardless.
|
||||||
|
*
|
||||||
|
* @return Whether the post is collapsible or never collapsed.
|
||||||
|
*/
|
||||||
|
val isCollapsible: Boolean,
|
||||||
|
/**
|
||||||
|
* Specifies whether the content of this post is currently limited in visibility to the first
|
||||||
|
* 500 characters or not.
|
||||||
|
*
|
||||||
|
* @return Whether the post is collapsed or fully expanded.
|
||||||
|
*/
|
||||||
|
/** Whether the status meets the requirement to be collapse */
|
||||||
|
val isCollapsed: Boolean,
|
||||||
|
) : StatusViewData() {
|
||||||
|
override val viewDataId: Long
|
||||||
|
get() = status.id.hashCode().toLong()
|
||||||
|
|
||||||
|
val content: Spanned
|
||||||
|
val spoilerText: String
|
||||||
|
val username: String
|
||||||
|
|
||||||
|
val actionable: Status
|
||||||
|
get() = status.actionableStatus
|
||||||
|
|
||||||
|
val rebloggedAvatar: String?
|
||||||
|
get() = status.reblog?.account?.avatar
|
||||||
|
|
||||||
|
val rebloggingStatus: Status?
|
||||||
|
get() = if (status.reblog != null) status else null
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (Build.VERSION.SDK_INT == 23) {
|
||||||
|
// https://github.com/tuskyapp/Tusky/issues/563
|
||||||
|
this.content = replaceCrashingCharacters(status.actionableStatus.content)
|
||||||
|
this.spoilerText =
|
||||||
|
replaceCrashingCharacters(status.actionableStatus.spoilerText).toString()
|
||||||
|
this.username =
|
||||||
|
replaceCrashingCharacters(status.actionableStatus.account.username).toString()
|
||||||
|
} else {
|
||||||
|
this.content = status.actionableStatus.content
|
||||||
|
this.spoilerText = status.actionableStatus.spoilerText
|
||||||
|
this.username = status.actionableStatus.account.username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SOFT_HYPHEN = '\u00ad'
|
||||||
|
private const val ASCII_HYPHEN = '-'
|
||||||
|
fun replaceCrashingCharacters(content: Spanned): Spanned {
|
||||||
|
return replaceCrashingCharacters(content as CharSequence) as Spanned
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replaceCrashingCharacters(content: CharSequence?): CharSequence? {
|
||||||
|
var replacing = false
|
||||||
|
var builder: SpannableStringBuilder? = null
|
||||||
|
val length = content!!.length
|
||||||
|
for (index in 0 until length) {
|
||||||
|
val character = content[index]
|
||||||
|
|
||||||
|
// If there are more than one or two, switch to a map
|
||||||
|
if (character == SOFT_HYPHEN) {
|
||||||
|
if (!replacing) {
|
||||||
|
replacing = true
|
||||||
|
builder = SpannableStringBuilder(content, 0, index)
|
||||||
|
}
|
||||||
|
builder!!.append(ASCII_HYPHEN)
|
||||||
|
} else if (replacing) {
|
||||||
|
builder!!.append(character)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (replacing) builder else content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val id: String
|
||||||
|
get() = status.id
|
||||||
|
|
||||||
|
/** Helper for Java */
|
||||||
|
fun copyWithStatus(status: Status): Concrete {
|
||||||
|
return copy(status = status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper for Java */
|
||||||
|
fun copyWithExpanded(isExpanded: Boolean): Concrete {
|
||||||
|
return copy(isExpanded = isExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper for Java */
|
||||||
|
fun copyWithShowingContent(isShowingContent: Boolean): Concrete {
|
||||||
|
return copy(isShowingContent = isShowingContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper for Java */
|
||||||
|
fun copyWIthCollapsed(isCollapsed: Boolean): Concrete {
|
||||||
|
return copy(isCollapsed = isCollapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Placeholder(val id: String, val isLoading: Boolean) : StatusViewData() {
|
||||||
|
override val viewDataId: Long
|
||||||
|
get() = id.hashCode().toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun asStatusOrNull() = this as? Concrete
|
||||||
|
|
||||||
|
fun asPlaceholderOrNull() = this as? Placeholder
|
||||||
|
}
|
|
@ -91,7 +91,7 @@ class BottomSheetActivityTest {
|
||||||
"",
|
"",
|
||||||
Status.Visibility.PUBLIC,
|
Status.Visibility.PUBLIC,
|
||||||
ArrayList(),
|
ArrayList(),
|
||||||
arrayOf(),
|
listOf(),
|
||||||
null,
|
null,
|
||||||
pinned = false,
|
pinned = false,
|
||||||
muted = false,
|
muted = false,
|
||||||
|
|
|
@ -1,260 +1,186 @@
|
||||||
package com.keylesspalace.tusky
|
package com.keylesspalace.tusky
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.SpannedString
|
import android.text.SpannedString
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.keylesspalace.tusky.entity.Filter
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
import com.keylesspalace.tusky.entity.Poll
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
import com.keylesspalace.tusky.entity.PollOption
|
import com.keylesspalace.tusky.entity.PollOption
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.fragment.SFragment
|
import com.keylesspalace.tusky.network.FilterModel
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.nhaarman.mockitokotlin2.doReturn
|
||||||
import com.nhaarman.mockitokotlin2.mock
|
import com.nhaarman.mockitokotlin2.mock
|
||||||
import okhttp3.Request
|
import io.reactivex.rxjava3.core.Single
|
||||||
import okio.Timeout
|
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.mockito.Mockito
|
|
||||||
import org.robolectric.Robolectric
|
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import retrofit2.Call
|
|
||||||
import retrofit2.Callback
|
|
||||||
import retrofit2.Response
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@Config(sdk = [28])
|
@Config(sdk = [28])
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class FilterTest {
|
class FilterTest {
|
||||||
|
|
||||||
private val fragment = FakeFragment()
|
lateinit var filterModel: FilterModel
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
|
filterModel = FilterModel()
|
||||||
|
val filters = listOf(
|
||||||
|
Filter(
|
||||||
|
id = "123",
|
||||||
|
phrase = "badWord",
|
||||||
|
context = listOf(Filter.HOME),
|
||||||
|
expiresAt = null,
|
||||||
|
irreversible = false,
|
||||||
|
wholeWord = false
|
||||||
|
),
|
||||||
|
Filter(
|
||||||
|
id = "123",
|
||||||
|
phrase = "badWholeWord",
|
||||||
|
context = listOf(Filter.HOME, Filter.PUBLIC),
|
||||||
|
expiresAt = null,
|
||||||
|
irreversible = false,
|
||||||
|
wholeWord = true
|
||||||
|
),
|
||||||
|
Filter(
|
||||||
|
id = "123",
|
||||||
|
phrase = "@twitter.com",
|
||||||
|
context = listOf(Filter.HOME),
|
||||||
|
expiresAt = null,
|
||||||
|
irreversible = false,
|
||||||
|
wholeWord = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
val controller = Robolectric.buildActivity(FakeActivity::class.java)
|
filterModel.initWithFilters(filters)
|
||||||
val activity = controller.get()
|
|
||||||
|
|
||||||
activity.accountManager = mock()
|
|
||||||
val apiMock = Mockito.mock(MastodonApi::class.java)
|
|
||||||
Mockito.`when`(apiMock.getFilters()).thenReturn(object: Call<List<Filter>> {
|
|
||||||
override fun isExecuted(): Boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
override fun clone(): Call<List<Filter>> {
|
|
||||||
throw Error("not implemented")
|
|
||||||
}
|
|
||||||
override fun isCanceled(): Boolean {
|
|
||||||
throw Error("not implemented")
|
|
||||||
}
|
|
||||||
override fun cancel() {
|
|
||||||
throw Error("not implemented")
|
|
||||||
}
|
|
||||||
override fun execute(): Response<List<Filter>> {
|
|
||||||
throw Error("not implemented")
|
|
||||||
}
|
|
||||||
override fun request(): Request {
|
|
||||||
throw Error("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun enqueue(callback: Callback<List<Filter>>) {
|
|
||||||
callback.onResponse(
|
|
||||||
this,
|
|
||||||
Response.success(
|
|
||||||
listOf(
|
|
||||||
Filter(
|
|
||||||
id = "123",
|
|
||||||
phrase = "badWord",
|
|
||||||
context = listOf(Filter.HOME),
|
|
||||||
expiresAt = null,
|
|
||||||
irreversible = false,
|
|
||||||
wholeWord = false
|
|
||||||
),
|
|
||||||
Filter(
|
|
||||||
id = "123",
|
|
||||||
phrase = "badWholeWord",
|
|
||||||
context = listOf(Filter.HOME, Filter.PUBLIC),
|
|
||||||
expiresAt = null,
|
|
||||||
irreversible = false,
|
|
||||||
wholeWord = true
|
|
||||||
),
|
|
||||||
Filter(
|
|
||||||
id = "123",
|
|
||||||
phrase = "wrongContext",
|
|
||||||
context = listOf(Filter.PUBLIC),
|
|
||||||
expiresAt = null,
|
|
||||||
irreversible = false,
|
|
||||||
wholeWord = true
|
|
||||||
),
|
|
||||||
Filter(
|
|
||||||
id = "123",
|
|
||||||
phrase = "@twitter.com",
|
|
||||||
context = listOf(Filter.HOME),
|
|
||||||
expiresAt = null,
|
|
||||||
irreversible = false,
|
|
||||||
wholeWord = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun timeout(): Timeout {
|
|
||||||
throw Error("not implemented")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
activity.mastodonApi = apiMock
|
|
||||||
|
|
||||||
|
|
||||||
controller.create().start()
|
|
||||||
|
|
||||||
fragment.mastodonApi = apiMock
|
|
||||||
|
|
||||||
|
|
||||||
activity.supportFragmentManager.beginTransaction()
|
|
||||||
.replace(R.id.mainDrawerLayout, fragment, "fragment")
|
|
||||||
.commit()
|
|
||||||
|
|
||||||
fragment.reloadFilters(false)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldNotFilter() {
|
fun shouldNotFilter() {
|
||||||
assertFalse(fragment.shouldFilterStatus(
|
assertFalse(
|
||||||
|
filterModel.shouldFilterStatus(
|
||||||
mockStatus(content = "should not be filtered")
|
mockStatus(content = "should not be filtered")
|
||||||
))
|
)
|
||||||
}
|
)
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shouldNotFilter_whenContextDoesNotMatch() {
|
|
||||||
assertFalse(fragment.shouldFilterStatus(
|
|
||||||
mockStatus(content = "one two wrongContext three")
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldFilter_whenContentMatchesBadWord() {
|
fun shouldFilter_whenContentMatchesBadWord() {
|
||||||
assertTrue(fragment.shouldFilterStatus(
|
assertTrue(
|
||||||
|
filterModel.shouldFilterStatus(
|
||||||
mockStatus(content = "one two badWord three")
|
mockStatus(content = "one two badWord three")
|
||||||
))
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldFilter_whenContentMatchesBadWordPart() {
|
fun shouldFilter_whenContentMatchesBadWordPart() {
|
||||||
assertTrue(fragment.shouldFilterStatus(
|
assertTrue(
|
||||||
|
filterModel.shouldFilterStatus(
|
||||||
mockStatus(content = "one two badWordPart three")
|
mockStatus(content = "one two badWordPart three")
|
||||||
))
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldFilter_whenContentMatchesBadWholeWord() {
|
fun shouldFilter_whenContentMatchesBadWholeWord() {
|
||||||
assertTrue(fragment.shouldFilterStatus(
|
assertTrue(
|
||||||
|
filterModel.shouldFilterStatus(
|
||||||
mockStatus(content = "one two badWholeWord three")
|
mockStatus(content = "one two badWholeWord three")
|
||||||
))
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldNotFilter_whenContentDoesNotMatchWholeWord() {
|
fun shouldNotFilter_whenContentDoesNotMatchWholeWord() {
|
||||||
assertFalse(fragment.shouldFilterStatus(
|
assertFalse(
|
||||||
|
filterModel.shouldFilterStatus(
|
||||||
mockStatus(content = "one two badWholeWordTest three")
|
mockStatus(content = "one two badWholeWordTest three")
|
||||||
))
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldFilter_whenSpoilerTextDoesMatch() {
|
fun shouldFilter_whenSpoilerTextDoesMatch() {
|
||||||
assertTrue(fragment.shouldFilterStatus(
|
assertTrue(
|
||||||
|
filterModel.shouldFilterStatus(
|
||||||
mockStatus(
|
mockStatus(
|
||||||
content = "should not be filtered",
|
content = "should not be filtered",
|
||||||
spoilerText = "badWord should be filtered"
|
spoilerText = "badWord should be filtered"
|
||||||
)
|
)
|
||||||
))
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldFilter_whenPollTextDoesMatch() {
|
fun shouldFilter_whenPollTextDoesMatch() {
|
||||||
assertTrue(fragment.shouldFilterStatus(
|
assertTrue(
|
||||||
|
filterModel.shouldFilterStatus(
|
||||||
mockStatus(
|
mockStatus(
|
||||||
content = "should not be filtered",
|
content = "should not be filtered",
|
||||||
spoilerText = "should not be filtered",
|
spoilerText = "should not be filtered",
|
||||||
pollOptions = listOf("should not be filtered", "badWord")
|
pollOptions = listOf("should not be filtered", "badWord")
|
||||||
)
|
)
|
||||||
))
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() {
|
fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() {
|
||||||
assertTrue(fragment.shouldFilterStatus(
|
assertTrue(
|
||||||
|
filterModel.shouldFilterStatus(
|
||||||
mockStatus(content = "one two someone@twitter.com three")
|
mockStatus(content = "one two someone@twitter.com three")
|
||||||
))
|
)
|
||||||
}
|
|
||||||
|
|
||||||
private fun mockStatus(
|
|
||||||
content: String = "",
|
|
||||||
spoilerText: String = "",
|
|
||||||
pollOptions: List<String>? = null
|
|
||||||
): Status {
|
|
||||||
return Status(
|
|
||||||
id = "123",
|
|
||||||
url = "https://mastodon.social/@Tusky/100571663297225812",
|
|
||||||
account = mock(),
|
|
||||||
inReplyToId = null,
|
|
||||||
inReplyToAccountId = null,
|
|
||||||
reblog = null,
|
|
||||||
content = SpannedString(content),
|
|
||||||
createdAt = Date(),
|
|
||||||
emojis = emptyList(),
|
|
||||||
reblogsCount = 0,
|
|
||||||
favouritesCount = 0,
|
|
||||||
reblogged = false,
|
|
||||||
favourited = false,
|
|
||||||
bookmarked = false,
|
|
||||||
sensitive = false,
|
|
||||||
spoilerText = spoilerText,
|
|
||||||
visibility = Status.Visibility.PUBLIC,
|
|
||||||
attachments = arrayListOf(),
|
|
||||||
mentions = emptyArray(),
|
|
||||||
application = null,
|
|
||||||
pinned = false,
|
|
||||||
muted = false,
|
|
||||||
poll = if (pollOptions != null) {
|
|
||||||
Poll(
|
|
||||||
id = "1234",
|
|
||||||
expiresAt = null,
|
|
||||||
expired = false,
|
|
||||||
multiple = false,
|
|
||||||
votesCount = 0,
|
|
||||||
votersCount = 0,
|
|
||||||
options = pollOptions.map {
|
|
||||||
PollOption(it, 0)
|
|
||||||
},
|
|
||||||
voted = false
|
|
||||||
)
|
|
||||||
} else null,
|
|
||||||
card = null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
private fun mockStatus(
|
||||||
|
content: String = "",
|
||||||
class FakeActivity: BottomSheetActivity() {
|
spoilerText: String = "",
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
pollOptions: List<String>? = null
|
||||||
super.onCreate(savedInstanceState)
|
): Status {
|
||||||
setContentView(R.layout.activity_main)
|
return Status(
|
||||||
}
|
id = "123",
|
||||||
}
|
url = "https://mastodon.social/@Tusky/100571663297225812",
|
||||||
|
account = mock(),
|
||||||
class FakeFragment: SFragment() {
|
inReplyToId = null,
|
||||||
override fun removeItem(position: Int) {
|
inReplyToAccountId = null,
|
||||||
|
reblog = null,
|
||||||
|
content = SpannedString(content),
|
||||||
|
createdAt = Date(),
|
||||||
|
emojis = emptyList(),
|
||||||
|
reblogsCount = 0,
|
||||||
|
favouritesCount = 0,
|
||||||
|
reblogged = false,
|
||||||
|
favourited = false,
|
||||||
|
bookmarked = false,
|
||||||
|
sensitive = false,
|
||||||
|
spoilerText = spoilerText,
|
||||||
|
visibility = Status.Visibility.PUBLIC,
|
||||||
|
attachments = arrayListOf(),
|
||||||
|
mentions = listOf(),
|
||||||
|
application = null,
|
||||||
|
pinned = false,
|
||||||
|
muted = false,
|
||||||
|
poll = if (pollOptions != null) {
|
||||||
|
Poll(
|
||||||
|
id = "1234",
|
||||||
|
expiresAt = null,
|
||||||
|
expired = false,
|
||||||
|
multiple = false,
|
||||||
|
votesCount = 0,
|
||||||
|
votersCount = 0,
|
||||||
|
options = pollOptions.map {
|
||||||
|
PollOption(it, 0)
|
||||||
|
},
|
||||||
|
voted = false
|
||||||
|
)
|
||||||
|
} else null,
|
||||||
|
card = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReblog(reblog: Boolean, position: Int) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun filterIsRelevant(filter: Filter): Boolean {
|
|
||||||
return filter.context.contains(Filter.HOME)
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package com.keylesspalace.tusky.fragment
|
package com.keylesspalace.tusky.components.timeline
|
||||||
|
|
||||||
import android.text.SpannableString
|
import android.text.SpannableString
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
@ -10,7 +10,6 @@ import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
||||||
import com.keylesspalace.tusky.entity.Account
|
import com.keylesspalace.tusky.entity.Account
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.repository.*
|
|
||||||
import com.keylesspalace.tusky.util.Either
|
import com.keylesspalace.tusky.util.Either
|
||||||
import com.nhaarman.mockitokotlin2.isNull
|
import com.nhaarman.mockitokotlin2.isNull
|
||||||
import com.nhaarman.mockitokotlin2.verify
|
import com.nhaarman.mockitokotlin2.verify
|
||||||
|
@ -54,10 +53,10 @@ class TimelineRepositoryTest {
|
||||||
|
|
||||||
private val limit = 30
|
private val limit = 30
|
||||||
private val account = AccountEntity(
|
private val account = AccountEntity(
|
||||||
id = 2,
|
id = 2,
|
||||||
accessToken = "token",
|
accessToken = "token",
|
||||||
domain = "domain.com",
|
domain = "domain.com",
|
||||||
isActive = true
|
isActive = true
|
||||||
)
|
)
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
|
@ -74,13 +73,13 @@ class TimelineRepositoryTest {
|
||||||
@Test
|
@Test
|
||||||
fun testNetworkUnbounded() {
|
fun testNetworkUnbounded() {
|
||||||
val statuses = listOf(
|
val statuses = listOf(
|
||||||
makeStatus("3"),
|
makeStatus("3"),
|
||||||
makeStatus("2")
|
makeStatus("2")
|
||||||
)
|
)
|
||||||
whenever(mastodonApi.homeTimeline(isNull(), isNull(), anyInt()))
|
whenever(mastodonApi.homeTimeline(isNull(), isNull(), anyInt()))
|
||||||
.thenReturn(Single.just(Response.success(statuses)))
|
.thenReturn(Single.just(Response.success(statuses)))
|
||||||
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.NETWORK)
|
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.NETWORK)
|
||||||
.blockingGet()
|
.blockingGet()
|
||||||
|
|
||||||
assertEquals(statuses.map(Status::lift), result)
|
assertEquals(statuses.map(Status::lift), result)
|
||||||
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
||||||
|
@ -90,9 +89,9 @@ class TimelineRepositoryTest {
|
||||||
verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id))
|
verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id))
|
||||||
for (status in statuses) {
|
for (status in statuses) {
|
||||||
verify(timelineDao).insertInTransaction(
|
verify(timelineDao).insertInTransaction(
|
||||||
status.toEntity(account.id, gson),
|
status.toEntity(account.id, gson),
|
||||||
status.account.toEntity(account.id, gson),
|
status.account.toEntity(account.id, gson),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
verify(timelineDao).cleanup(anyLong())
|
verify(timelineDao).cleanup(anyLong())
|
||||||
|
@ -102,34 +101,38 @@ class TimelineRepositoryTest {
|
||||||
@Test
|
@Test
|
||||||
fun testNetworkLoadingTopNoGap() {
|
fun testNetworkLoadingTopNoGap() {
|
||||||
val response = listOf(
|
val response = listOf(
|
||||||
makeStatus("4"),
|
makeStatus("4"),
|
||||||
makeStatus("3"),
|
makeStatus("3"),
|
||||||
makeStatus("2")
|
makeStatus("2")
|
||||||
)
|
)
|
||||||
val sinceId = "2"
|
val sinceId = "2"
|
||||||
val sinceIdMinusOne = "1"
|
val sinceIdMinusOne = "1"
|
||||||
whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1))
|
whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1))
|
||||||
.thenReturn(Single.just(Response.success(response)))
|
.thenReturn(Single.just(Response.success(response)))
|
||||||
val result = subject.getStatuses(null, sinceId, sinceIdMinusOne, limit,
|
val result = subject.getStatuses(
|
||||||
TimelineRequestMode.NETWORK)
|
null, sinceId, sinceIdMinusOne, limit,
|
||||||
.blockingGet()
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
.blockingGet()
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
response.subList(0, 2).map(Status::lift),
|
response.subList(0, 2).map(Status::lift),
|
||||||
result
|
result
|
||||||
)
|
)
|
||||||
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
||||||
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
||||||
// We assume for now that overlapped one is inserted but it's not that important
|
// We assume for now that overlapped one is inserted but it's not that important
|
||||||
for (status in response) {
|
for (status in response) {
|
||||||
verify(timelineDao).insertInTransaction(
|
verify(timelineDao).insertInTransaction(
|
||||||
status.toEntity(account.id, gson),
|
status.toEntity(account.id, gson),
|
||||||
status.account.toEntity(account.id, gson),
|
status.account.toEntity(account.id, gson),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id,
|
verify(timelineDao).removeAllPlaceholdersBetween(
|
||||||
response.last().id)
|
account.id, response.first().id,
|
||||||
|
response.last().id
|
||||||
|
)
|
||||||
verify(timelineDao).cleanup(anyLong())
|
verify(timelineDao).cleanup(anyLong())
|
||||||
verifyNoMoreInteractions(timelineDao)
|
verifyNoMoreInteractions(timelineDao)
|
||||||
}
|
}
|
||||||
|
@ -137,16 +140,18 @@ class TimelineRepositoryTest {
|
||||||
@Test
|
@Test
|
||||||
fun testNetworkLoadingTopWithGap() {
|
fun testNetworkLoadingTopWithGap() {
|
||||||
val response = listOf(
|
val response = listOf(
|
||||||
makeStatus("5"),
|
makeStatus("5"),
|
||||||
makeStatus("4")
|
makeStatus("4")
|
||||||
)
|
)
|
||||||
val sinceId = "2"
|
val sinceId = "2"
|
||||||
val sinceIdMinusOne = "1"
|
val sinceIdMinusOne = "1"
|
||||||
whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1))
|
whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1))
|
||||||
.thenReturn(Single.just(Response.success(response)))
|
.thenReturn(Single.just(Response.success(response)))
|
||||||
val result = subject.getStatuses(null, sinceId, sinceIdMinusOne, limit,
|
val result = subject.getStatuses(
|
||||||
TimelineRequestMode.NETWORK)
|
null, sinceId, sinceIdMinusOne, limit,
|
||||||
.blockingGet()
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
.blockingGet()
|
||||||
|
|
||||||
val placeholder = Placeholder("3")
|
val placeholder = Placeholder("3")
|
||||||
assertEquals(response.map(Status::lift) + Either.Left(placeholder), result)
|
assertEquals(response.map(Status::lift) + Either.Left(placeholder), result)
|
||||||
|
@ -154,9 +159,9 @@ class TimelineRepositoryTest {
|
||||||
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
||||||
for (status in response) {
|
for (status in response) {
|
||||||
verify(timelineDao).insertInTransaction(
|
verify(timelineDao).insertInTransaction(
|
||||||
status.toEntity(account.id, gson),
|
status.toEntity(account.id, gson),
|
||||||
status.account.toEntity(account.id, gson),
|
status.account.toEntity(account.id, gson),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
|
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
|
||||||
|
@ -174,36 +179,40 @@ class TimelineRepositoryTest {
|
||||||
// 1
|
// 1
|
||||||
|
|
||||||
val response = listOf(
|
val response = listOf(
|
||||||
makeStatus("5"),
|
makeStatus("5"),
|
||||||
makeStatus("4"),
|
makeStatus("4"),
|
||||||
makeStatus("3"),
|
makeStatus("3"),
|
||||||
makeStatus("2")
|
makeStatus("2")
|
||||||
)
|
)
|
||||||
val sinceId = "2"
|
val sinceId = "2"
|
||||||
val sinceIdMinusOne = "1"
|
val sinceIdMinusOne = "1"
|
||||||
val maxId = "3"
|
val maxId = "3"
|
||||||
whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1))
|
whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1))
|
||||||
.thenReturn(Single.just(Response.success(response)))
|
.thenReturn(Single.just(Response.success(response)))
|
||||||
val result = subject.getStatuses(maxId, sinceId, sinceIdMinusOne, limit,
|
val result = subject.getStatuses(
|
||||||
TimelineRequestMode.NETWORK)
|
maxId, sinceId, sinceIdMinusOne, limit,
|
||||||
.blockingGet()
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
.blockingGet()
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
response.subList(0, response.lastIndex).map(Status::lift),
|
response.subList(0, response.lastIndex).map(Status::lift),
|
||||||
result
|
result
|
||||||
)
|
)
|
||||||
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
||||||
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
||||||
// We assume for now that overlapped one is inserted but it's not that important
|
// We assume for now that overlapped one is inserted but it's not that important
|
||||||
for (status in response) {
|
for (status in response) {
|
||||||
verify(timelineDao).insertInTransaction(
|
verify(timelineDao).insertInTransaction(
|
||||||
status.toEntity(account.id, gson),
|
status.toEntity(account.id, gson),
|
||||||
status.account.toEntity(account.id, gson),
|
status.account.toEntity(account.id, gson),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id,
|
verify(timelineDao).removeAllPlaceholdersBetween(
|
||||||
response.last().id)
|
account.id, response.first().id,
|
||||||
|
response.last().id
|
||||||
|
)
|
||||||
verify(timelineDao).cleanup(anyLong())
|
verify(timelineDao).cleanup(anyLong())
|
||||||
verifyNoMoreInteractions(timelineDao)
|
verifyNoMoreInteractions(timelineDao)
|
||||||
}
|
}
|
||||||
|
@ -218,23 +227,25 @@ class TimelineRepositoryTest {
|
||||||
// 1
|
// 1
|
||||||
|
|
||||||
val response = listOf(
|
val response = listOf(
|
||||||
makeStatus("6"),
|
makeStatus("6"),
|
||||||
makeStatus("5"),
|
makeStatus("5"),
|
||||||
makeStatus("4")
|
makeStatus("4")
|
||||||
)
|
)
|
||||||
val sinceId = "2"
|
val sinceId = "2"
|
||||||
val sinceIdMinusOne = "1"
|
val sinceIdMinusOne = "1"
|
||||||
val maxId = "4"
|
val maxId = "4"
|
||||||
whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1))
|
whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1))
|
||||||
.thenReturn(Single.just(Response.success(response)))
|
.thenReturn(Single.just(Response.success(response)))
|
||||||
val result = subject.getStatuses(maxId, sinceId, sinceIdMinusOne, limit,
|
val result = subject.getStatuses(
|
||||||
TimelineRequestMode.NETWORK)
|
maxId, sinceId, sinceIdMinusOne, limit,
|
||||||
.blockingGet()
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
.blockingGet()
|
||||||
|
|
||||||
val placeholder = Placeholder("3")
|
val placeholder = Placeholder("3")
|
||||||
assertEquals(
|
assertEquals(
|
||||||
response.map(Status::lift) + Either.Left(placeholder),
|
response.map(Status::lift) + Either.Left(placeholder),
|
||||||
result
|
result
|
||||||
)
|
)
|
||||||
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
||||||
// We assume for now that overlapped one is inserted but it's not that important
|
// We assume for now that overlapped one is inserted but it's not that important
|
||||||
|
@ -243,13 +254,15 @@ class TimelineRepositoryTest {
|
||||||
|
|
||||||
for (status in response) {
|
for (status in response) {
|
||||||
verify(timelineDao).insertInTransaction(
|
verify(timelineDao).insertInTransaction(
|
||||||
status.toEntity(account.id, gson),
|
status.toEntity(account.id, gson),
|
||||||
status.account.toEntity(account.id, gson),
|
status.account.toEntity(account.id, gson),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id,
|
verify(timelineDao).removeAllPlaceholdersBetween(
|
||||||
response.last().id)
|
account.id, response.first().id,
|
||||||
|
response.last().id
|
||||||
|
)
|
||||||
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
|
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
|
||||||
verify(timelineDao).cleanup(anyLong())
|
verify(timelineDao).cleanup(anyLong())
|
||||||
verifyNoMoreInteractions(timelineDao)
|
verifyNoMoreInteractions(timelineDao)
|
||||||
|
@ -265,11 +278,11 @@ class TimelineRepositoryTest {
|
||||||
dbResult.account = status.account.toEntity(account.id, gson)
|
dbResult.account = status.account.toEntity(account.id, gson)
|
||||||
|
|
||||||
whenever(mastodonApi.homeTimeline(any(), any(), any()))
|
whenever(mastodonApi.homeTimeline(any(), any(), any()))
|
||||||
.thenReturn(Single.just(Response.success((listOf(status)))))
|
.thenReturn(Single.just(Response.success((listOf(status)))))
|
||||||
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
|
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
|
||||||
.thenReturn(Single.just(listOf(dbResult)))
|
.thenReturn(Single.just(listOf(dbResult)))
|
||||||
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
|
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
|
||||||
.blockingGet()
|
.blockingGet()
|
||||||
assertEquals(listOf(status, dbStatus).map(Status::lift), result)
|
assertEquals(listOf(status, dbStatus).map(Status::lift), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -283,60 +296,60 @@ class TimelineRepositoryTest {
|
||||||
dbResult2.status = Placeholder("1").toEntity(account.id)
|
dbResult2.status = Placeholder("1").toEntity(account.id)
|
||||||
|
|
||||||
whenever(mastodonApi.homeTimeline(any(), any(), any()))
|
whenever(mastodonApi.homeTimeline(any(), any(), any()))
|
||||||
.thenReturn(Single.just(Response.success(listOf(status))))
|
.thenReturn(Single.just(Response.success(listOf(status))))
|
||||||
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
|
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
|
||||||
.thenReturn(Single.just(listOf(dbResult, dbResult2)))
|
.thenReturn(Single.just(listOf(dbResult, dbResult2)))
|
||||||
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
|
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
|
||||||
.blockingGet()
|
.blockingGet()
|
||||||
assertEquals(listOf(status).map(Status::lift), result)
|
assertEquals(listOf(status).map(Status::lift), result)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun makeStatus(id: String, account: Account = makeAccount(id)): Status {
|
fun makeAccount(id: String): Account {
|
||||||
return Status(
|
return Account(
|
||||||
id = id,
|
id = id,
|
||||||
account = account,
|
localUsername = "test$id",
|
||||||
content = SpannableString("hello$id"),
|
username = "test$id@example.com",
|
||||||
createdAt = Date(),
|
displayName = "Example Account $id",
|
||||||
emojis = listOf(),
|
note = SpannableString("Note! $id"),
|
||||||
reblogsCount = 3,
|
url = "https://example.com/@test$id",
|
||||||
favouritesCount = 5,
|
avatar = "avatar$id",
|
||||||
sensitive = false,
|
header = "Header$id",
|
||||||
visibility = Status.Visibility.PUBLIC,
|
followersCount = 300,
|
||||||
spoilerText = "",
|
followingCount = 400,
|
||||||
reblogged = true,
|
statusesCount = 1000,
|
||||||
favourited = false,
|
bot = false,
|
||||||
bookmarked = false,
|
emojis = listOf(),
|
||||||
attachments = ArrayList(),
|
fields = null,
|
||||||
mentions = arrayOf(),
|
source = null
|
||||||
application = null,
|
)
|
||||||
inReplyToAccountId = null,
|
}
|
||||||
inReplyToId = null,
|
|
||||||
pinned = false,
|
|
||||||
muted = false,
|
|
||||||
reblog = null,
|
|
||||||
url = "http://example.com/statuses/$id",
|
|
||||||
poll = null,
|
|
||||||
card = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makeAccount(id: String): Account {
|
fun makeStatus(id: String, account: Account = makeAccount(id)): Status {
|
||||||
return Account(
|
return Status(
|
||||||
id = id,
|
id = id,
|
||||||
localUsername = "test$id",
|
account = account,
|
||||||
username = "test$id@example.com",
|
content = SpannableString("hello$id"),
|
||||||
displayName = "Example Account $id",
|
createdAt = Date(),
|
||||||
note = SpannableString("Note! $id"),
|
emojis = listOf(),
|
||||||
url = "https://example.com/@test$id",
|
reblogsCount = 3,
|
||||||
avatar = "avatar$id",
|
favouritesCount = 5,
|
||||||
header = "Header$id",
|
sensitive = false,
|
||||||
followersCount = 300,
|
visibility = Status.Visibility.PUBLIC,
|
||||||
followingCount = 400,
|
spoilerText = "",
|
||||||
statusesCount = 1000,
|
reblogged = true,
|
||||||
bot = false,
|
favourited = false,
|
||||||
emojis = listOf(),
|
bookmarked = false,
|
||||||
fields = null,
|
attachments = ArrayList(),
|
||||||
source = null
|
mentions = listOf(),
|
||||||
)
|
application = null,
|
||||||
}
|
inReplyToAccountId = null,
|
||||||
}
|
inReplyToId = null,
|
||||||
|
pinned = false,
|
||||||
|
muted = false,
|
||||||
|
reblog = null,
|
||||||
|
url = "http://example.com/statuses/$id",
|
||||||
|
poll = null,
|
||||||
|
card = null
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,783 @@
|
||||||
|
package com.keylesspalace.tusky.components.timeline
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
|
import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Companion.LOAD_AT_ONCE
|
||||||
|
import com.keylesspalace.tusky.db.AccountEntity
|
||||||
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
|
import com.keylesspalace.tusky.entity.PollOption
|
||||||
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import com.keylesspalace.tusky.network.FilterModel
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.keylesspalace.tusky.network.TimelineCases
|
||||||
|
import com.keylesspalace.tusky.util.Either
|
||||||
|
import com.keylesspalace.tusky.util.toViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.nhaarman.mockitokotlin2.*
|
||||||
|
import io.reactivex.rxjava3.annotations.NonNull
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.observers.TestObserver
|
||||||
|
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.shadows.ShadowLog
|
||||||
|
import retrofit2.Response
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
|
||||||
|
@Config(sdk = [29])
|
||||||
|
class TimelineViewModelTest {
|
||||||
|
lateinit var timelineRepository: TimelineRepository
|
||||||
|
lateinit var timelineCases: TimelineCases
|
||||||
|
lateinit var mastodonApi: MastodonApi
|
||||||
|
lateinit var eventHub: EventHub
|
||||||
|
lateinit var viewModel: TimelineViewModel
|
||||||
|
lateinit var accountManager: AccountManager
|
||||||
|
lateinit var sharedPreference: SharedPreferences
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
ShadowLog.stream = System.out
|
||||||
|
timelineRepository = mock()
|
||||||
|
timelineCases = mock()
|
||||||
|
mastodonApi = mock()
|
||||||
|
eventHub = mock {
|
||||||
|
on { events } doReturn Observable.never()
|
||||||
|
}
|
||||||
|
val account = AccountEntity(
|
||||||
|
0,
|
||||||
|
"domain",
|
||||||
|
"accessToken",
|
||||||
|
isActive = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
accountManager = mock {
|
||||||
|
on { activeAccount } doReturn account
|
||||||
|
}
|
||||||
|
sharedPreference = mock()
|
||||||
|
viewModel = TimelineViewModel(
|
||||||
|
timelineRepository,
|
||||||
|
timelineCases,
|
||||||
|
mastodonApi,
|
||||||
|
eventHub,
|
||||||
|
accountManager,
|
||||||
|
sharedPreference,
|
||||||
|
FilterModel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loadInitial, home, without cache, empty response`() {
|
||||||
|
val initialResponse = listOf<Status>()
|
||||||
|
setCachedResponse(initialResponse)
|
||||||
|
|
||||||
|
// loadAbove -> loadBelow
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
maxId = null,
|
||||||
|
sinceId = null,
|
||||||
|
sincedIdMinusOne = null,
|
||||||
|
requestMode = TimelineRequestMode.ANY,
|
||||||
|
limit = LOAD_AT_ONCE
|
||||||
|
)
|
||||||
|
).thenReturn(Single.just(listOf()))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(timelineRepository).getStatuses(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.ANY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loadInitial, home, without cache, single item in response`() {
|
||||||
|
setCachedResponse(listOf())
|
||||||
|
|
||||||
|
val status = makeStatus("1")
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
eq(LOAD_AT_ONCE),
|
||||||
|
eq(TimelineRequestMode.ANY)
|
||||||
|
)
|
||||||
|
).thenReturn(
|
||||||
|
Single.just(
|
||||||
|
listOf(
|
||||||
|
Either.Right(status)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val updates = viewModel.viewUpdates.test()
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(timelineRepository).getStatuses(
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
eq(LOAD_AT_ONCE),
|
||||||
|
eq(TimelineRequestMode.ANY)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertViewUpdated(updates)
|
||||||
|
|
||||||
|
assertHasList(listOf(status).toViewData())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loadInitial, list`() {
|
||||||
|
val listId = "listId"
|
||||||
|
viewModel.init(TimelineViewModel.Kind.LIST, listId, listOf())
|
||||||
|
val status = makeStatus("1")
|
||||||
|
|
||||||
|
whenever(
|
||||||
|
mastodonApi.listTimeline(
|
||||||
|
listId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
)
|
||||||
|
).thenReturn(
|
||||||
|
Single.just(
|
||||||
|
Response.success(
|
||||||
|
listOf(
|
||||||
|
status
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val updates = viewModel.viewUpdates.test()
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial().join()
|
||||||
|
}
|
||||||
|
assertViewUpdated(updates)
|
||||||
|
|
||||||
|
assertHasList(listOf(status).toViewData())
|
||||||
|
assertFalse("loading", viewModel.isLoadingInitially)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loadInitial, home, without cache, error on load`() {
|
||||||
|
setCachedResponse(listOf())
|
||||||
|
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
maxId = null,
|
||||||
|
sinceId = null,
|
||||||
|
sincedIdMinusOne = null,
|
||||||
|
limit = LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.ANY,
|
||||||
|
)
|
||||||
|
).thenReturn(Single.error(IOException("test")))
|
||||||
|
|
||||||
|
val updates = viewModel.viewUpdates.test()
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(timelineRepository).getStatuses(
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
eq(LOAD_AT_ONCE),
|
||||||
|
eq(TimelineRequestMode.ANY)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertViewUpdated(updates)
|
||||||
|
|
||||||
|
assertHasList(listOf())
|
||||||
|
assertEquals(TimelineViewModel.FailureReason.NETWORK, viewModel.failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loadInitial, home, with cache, error on load above`() {
|
||||||
|
val statuses = (5 downTo 1).map { makeStatus(it.toString()) }
|
||||||
|
setCachedResponse(statuses)
|
||||||
|
setInitialRefresh("6", statuses)
|
||||||
|
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
maxId = null,
|
||||||
|
sinceId = "5",
|
||||||
|
sincedIdMinusOne = "4",
|
||||||
|
limit = LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.NETWORK,
|
||||||
|
)
|
||||||
|
).thenReturn(Single.error(IOException("test")))
|
||||||
|
|
||||||
|
val updates = viewModel.viewUpdates.test()
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertViewUpdated(updates)
|
||||||
|
|
||||||
|
assertHasList(statuses.toViewData())
|
||||||
|
// No failure set since we had statuses
|
||||||
|
assertNull(viewModel.failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loadInitial, home, with cache, error on refresh`() {
|
||||||
|
val statuses = (5 downTo 2).map { makeStatus(it.toString()) }
|
||||||
|
setCachedResponse(statuses)
|
||||||
|
|
||||||
|
// Error on refreshing cached
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
maxId = "6",
|
||||||
|
sinceId = null,
|
||||||
|
sincedIdMinusOne = null,
|
||||||
|
limit = LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.NETWORK,
|
||||||
|
)
|
||||||
|
).thenReturn(Single.error(IOException("test")))
|
||||||
|
|
||||||
|
// Empty on loading above
|
||||||
|
setLoadAbove("5", "4", listOf())
|
||||||
|
|
||||||
|
val updates = viewModel.viewUpdates.test()
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertViewUpdated(updates)
|
||||||
|
|
||||||
|
assertHasList(statuses.toViewData())
|
||||||
|
assertNull(viewModel.failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loads above cached`() {
|
||||||
|
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) }
|
||||||
|
setCachedResponse(cachedStatuses)
|
||||||
|
setInitialRefresh("6", cachedStatuses)
|
||||||
|
|
||||||
|
val additionalStatuses = (10 downTo 6)
|
||||||
|
.map { makeStatus(it.toString()) }
|
||||||
|
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
null,
|
||||||
|
"5",
|
||||||
|
"4",
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
).thenReturn(Single.just(additionalStatuses.toEitherList()))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
// We could also check refresh progress here but it's a bit cumbersome
|
||||||
|
|
||||||
|
assertHasList(additionalStatuses.plus(cachedStatuses).toViewData())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun refresh() {
|
||||||
|
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) }
|
||||||
|
setCachedResponse(cachedStatuses)
|
||||||
|
setInitialRefresh("6", cachedStatuses)
|
||||||
|
|
||||||
|
val additionalStatuses = listOf(makeStatus("6"))
|
||||||
|
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
null,
|
||||||
|
"5",
|
||||||
|
"4",
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
).thenReturn(Single.just(additionalStatuses.toEitherList()))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInvocations(timelineRepository)
|
||||||
|
|
||||||
|
val newStatuses = (8 downTo 7).map { makeStatus(it.toString()) }
|
||||||
|
|
||||||
|
// Loading above the cached manually
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
null,
|
||||||
|
"6",
|
||||||
|
"5",
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
).thenReturn(Single.just(newStatuses.toEitherList()))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
val allStatuses = newStatuses + additionalStatuses + cachedStatuses
|
||||||
|
assertHasList(allStatuses.toViewData())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refresh failed`() {
|
||||||
|
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) }
|
||||||
|
setCachedResponse(cachedStatuses)
|
||||||
|
setInitialRefresh("6", cachedStatuses)
|
||||||
|
setLoadAbove("5", "4", listOf())
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInvocations(timelineRepository)
|
||||||
|
|
||||||
|
// Loading above the cached manually
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
null,
|
||||||
|
"6",
|
||||||
|
"5",
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
).thenReturn(Single.error(IOException("test")))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.refresh().join()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertHasList(cachedStatuses.map { it.toViewData(false, false) })
|
||||||
|
assertFalse("refreshing", viewModel.isRefreshing)
|
||||||
|
assertNull("failure is not set", viewModel.failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loadMore() {
|
||||||
|
val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) }
|
||||||
|
setCachedResponse(cachedStatuses)
|
||||||
|
setInitialRefresh("11", cachedStatuses)
|
||||||
|
|
||||||
|
// Nothing above
|
||||||
|
setLoadAbove("10", "9", listOf())
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial().join()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInvocations(timelineRepository)
|
||||||
|
|
||||||
|
val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) }
|
||||||
|
|
||||||
|
// Loading below the cached
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
"5",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.ANY
|
||||||
|
)
|
||||||
|
).thenReturn(Single.just(oldStatuses.toEitherList()))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadMore().join()
|
||||||
|
}
|
||||||
|
|
||||||
|
val allStatuses = cachedStatuses + oldStatuses
|
||||||
|
assertHasList(allStatuses.toViewData())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loadMore parallel`() {
|
||||||
|
val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) }
|
||||||
|
setCachedResponse(cachedStatuses)
|
||||||
|
setInitialRefresh("11", cachedStatuses)
|
||||||
|
|
||||||
|
// Nothing above
|
||||||
|
setLoadAbove("10", "9", listOf())
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial().join()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInvocations(timelineRepository)
|
||||||
|
|
||||||
|
val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) }
|
||||||
|
|
||||||
|
val responseSubject = PublishSubject.create<List<TimelineStatus>>()
|
||||||
|
// Loading below the cached
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
"5",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.ANY
|
||||||
|
)
|
||||||
|
).thenReturn(responseSubject.firstOrError())
|
||||||
|
|
||||||
|
clearInvocations(timelineRepository)
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
// Trigger them in parallel
|
||||||
|
val job1 = viewModel.loadMore()
|
||||||
|
val job2 = viewModel.loadMore()
|
||||||
|
// Send the response
|
||||||
|
responseSubject.onNext(oldStatuses.toEitherList())
|
||||||
|
// Wait for both
|
||||||
|
job1.join()
|
||||||
|
job2.join()
|
||||||
|
}
|
||||||
|
|
||||||
|
val allStatuses = cachedStatuses + oldStatuses
|
||||||
|
assertHasList(allStatuses.toViewData())
|
||||||
|
|
||||||
|
verify(timelineRepository, times(1)).getStatuses(
|
||||||
|
"5",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.ANY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loadMore failed`() {
|
||||||
|
val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) }
|
||||||
|
setCachedResponse(cachedStatuses)
|
||||||
|
setInitialRefresh("11", cachedStatuses)
|
||||||
|
|
||||||
|
// Nothing above
|
||||||
|
setLoadAbove("10", "9", listOf())
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial().join()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInvocations(timelineRepository)
|
||||||
|
|
||||||
|
// Loading below the cached
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
"5",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.ANY
|
||||||
|
)
|
||||||
|
).thenReturn(Single.error(IOException("test")))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadMore().join()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertHasList(cachedStatuses.toViewData())
|
||||||
|
|
||||||
|
// Check that we can still load after that
|
||||||
|
|
||||||
|
val oldStatuses = listOf(makeStatus("4"))
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
"5",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.ANY
|
||||||
|
)
|
||||||
|
).thenReturn(Single.just(oldStatuses.toEitherList()))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadMore().join()
|
||||||
|
}
|
||||||
|
assertHasList((cachedStatuses + oldStatuses).toViewData())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loadGap() {
|
||||||
|
val status5 = makeStatus("5")
|
||||||
|
val status4 = makeStatus("4")
|
||||||
|
val status3 = makeStatus("3")
|
||||||
|
val status1 = makeStatus("1")
|
||||||
|
|
||||||
|
val cachedStatuses: List<TimelineStatus> = listOf(
|
||||||
|
Either.Right(status5),
|
||||||
|
Either.Left(Placeholder("4")),
|
||||||
|
Either.Right(status1)
|
||||||
|
)
|
||||||
|
val laterFetchedStatuses = listOf<TimelineStatus>(
|
||||||
|
Either.Right(status4),
|
||||||
|
Either.Right(status3),
|
||||||
|
)
|
||||||
|
|
||||||
|
setCachedResponseWithGaps(cachedStatuses)
|
||||||
|
setInitialRefreshWithGaps("6", cachedStatuses)
|
||||||
|
|
||||||
|
// Nothing above
|
||||||
|
setLoadAbove("5", items = listOf())
|
||||||
|
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
"5",
|
||||||
|
"1",
|
||||||
|
null,
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
).thenReturn(Single.just(laterFetchedStatuses))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial().join()
|
||||||
|
|
||||||
|
viewModel.loadGap(1).join()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertHasList(
|
||||||
|
listOf(
|
||||||
|
status5,
|
||||||
|
status4,
|
||||||
|
status3,
|
||||||
|
status1
|
||||||
|
).toViewData()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loadGap failed`() {
|
||||||
|
val status5 = makeStatus("5")
|
||||||
|
val status1 = makeStatus("1")
|
||||||
|
|
||||||
|
val cachedStatuses: List<TimelineStatus> = listOf(
|
||||||
|
Either.Right(status5),
|
||||||
|
Either.Left(Placeholder("4")),
|
||||||
|
Either.Right(status1)
|
||||||
|
)
|
||||||
|
setCachedResponseWithGaps(cachedStatuses)
|
||||||
|
setInitialRefreshWithGaps("6", cachedStatuses)
|
||||||
|
|
||||||
|
setLoadAbove("5", items = listOf())
|
||||||
|
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
"5",
|
||||||
|
"1",
|
||||||
|
null,
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
).thenReturn(Single.error(IOException("test")))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.loadInitial().join()
|
||||||
|
|
||||||
|
viewModel.loadGap(1).join()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertHasList(
|
||||||
|
listOf(
|
||||||
|
status5.toViewData(false, false),
|
||||||
|
StatusViewData.Placeholder("4", false),
|
||||||
|
status1.toViewData(false, false),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun favorite() {
|
||||||
|
val status5 = makeStatus("5")
|
||||||
|
val status4 = makeStatus("4")
|
||||||
|
val status3 = makeStatus("3")
|
||||||
|
val statuses = listOf(status5, status4, status3)
|
||||||
|
setCachedResponse(statuses)
|
||||||
|
setInitialRefresh("6", statuses)
|
||||||
|
setLoadAbove("5", "4", listOf())
|
||||||
|
|
||||||
|
runBlocking { viewModel.loadInitial() }
|
||||||
|
|
||||||
|
whenever(timelineCases.favourite("4", true))
|
||||||
|
.thenReturn(Single.just(status4.copy(favourited = true)))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.favorite(true, 1).join()
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(timelineCases).favourite("4", true)
|
||||||
|
|
||||||
|
assertHasList(listOf(status5, status4.copy(favourited = true), status3).toViewData())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reblog() {
|
||||||
|
val status5 = makeStatus("5")
|
||||||
|
val status4 = makeStatus("4")
|
||||||
|
val status3 = makeStatus("3")
|
||||||
|
val statuses = listOf(status5, status4, status3)
|
||||||
|
setCachedResponse(statuses)
|
||||||
|
setInitialRefresh("6", statuses)
|
||||||
|
setLoadAbove("5", "4", listOf())
|
||||||
|
|
||||||
|
runBlocking { viewModel.loadInitial() }
|
||||||
|
|
||||||
|
whenever(timelineCases.reblog("4", true))
|
||||||
|
.thenReturn(Single.just(status4.copy(reblogged = true)))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.reblog(true, 1).join()
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(timelineCases).reblog("4", true)
|
||||||
|
|
||||||
|
assertHasList(listOf(status5, status4.copy(reblogged = true), status3).toViewData())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bookmark() {
|
||||||
|
val status5 = makeStatus("5")
|
||||||
|
val status4 = makeStatus("4")
|
||||||
|
val status3 = makeStatus("3")
|
||||||
|
val statuses = listOf(status5, status4, status3)
|
||||||
|
setCachedResponse(statuses)
|
||||||
|
setInitialRefresh("6", statuses)
|
||||||
|
setLoadAbove("5", "4", listOf())
|
||||||
|
|
||||||
|
runBlocking { viewModel.loadInitial() }
|
||||||
|
|
||||||
|
whenever(timelineCases.bookmark("4", true))
|
||||||
|
.thenReturn(Single.just(status4.copy(bookmarked = true)))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.bookmark(true, 1).join()
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(timelineCases).bookmark("4", true)
|
||||||
|
|
||||||
|
assertHasList(listOf(status5, status4.copy(bookmarked = true), status3).toViewData())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun voteInPoll() {
|
||||||
|
val status5 = makeStatus("5")
|
||||||
|
val poll = Poll(
|
||||||
|
"1",
|
||||||
|
expiresAt = null,
|
||||||
|
expired = false,
|
||||||
|
multiple = false,
|
||||||
|
votersCount = 1,
|
||||||
|
votesCount = 1,
|
||||||
|
voted = false,
|
||||||
|
options = listOf(PollOption("1", 1), PollOption("2", 2)),
|
||||||
|
)
|
||||||
|
val status4 = makeStatus("4").copy(poll = poll)
|
||||||
|
val status3 = makeStatus("3")
|
||||||
|
val statuses = listOf(status5, status4, status3)
|
||||||
|
setCachedResponse(statuses)
|
||||||
|
setInitialRefresh("6", statuses)
|
||||||
|
setLoadAbove("5", "4", listOf())
|
||||||
|
|
||||||
|
runBlocking { viewModel.loadInitial() }
|
||||||
|
|
||||||
|
val votedPoll = poll.votedCopy(listOf(0))
|
||||||
|
whenever(timelineCases.voteInPoll("4", poll.id, listOf(0)))
|
||||||
|
.thenReturn(Single.just(votedPoll))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
viewModel.voteInPoll(1, listOf(0)).join()
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(timelineCases).voteInPoll("4", poll.id, listOf(0))
|
||||||
|
|
||||||
|
assertHasList(listOf(status5, status4.copy(poll = votedPoll), status3).toViewData())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setLoadAbove(
|
||||||
|
above: String,
|
||||||
|
aboveMinusOne: String? = null,
|
||||||
|
items: List<TimelineStatus>
|
||||||
|
) {
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
null,
|
||||||
|
above,
|
||||||
|
aboveMinusOne,
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
).thenReturn(Single.just(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun assertHasList(aList: List<StatusViewData>) {
|
||||||
|
assertEquals(
|
||||||
|
aList,
|
||||||
|
viewModel.statuses.toList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertViewUpdated(updates: @NonNull TestObserver<Unit>) {
|
||||||
|
assertTrue("There were view updates", updates.values().isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInitialRefresh(maxId: String?, statuses: List<Status>) {
|
||||||
|
setInitialRefreshWithGaps(maxId, statuses.toEitherList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCachedResponse(initialResponse: List<Status>) {
|
||||||
|
setCachedResponseWithGaps(initialResponse.toEitherList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCachedResponseWithGaps(initialResponse: List<TimelineStatus>) {
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
eq(LOAD_AT_ONCE),
|
||||||
|
eq(TimelineRequestMode.DISK)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.thenReturn(Single.just(initialResponse))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInitialRefreshWithGaps(maxId: String?, statuses: List<TimelineStatus>) {
|
||||||
|
whenever(
|
||||||
|
timelineRepository.getStatuses(
|
||||||
|
maxId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
LOAD_AT_ONCE,
|
||||||
|
TimelineRequestMode.NETWORK
|
||||||
|
)
|
||||||
|
).thenReturn(Single.just(statuses))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Status>.toViewData(): List<StatusViewData> = map {
|
||||||
|
it.toViewData(
|
||||||
|
alwaysShowSensitiveMedia = false,
|
||||||
|
alwaysOpenSpoiler = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Status>.toEitherList() = map { Either.Right<Placeholder, Status>(it) }
|
||||||
|
}
|
Loading…
Reference in a new issue